From 9cddb532bf1e3ad4e1c756aee6b06d15aa6c0992 Mon Sep 17 00:00:00 2001 From: Myasnikov Daniil Date: Tue, 14 Jul 2026 17:29:19 +0500 Subject: [PATCH] fix(ci): publish "E2E Tests" check-run as the COZYSTACK_CI app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthetic "E2E Tests" required check-run was created with the default GITHUB_TOKEN (the "GitHub Actions" app). A token-created check-run has no owning job, so GitHub files it under an arbitrary GitHub Actions check-suite for the head SHA — in practice the earliest one, the PR-size labeler — so "E2E Tests" rendered under "PR size label". Publish it as the COZYSTACK_CI GitHub App instead (create-github-app-token, scoped to permission-checks: write) so the check lands in the app own dedicated suite. This is also stricter: only the CI app can satisfy the gate. - pull-requests.yaml (e2e-report): mint the app token, pass it to checks.create. - e2e-fork.yaml: split resolve into a verdict decision on the default workflow_run token (keeps the actions:read / pull-requests:read lookups) and an "open" step that publishes the check as the app; report re-mints the token so checks.update runs as the same app that opened the pending run. - docs/agents/e2e-testing.md: document the change and the branch-protection requirement. Requires main branch protection to require "E2E Tests" from the COZYSTACK_CI app id (not app_id 15368). Assisted-By: Claude Co-Authored-By: Claude Opus 4.8 Signed-off-by: Myasnikov Daniil --- .github/workflows/e2e-fork.yaml | 99 ++++++++++++++++++++-------- .github/workflows/pull-requests.yaml | 22 ++++++- 2 files changed, 94 insertions(+), 27 deletions(-) diff --git a/.github/workflows/e2e-fork.yaml b/.github/workflows/e2e-fork.yaml index cc3a259fd..23df525ae 100644 --- a/.github/workflows/e2e-fork.yaml +++ b/.github/workflows/e2e-fork.yaml @@ -58,7 +58,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - checks: write actions: read pull-requests: read outputs: @@ -66,9 +65,13 @@ jobs: pr_number: ${{ steps.r.outputs.pr_number }} head_sha: ${{ steps.r.outputs.head_sha }} run_id: ${{ steps.r.outputs.run_id }} - check_run_id: ${{ steps.r.outputs.check_run_id }} + check_run_id: ${{ steps.open.outputs.check_run_id }} steps: - - name: Resolve and open check-run + # Decide the verdict using this step's default (workflow_run) token, which + # already has the actions:read / pull-requests:read scopes these lookups + # need. It creates NO check-run — the next step publishes the verdict as + # the COZYSTACK_CI app so the app token stays scoped to checks:write only. + - name: Resolve fork PR and decide verdict id: r uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: @@ -87,19 +90,18 @@ jobs: core.setOutput('head_sha', HEAD_SHA); core.setOutput('run_id', String(RUN_ID)); - const conclude = async (conclusion, summary) => { - const cr = await github.rest.checks.create({ - owner, repo, name: 'E2E Tests', head_sha: HEAD_SHA, - status: 'completed', conclusion, - output: { title: 'E2E Tests', summary }, - }); - return cr.data.id; + // Record a terminal verdict for the `open` step to publish as a + // completed check-run. This step never creates a check itself. + const decide = (conclusion, summary) => { + core.setOutput('verdict_status', 'completed'); + core.setOutput('verdict_conclusion', conclusion); + core.setOutput('verdict_summary', summary); + core.setOutput('should_run', 'false'); }; // Fork build did not succeed → fail the required check (fail-closed). if (CONCLUSION !== 'success') { - await conclude('failure', `Pull Request workflow concluded '${CONCLUSION}'; e2e not run`); - core.setOutput('should_run', 'false'); + decide('failure', `Pull Request workflow concluded '${CONCLUSION}'; e2e not run`); return; } @@ -114,8 +116,7 @@ jobs: const pr = assoc.data.find(p => p.state === 'open'); const prNumber = pr ? String(pr.number) : ''; if (!/^[0-9]+$/.test(prNumber)) { - await conclude('failure', `no open PR for ${HEAD_SHA}`); - core.setOutput('should_run', 'false'); + decide('failure', `no open PR for ${HEAD_SHA}`); return; } core.setOutput('pr_number', prNumber); @@ -130,8 +131,7 @@ jobs: }); const hasNonDocs = files.some(f => !f.filename.startsWith('docs/')); if (!hasNonDocs) { - await conclude('success', 'docs-only PR (verified via PR file list) — e2e not required'); - core.setOutput('should_run', 'false'); + decide('success', 'docs-only PR (verified via PR file list) — e2e not required'); return; } @@ -143,20 +143,55 @@ jobs: { owner, repo, run_id: RUN_ID, per_page: 100 }, ); if (!arts.some(a => a.name === 'pr-patch')) { - await conclude('failure', 'expected pr-patch artifact is missing — the fork build did not complete'); - core.setOutput('should_run', 'false'); + decide('failure', 'expected pr-patch artifact is missing — the fork build did not complete'); return; } - // Real run: open an in-progress check-run; `report` concludes it. - const cr = await github.rest.checks.create({ - owner, repo, name: 'E2E Tests', head_sha: HEAD_SHA, - status: 'in_progress', - output: { title: 'E2E Tests', summary: 'Running e2e in the privileged fork workflow…' }, - }); - core.setOutput('check_run_id', String(cr.data.id)); + // Real run: the `open` step opens an in-progress check-run; `report` + // concludes it. + core.setOutput('verdict_status', 'in_progress'); + core.setOutput('verdict_summary', 'Running e2e in the privileged fork workflow…'); core.setOutput('should_run', 'true'); + # Skipped when `resolve` set-failed on a bad SHA (fail-closed: no check → + # "E2E Tests" stays unreported → PR not mergeable). + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ secrets.COZYSTACK_CI_APP_ID }} + private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }} + owner: cozystack + repositories: ${{ github.event.repository.name }} + permission-checks: write + # Publish the verdict as the COZYSTACK_CI app so the check-run lands in the + # app's own check-suite — matching the same-repo e2e-report path and the + # branch-protection pin (see pull-requests.yaml e2e-report comment and + # docs/agents/e2e-testing.md §10). Creating it with the default token would + # file it under an arbitrary GitHub Actions suite (a labeler). + - name: Open "E2E Tests" check-run (as app) + id: open + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + env: + STATUS: ${{ steps.r.outputs.verdict_status }} + CONCLUSION: ${{ steps.r.outputs.verdict_conclusion }} + SUMMARY: ${{ steps.r.outputs.verdict_summary }} + HEAD_SHA: ${{ steps.r.outputs.head_sha }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const { STATUS, CONCLUSION, SUMMARY, HEAD_SHA } = process.env; + const { owner, repo } = context.repo; + const params = { + owner, repo, name: 'E2E Tests', head_sha: HEAD_SHA, + status: STATUS, + output: { title: 'E2E Tests', summary: SUMMARY }, + }; + if (STATUS === 'completed') params.conclusion = CONCLUSION; + const cr = await github.rest.checks.create(params); + // Only the real (in-progress) run is handed to `report` to conclude. + if (STATUS === 'in_progress') core.setOutput('check_run_id', String(cr.data.id)); + # Push the fork-built image archives to OCIR by digest and flux-push the # digest-pinned packages tree. Only trusted binaries touch the fork's content # while the OCIR token is live — no `make`/fork script runs here. @@ -406,8 +441,19 @@ jobs: if: ${{ always() && needs.resolve.outputs.should_run == 'true' }} runs-on: ubuntu-latest permissions: - checks: write + contents: read steps: + # The pending check-run was opened by the COZYSTACK_CI app in `resolve`; + # checks.update must run as the same app, so mint its token here too. + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ secrets.COZYSTACK_CI_APP_ID }} + private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }} + owner: cozystack + repositories: ${{ github.event.repository.name }} + permission-checks: write - name: Conclude "E2E Tests" check-run uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: @@ -416,6 +462,7 @@ jobs: E2E_RESULT: ${{ needs.e2e.result }} HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} with: + github-token: ${{ steps.app-token.outputs.token }} script: | const { CHECK_RUN_ID, PUBLISH_RESULT, E2E_RESULT, HEAD_SHA } = process.env; const { owner, repo } = context.repo; diff --git a/.github/workflows/pull-requests.yaml b/.github/workflows/pull-requests.yaml index 12131568c..1a8530a40 100644 --- a/.github/workflows/pull-requests.yaml +++ b/.github/workflows/pull-requests.yaml @@ -884,6 +884,17 @@ jobs: # • otherwise (skipped/failed/cancelled with code changes) → failure # Fork PRs skip this job; e2e-fork.yaml concludes "E2E Tests" for them, so a # fork PR stays "Expected"/not-mergeable until the privileged run reports. + # + # The check-run is published with the COZYSTACK_CI GitHub App token, NOT the + # default GITHUB_TOKEN. A token-created check-run is owned by the app that made + # it and GitHub files it under that app's check-suite; with the default token + # (the "GitHub Actions" app) a floating check-run attaches to an arbitrary + # GitHub Actions suite for the SHA — in practice the earliest one, a tiny + # labeler workflow — so "E2E Tests" rendered under "PR size label". Publishing + # as the app gives the check its own dedicated suite (shown under the app's + # name) and is stricter: only the CI app can satisfy the gate, not any Actions + # workflow. REQUIRES main branch protection to require "E2E Tests" from this + # app's id (not app_id 15368) — see docs/agents/e2e-testing.md §10. e2e-report: name: "Report E2E status" needs: ["plan", "e2e"] @@ -891,8 +902,16 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - checks: write steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 + with: + app-id: ${{ secrets.COZYSTACK_CI_APP_ID }} + private-key: ${{ secrets.COZYSTACK_CI_PRIVATE_KEY }} + owner: cozystack + repositories: ${{ github.event.repository.name }} + permission-checks: write - name: Conclude "E2E Tests" check-run uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: @@ -901,6 +920,7 @@ jobs: E2E_RESULT: ${{ needs.e2e.result }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} with: + github-token: ${{ steps.app-token.outputs.token }} script: | const { PLAN_RESULT, CODE, E2E_RESULT, HEAD_SHA } = process.env; let conclusion, summary;