Merge remote-tracking branch 'origin/master' into fix/runner-paid-matrix-integrity

* origin/master:
  chore(deps): bump better-auth from 1.7.0 to 1.7.2 (#12565)
  chore(deps-dev): bump @vitejs/plugin-react from 4.7.0 to 6.1.1 (#12566)
  chore(lockfile): refresh pnpm-lock.yaml (#12771)
  chore(deps): bump @aws-sdk/client-s3 from 3.1115.0 to 3.1120.0 (#12567)
  ci(runner): allow trusted branch targets (#12768)
  chore(deps): bump @tanstack/react-query from 5.101.4 to 5.102.8 (#12568)
  chore(deps): bump mermaid from 11.16.1 to 11.17.2 (#12569)

# Conflicts:
#	.github/workflows/runner-full-stack-e2e.yml
This commit is contained in:
Dotta 2026-09-03 12:01:28 -05:00
commit 0f140a3433
13 changed files with 382 additions and 280 deletions

View File

@ -0,0 +1,24 @@
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import assert from 'node:assert/strict';
const workflows = [
'.github/workflows/refresh-lockfile.yml',
'.github/workflows/pr-trusted.yml',
'.github/workflows/docker.yml',
];
test('lockfile repair workflows resolve dependencies instead of updating metadata only', async () => {
for (const workflow of workflows) {
const contents = await readFile(workflow, 'utf8');
const repairCommands = contents
.split('\n')
.filter((line) => line.includes('pnpm install') && line.includes('--no-frozen-lockfile'));
assert.ok(repairCommands.length > 0, `${workflow} must contain a lockfile repair command`);
for (const command of repairCommands) {
assert.match(command, /--ignore-scripts/);
assert.doesNotMatch(command, /--lockfile-only/);
}
}
});

View File

@ -87,7 +87,7 @@ jobs:
- name: Refresh lockfile for Docker build context
run: |
set -euo pipefail
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
pnpm install --ignore-scripts --no-frozen-lockfile
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
@ -281,7 +281,7 @@ jobs:
- name: Refresh lockfile for Docker build context
run: |
set -euo pipefail
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
pnpm install --ignore-scripts --no-frozen-lockfile
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then

View File

@ -337,7 +337,7 @@ jobs:
id: regen_lockfile
run: |
cp pnpm-lock.yaml "$RUNNER_TEMP/pnpm-lock.before.yaml"
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
pnpm install --ignore-scripts --no-frozen-lockfile
if cmp -s "$RUNNER_TEMP/pnpm-lock.before.yaml" pnpm-lock.yaml; then
echo "regenerated=0" >> "$GITHUB_OUTPUT"
else

View File

@ -35,7 +35,7 @@ jobs:
cache: pnpm
- name: Refresh pnpm lockfile
run: pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Fail on unexpected file changes
run: |

View File

@ -5,6 +5,10 @@ on:
- cron: "47 8 * * 0"
workflow_dispatch:
inputs:
target_branch:
description: "Branch in paperclipai/paperclip to test; the trusted workflow still runs from master"
type: string
required: false
all:
description: "Run the complete paid matrix when no narrower selector is supplied"
type: boolean
@ -38,10 +42,10 @@ permissions:
contents: read
concurrency:
group: runner-full-stack-e2e-${{ github.ref }}
# Development-only validation refs supersede older runs on the same ref.
# Preserve every protected default-branch campaign for its paid audit trail.
cancel-in-progress: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }}
group: runner-full-stack-e2e-${{ inputs.target_branch || github.event.repository.default_branch }}
# Development branch campaigns supersede older runs for the same target.
# Preserve every default-branch campaign for its paid audit trail.
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.target_branch != '' && inputs.target_branch != github.event.repository.default_branch }}
jobs:
authorize:
@ -55,6 +59,7 @@ jobs:
test_runner: ${{ steps.runner.outputs.runner }}
max_parallel_default: ${{ steps.runner.outputs.max_parallel_default }}
max_parallel_limit: ${{ steps.runner.outputs.max_parallel_limit }}
target_sha: ${{ steps.target.outputs.sha }}
steps:
- name: Require default branch and allowlisted numeric actor IDs
env:
@ -89,6 +94,27 @@ jobs:
fi
done
- name: Resolve requested repository branch to an immutable commit
id: target
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
TARGET_BRANCH: ${{ inputs.target_branch || github.event.repository.default_branch }}
run: |
set -euo pipefail
if [ -z "$TARGET_BRANCH" ] || [[ "$TARGET_BRANCH" == refs/* ]]; then
echo "target_branch must name a branch in this repository without a refs/ prefix." >&2
exit 1
fi
encoded_branch="$(jq -rn --arg branch "$TARGET_BRANCH" '$branch | @uri')"
target_sha="$(gh api -X GET "repos/$REPOSITORY/branches/$encoded_branch" --jq .commit.sha)"
if ! [[ "$target_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "The requested repository branch did not resolve to a commit." >&2
exit 1
fi
echo "sha=$target_sha" >> "$GITHUB_OUTPUT"
echo "Resolved the requested repository branch to $target_sha."
- name: Select paid test runner
id: runner
env:
@ -133,6 +159,9 @@ jobs:
daytona_image_content_id: ${{ steps.daytona_image_content.outputs.content_id }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.authorize.outputs.target_sha }}
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
@ -241,6 +270,9 @@ jobs:
content_id: ${{ steps.image.outputs.content_id }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.authorize.outputs.target_sha }}
persist-credentials: false
- name: No Daytona image needed
id: local_only
@ -269,6 +301,7 @@ jobs:
NEEDS_DAYTONA: ${{ needs.catalog.outputs.needs_daytona }}
IMAGE_CONTENT_ID: ${{ needs.catalog.outputs.daytona_image_content_id }}
IMAGE_TAG: ghcr.io/paperclipai/paperclip-daytona-runner:e2e-content-${{ needs.catalog.outputs.daytona_image_content_id }}
TARGET_SHA: ${{ needs.authorize.outputs.target_sha }}
run: |
set -euo pipefail
if [ "$NEEDS_DAYTONA" != true ]; then
@ -285,7 +318,7 @@ jobs:
docker buildx build \
--platform linux/amd64 \
--build-arg "PAPERCLIP_RUNNER_CONTENT_ID=${IMAGE_CONTENT_ID}" \
--build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=${GITHUB_SHA}" \
--build-arg "PAPERCLIP_RUNNER_SOURCE_REVISION=${TARGET_SHA}" \
--file docker/daytona-runner/Dockerfile \
--tag "$IMAGE_TAG" \
--push \
@ -558,6 +591,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.authorize.outputs.target_sha }}
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
@ -677,7 +711,7 @@ jobs:
report:
name: Merge and enforce campaign result
if: always() && !cancelled() && needs.catalog.result == 'success'
needs: [catalog, daytona_image, test]
needs: [authorize, catalog, daytona_image, test]
outputs:
history_source_ready: ${{ steps.history_source_ready.outputs.ready }}
runs-on: ubuntu-latest
@ -686,6 +720,10 @@ jobs:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Reporting and sanitization are part of the trusted workflow boundary.
ref: ${{ github.sha }}
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
@ -772,7 +810,7 @@ jobs:
publish_history:
name: Publish pruned immutable history and landing site
needs: [catalog, report]
needs: [authorize, catalog, report]
if: always() && needs.catalog.result == 'success' && needs.report.outputs.history_source_ready == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
@ -786,6 +824,10 @@ jobs:
name: runner-e2e-history
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# Never execute target-controlled publication code with AWS credentials.
ref: ${{ github.sha }}
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:

View File

@ -180,7 +180,7 @@
"@types/node": "^24.0.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"@vitejs/plugin-react": "^6.1.1",
"axe-core": "^4.12.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",

File diff suppressed because it is too large Load Diff

View File

@ -306,8 +306,8 @@ test("the trusted PR workflow regenerates stale stacked lockfiles", () => {
);
assert.match(
workflow,
/pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile/,
"the policy job must validate the complete merge tree instead of only the current PR layer",
/pnpm install --ignore-scripts --no-frozen-lockfile/,
"the policy job must resolve the complete merge tree instead of only updating lockfile metadata",
);
assert.match(
workflow,

View File

@ -44,7 +44,7 @@
"typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1115.0",
"@aws-sdk/client-s3": "^3.1120.0",
"@opentelemetry/api": "^1.9.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
@ -66,7 +66,7 @@
"acpx": "0.13.1",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"better-auth": "1.7.0",
"better-auth": "1.7.2",
"chokidar": "^5.0.0",
"detect-port": "^2.1.0",
"dompurify": "^3.4.13",

View File

@ -271,13 +271,35 @@ auto-stop/archive/delete values remain as cancellation backstops.
## GitHub Actions
`Runner Full-Stack E2E` has only `schedule` and `workflow_dispatch` triggers; it
never runs for a pull request or ordinary push. Because this repository is
public, manual campaigns fail before checkout unless they run from the default
branch and both the original actor and rerun actor have numeric GitHub user IDs
in the non-empty JSON-array repository variable
`RUNNER_E2E_ALLOWED_ACTOR_IDS`. Usernames are intentionally not trusted.
The first scheduled attempt is trusted automation; any human rerun of a
scheduled campaign must pass the triggering-actor allowlist.
never runs for a pull request or ordinary push. Start the trusted workflow from
the default branch. A CODEOWNER can set the optional `target_branch` input to
any branch in `paperclipai/paperclip`. The authorization job resolves that
branch to one immutable commit before any checkout. Catalog, image, and paid
test jobs check out that exact commit. Report sanitization and AWS history
publication explicitly check out the trusted workflow commit. The workflow
definition, runner-group permission, and protected-environment deployment still
come from the default branch. Do not select the target branch in GitHub's **Use
workflow from** control.
Because this repository is public, manual campaigns fail before checkout unless
the trusted workflow runs from the default branch and both the original actor
and rerun actor have numeric GitHub user IDs in the non-empty JSON-array
repository variable `RUNNER_E2E_ALLOWED_ACTOR_IDS`. Keep this stable-ID list in
sync with the owners of `.github/**` in `.github/CODEOWNERS`. Usernames are
intentionally not trusted. The first scheduled attempt is trusted automation;
any human rerun of a scheduled campaign must pass the triggering-actor
allowlist.
For example, this command runs one branch cell through the trusted default-branch
workflow:
```bash
gh workflow run runner-full-stack-e2e.yml \
--ref master \
-f target_branch=fix/example \
-f all=false \
-f id=core-compatibility.runner-codex.local.message-marker
```
Create a protected `runner-e2e-paid` GitHub environment, restrict it to the
default branch, limit environment administration to trusted maintainers, and
@ -307,11 +329,14 @@ it, and require a fresh ephemeral instance for each job so one paid cell cannot
leave state for the next. Provider secrets remain protected by the stable-ID
authorization checks and the default-branch-only `runner-e2e-paid` environment;
the fleet itself is not an authorization boundary. These external fleet controls
are as important as the workflow checks in a public repository.
are as important as the workflow checks in a public repository. A CODEOWNER
dispatch is an explicit authorization to execute the selected repository branch
with the cell's scoped provider credential.
Non-default validation runs share a concurrency key per ref and cancel an older
run when a replacement is dispatched. Protected default-branch paid campaigns
are retained and are never auto-cancelled, preserving their audit trail.
Development branch campaigns share a concurrency key per target branch and
cancel an older run when a replacement is dispatched. Default-branch target
campaigns are retained and are never auto-cancelled, preserving their audit
trail.
GitHub Actions artifacts are access-controlled 30-day operational copies, not
the permanent public history. They retain packaged PNG/WebM and generated

View File

@ -9,15 +9,23 @@ changes.
## GitHub authorization
Set `RUNNER_E2E_ALLOWED_ACTOR_IDS` to a non-empty JSON array of numeric GitHub
user IDs, for example `[123456,789012]`. Resolve each ID from the authenticated
CLI and verify the login before adding it:
user IDs. Keep the list equal to the owners of `.github/**` in
`.github/CODEOWNERS`. For example, use `[123456,789012]`. Resolve each ID from
the authenticated CLI and verify the login before adding it:
```bash
gh api users/LOGIN --jq '{login,id}'
```
The paid workflows reject manual dispatches outside the default branch before
checkout. They verify both the original actor and triggering actor for every
The paid workflows reject manual dispatches when the workflow definition does
not come from the default branch. A trusted dispatcher may name any branch in
`paperclipai/paperclip` as the code under test. The authorization job resolves
that branch through the GitHub API and passes only its immutable commit SHA to
the catalog, image, and paid test checkouts. Report sanitization and AWS history
publication explicitly use the trusted workflow commit. Never run the workflow
definition from the target branch.
The workflows verify both the original actor and triggering actor for every
scheduled or manual attempt, including human reruns. Every
secret-bearing job repeats this check as its first step so GitHub's partial-job
rerun feature cannot bypass a successful predecessor authorization job. The
@ -79,14 +87,23 @@ fresh ephemeral instance for every job, prohibit persistent runner reuse, and
disable interactive SSH/debug access unless a separate incident procedure
explicitly authorizes it.
Changing the runner does not widen secret access. The paid workflow still has
only schedule and manual triggers, requires the protected default branch and
allowlisted stable actor IDs before checkout, repeats that authorization as the
first matrix step, and receives provider credentials only from the protected
Changing the runner does not widen who can authorize secret access. The paid
workflow still has only schedule and manual triggers, requires its trusted
definition to come from the protected default branch, requires allowlisted
stable actor IDs before checkout, and repeats that authorization as the first
matrix step. Provider credentials come only from the protected
`runner-e2e-paid` environment. The fleet selector is an exact workflow literal;
the only repository-controlled input is its boolean rollout switch, so
the only repository-controlled routing input is its boolean rollout switch, so
configuration cannot redirect a secret-bearing job to an arbitrary runner.
The optional target branch is code, not workflow authority. A CODEOWNER who
dispatches a target branch explicitly authorizes that branch's selected test
process to receive the cell's scoped provider credential. The workflow resolves
the target only inside the same repository, pins one SHA for the campaign, and
checks it out only after authorization. Target-controlled code cannot replace
the report sanitizer or the AWS history publisher. Fork refs and
target-controlled workflow definitions do not enter this path.
## AWS OIDC and S3
The AWS role trust policy should accept only GitHub's OIDC audience and the

View File

@ -77,6 +77,13 @@ describe("public repository paid workflow security", () => {
expect(authorizeJob).toContain(
"AWS_PAID_RUNNER_ENABLED: ${{ vars.RUNNER_E2E_AWS_ENABLED }}",
);
expect(authorizeJob).toContain(
"Resolve requested repository branch to an immutable commit",
);
expect(authorizeJob).toContain(
"repos/$REPOSITORY/branches/$encoded_branch",
);
expect(authorizeJob).toContain('echo "sha=$target_sha"');
expect(paidJob).toContain(
"runs-on: ${{ needs.authorize.outputs.test_runner }}",
);
@ -91,7 +98,34 @@ describe("public repository paid workflow security", () => {
'[ "$MAX_PARALLEL" -gt "$MAX_PARALLEL_LIMIT" ]',
);
expect(fullStack).toContain(
"cancel-in-progress: ${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) }}",
"group: runner-full-stack-e2e-${{ inputs.target_branch || github.event.repository.default_branch }}",
);
expect(fullStack).toContain(
"cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.target_branch != '' && inputs.target_branch != github.event.repository.default_branch }}",
);
expect(
fullStack.match(
/ref: \$\{\{ needs\.authorize\.outputs\.target_sha \}\}/g,
),
).toHaveLength(3);
expect(fullStack.match(/ref: \$\{\{ github\.sha \}\}/g)).toHaveLength(2);
expect(fullStack.match(/persist-credentials: false/g)).toHaveLength(5);
expect(fullStack).not.toContain("ref: ${{ inputs.target_branch }}");
expect(fullStack).toContain(
"PAPERCLIP_RUNNER_SOURCE_REVISION=${TARGET_SHA}",
);
const reportJob = fullStack.slice(
fullStack.indexOf(" report:"),
fullStack.indexOf(" publish_history:"),
);
const historyJob = fullStack.slice(fullStack.indexOf(" publish_history:"));
expect(reportJob).toContain("ref: ${{ github.sha }}");
expect(reportJob).not.toContain(
"ref: ${{ needs.authorize.outputs.target_sha }}",
);
expect(historyJob).toContain("ref: ${{ github.sha }}");
expect(historyJob).not.toContain(
"ref: ${{ needs.authorize.outputs.target_sha }}",
);
expect(fullStack).toContain(
"if: always() && !cancelled() && needs.catalog.result == 'success'",

View File

@ -51,7 +51,7 @@
"@paperclipai/shared": "workspace:*",
"@radix-ui/react-slot": "^1.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query": "^5.102.8",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
@ -60,7 +60,7 @@
"i18next": "^26.3.6",
"lexical": "0.48.0",
"lucide-react": "^1.32.0",
"mermaid": "^11.16.1",
"mermaid": "^11.17.2",
"motion": "^12.42.2",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
@ -82,7 +82,7 @@
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^5.2.0",
"@vitejs/plugin-react": "^6.1.1",
"storybook": "10.5.10",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",