diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 8883b360a7..19bf0fda71 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -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: @@ -130,6 +156,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: @@ -233,6 +262,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 @@ -261,6 +293,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 @@ -277,7 +310,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 \ @@ -354,6 +387,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ needs.authorize.outputs.target_sha }} persist-credentials: false - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 @@ -416,7 +450,7 @@ jobs: report: name: Merge and enforce campaign result if: always() && 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 @@ -425,6 +459,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: @@ -511,7 +549,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 @@ -525,6 +563,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: diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index c7d9cb1320..29f8179757 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -266,13 +266,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 @@ -302,11 +324,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 diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index 19f407bb76..d99294444c 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -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 @@ -78,14 +86,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 diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 8b45eee73e..eb0745fb12 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -75,6 +75,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 }}", ); @@ -89,7 +96,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 }}", ); for (const [secret, condition] of Object.entries({ OPENAI_API_KEY: "matrix.credentialName == 'OPENAI_API_KEY'",