PR Comment #18547
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Copyright (c) Meta Platforms, Inc. and affiliates. | |
| # PR Analysis Report — posts/updates the analysis comment for ALL PRs. | |
| # | |
| # Runs on workflow_run after CI completes, NOT on pull_request. That matters: | |
| # a pull_request workflow triggered by a fork gets a READ-ONLY token and can't | |
| # comment, which is why the report never appeared on external-contributor PRs. | |
| # workflow_run runs from the base repo with the repo's own token (pull-requests: | |
| # write), so it can comment on fork PRs too — one code path for every PR, no | |
| # drift between a fork path and a same-repo path. | |
| # | |
| # Safety: resolves scope and PR identity from the GitHub API. Privileged jobs | |
| # check out only trusted default-branch code. PR-built static artifacts are | |
| # published only after exact run/PR/repository checks; report fields remain | |
| # untrusted data and are rendered by default-branch code. | |
| name: PR Comment | |
| on: | |
| workflow_run: | |
| workflows: ['CI'] | |
| types: [requested, in_progress, completed] | |
| permissions: {} | |
| # The event already carries a stable head-repository/branch identity. Acquire | |
| # this shared lock before resolving the PR so a rerun can cancel older | |
| # invalidation, report-publication, or acceptance mutations for the same head. | |
| # Post-merge promotion does not join this cancellation group: it serializes | |
| # durably with every baseline publisher, then recaptures the merged commit and | |
| # requires its canonical pixels to match the latest completed accepted CI. | |
| concurrency: | |
| group: visual-acceptance-head-${{ github.event.workflow_run.head_repository.id }}-${{ github.event.workflow_run.head_branch }} | |
| cancel-in-progress: ${{ github.event.action == 'requested' || github.event.action == 'in_progress' }} | |
| jobs: | |
| resolve: | |
| if: github.event.workflow_run.event == 'pull_request' | |
| runs-on: ubuntu-slim | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| valid: ${{ steps.identity.outputs.valid }} | |
| pr_number: ${{ steps.identity.outputs.pr_number }} | |
| head_sha: ${{ steps.identity.outputs.head_sha }} | |
| head_repo: ${{ steps.identity.outputs.head_repo }} | |
| head_repo_id: ${{ steps.identity.outputs.head_repo_id }} | |
| head_ref: ${{ steps.identity.outputs.head_ref }} | |
| base_repo: ${{ steps.identity.outputs.base_repo }} | |
| base_sha: ${{ steps.identity.outputs.base_sha }} | |
| run_id: ${{ steps.identity.outputs.run_id }} | |
| run_attempt: ${{ steps.identity.outputs.run_attempt }} | |
| source_conclusion: ${{ steps.identity.outputs.source_conclusion }} | |
| spec_only: ${{ steps.identity.outputs.spec_only }} | |
| tooling_only: ${{ steps.identity.outputs.tooling_only }} | |
| steps: | |
| - name: Checkout trusted default-branch code | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: main | |
| - name: Resolve trusted PR identity | |
| id: identity | |
| uses: actions/github-script@v9 | |
| with: | |
| retries: 3 | |
| script: | | |
| const {pathToFileURL} = require('node:url'); | |
| const {resolveWorkflowRunPullRequest} = await import( | |
| pathToFileURL(`${process.env.GITHUB_WORKSPACE}/.github/scripts/lib/pr-preview.mjs`).href | |
| ); | |
| const identity = await resolveWorkflowRunPullRequest({ | |
| github, | |
| ...context.repo, | |
| run: context.payload.workflow_run, | |
| }); | |
| if (identity === null) { | |
| core.notice( | |
| `Skipping source run ${context.payload.workflow_run.id}: no open pull request remains for this head.`, | |
| ); | |
| core.setOutput('valid', 'false'); | |
| return; | |
| } | |
| const path = require('node:path'); | |
| const {classifyChanges} = require(path.join( | |
| process.env.GITHUB_WORKSPACE, | |
| '.github/scripts/change-scope.cjs', | |
| )); | |
| const {data: pull} = await github.rest.pulls.get({ | |
| ...context.repo, | |
| pull_number: identity.prNumber, | |
| }); | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| ...context.repo, | |
| pull_number: identity.prNumber, | |
| per_page: 100, | |
| }); | |
| const changeScope = classifyChanges(files, { | |
| expectedCount: pull.changed_files, | |
| }); | |
| core.setOutput('valid', 'true'); | |
| core.setOutput('pr_number', String(identity.prNumber)); | |
| core.setOutput('head_sha', identity.headSha); | |
| core.setOutput('head_repo', identity.headRepository); | |
| core.setOutput('head_repo_id', identity.headRepositoryId); | |
| core.setOutput('head_ref', identity.headRef); | |
| core.setOutput('base_repo', identity.baseRepository); | |
| core.setOutput('base_sha', identity.baseSha); | |
| core.setOutput('run_id', String(identity.sourceRunId)); | |
| core.setOutput('run_attempt', String(identity.sourceRunAttempt)); | |
| core.setOutput('source_conclusion', identity.sourceConclusion); | |
| core.setOutput('spec_only', String(changeScope.specOnly)); | |
| core.setOutput('tooling_only', String(changeScope.toolingOnly)); | |
| invalidate: | |
| needs: resolve | |
| if: >- | |
| (github.event.action == 'requested' || github.event.action == 'in_progress') && | |
| needs.resolve.outputs.valid == 'true' && | |
| needs.resolve.outputs.spec_only != 'true' && | |
| needs.resolve.outputs.tooling_only != 'true' | |
| runs-on: ubuntu-slim | |
| permissions: | |
| pull-requests: write | |
| statuses: write | |
| steps: | |
| - name: Invalidate visible approval for the new evidence run | |
| uses: actions/github-script@v9 | |
| with: | |
| retries: 3 | |
| script: | | |
| const {owner, repo} = context.repo; | |
| const run = context.payload.workflow_run; | |
| const pr = Number('${{ needs.resolve.outputs.pr_number }}'); | |
| const head = '${{ needs.resolve.outputs.head_sha }}'; | |
| await github.rest.repos.createCommitStatus({ | |
| owner, | |
| repo, | |
| sha: head, | |
| context: 'visual-acceptance', | |
| state: 'pending', | |
| description: `CI run ${run.id}/${run.run_attempt} is producing fresh visual evidence.`, | |
| }); | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, repo, issue_number: pr, name: 'visual-approved', | |
| }); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| } | |
| spec-only-visual: | |
| needs: resolve | |
| if: >- | |
| github.event.action == 'completed' && | |
| needs.resolve.outputs.valid == 'true' && | |
| (needs.resolve.outputs.spec_only == 'true' || | |
| needs.resolve.outputs.tooling_only == 'true') | |
| runs-on: ubuntu-slim | |
| permissions: | |
| statuses: write | |
| steps: | |
| - name: Mark spec-only visual scope complete | |
| uses: actions/github-script@v9 | |
| env: | |
| HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} | |
| with: | |
| retries: 3 | |
| script: | | |
| const {owner, repo} = context.repo; | |
| await github.rest.repos.createCommitStatus({ | |
| owner, repo, sha: process.env.HEAD_SHA, | |
| context: 'visual-acceptance', state: 'success', | |
| description: 'Classified change — no visual scope.', | |
| }); | |
| spec-only-reconcile: | |
| needs: resolve | |
| if: >- | |
| github.event.action == 'completed' && | |
| needs.resolve.outputs.valid == 'true' && | |
| (needs.resolve.outputs.spec_only == 'true' || | |
| needs.resolve.outputs.tooling_only == 'true') | |
| runs-on: ubuntu-slim | |
| permissions: | |
| actions: read | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Checkout trusted default-branch code | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: main | |
| - name: Remove stale preview links from an existing report | |
| uses: actions/github-script@v9 | |
| env: | |
| PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} | |
| HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} | |
| HEAD_REPOSITORY: ${{ needs.resolve.outputs.head_repo }} | |
| HEAD_REPOSITORY_ID: ${{ needs.resolve.outputs.head_repo_id }} | |
| HEAD_REF: ${{ needs.resolve.outputs.head_ref }} | |
| BASE_REPOSITORY: ${{ needs.resolve.outputs.base_repo }} | |
| BASE_SHA: ${{ needs.resolve.outputs.base_sha }} | |
| RUN_ID: ${{ needs.resolve.outputs.run_id }} | |
| RUN_ATTEMPT: ${{ needs.resolve.outputs.run_attempt }} | |
| SOURCE_CONCLUSION: ${{ needs.resolve.outputs.source_conclusion }} | |
| with: | |
| retries: 3 | |
| script: | | |
| const {pathToFileURL} = require('node:url'); | |
| const {reconcilePrComment} = await import( | |
| pathToFileURL(`${process.env.GITHUB_WORKSPACE}/.github/scripts/lib/pr-preview.mjs`).href | |
| ); | |
| await reconcilePrComment({ | |
| github, | |
| core, | |
| context, | |
| expectedIdentity: { | |
| prNumber: Number(process.env.PR_NUMBER), | |
| headSha: process.env.HEAD_SHA, | |
| headRepository: process.env.HEAD_REPOSITORY, | |
| headRepositoryId: process.env.HEAD_REPOSITORY_ID, | |
| headRef: process.env.HEAD_REF, | |
| baseRepository: process.env.BASE_REPOSITORY, | |
| baseSha: process.env.BASE_SHA, | |
| sourceRunId: Number(process.env.RUN_ID), | |
| sourceRunAttempt: Number(process.env.RUN_ATTEMPT), | |
| sourceConclusion: process.env.SOURCE_CONCLUSION, | |
| }, | |
| createIfMissing: false, | |
| fallbackMessage: | |
| 'The current change has no preview-producing surface. Preview links are not shown.', | |
| }); | |
| deploy-preview: | |
| needs: resolve | |
| if: >- | |
| github.event.action == 'completed' && | |
| needs.resolve.outputs.valid == 'true' && | |
| needs.resolve.outputs.spec_only != 'true' && | |
| needs.resolve.outputs.tooling_only != 'true' | |
| permissions: | |
| actions: read | |
| contents: write | |
| pull-requests: read | |
| uses: ./.github/workflows/deploy-preview.yml | |
| with: | |
| pr_number: ${{ needs.resolve.outputs.pr_number }} | |
| head_sha: ${{ needs.resolve.outputs.head_sha }} | |
| head_repository: ${{ needs.resolve.outputs.head_repo }} | |
| head_repository_id: ${{ needs.resolve.outputs.head_repo_id }} | |
| head_ref: ${{ needs.resolve.outputs.head_ref }} | |
| base_repository: ${{ needs.resolve.outputs.base_repo }} | |
| source_run_id: ${{ needs.resolve.outputs.run_id }} | |
| source_run_attempt: ${{ needs.resolve.outputs.run_attempt }} | |
| source_conclusion: ${{ needs.resolve.outputs.source_conclusion }} | |
| secrets: inherit | |
| comment: | |
| needs: [resolve, deploy-preview] | |
| # Reconcile after the trusted publisher settles. A failed publisher still | |
| # runs this job so stale links are removed rather than retained. | |
| if: >- | |
| always() && | |
| github.event.action == 'completed' && | |
| needs.resolve.outputs.valid == 'true' && | |
| needs.resolve.outputs.spec_only != 'true' && | |
| needs.resolve.outputs.tooling_only != 'true' | |
| runs-on: 2-core-ubuntu-arm | |
| permissions: | |
| pull-requests: write | |
| statuses: write | |
| actions: read | |
| contents: write # immutable validated visual evidence on gh-pages | |
| steps: | |
| - name: Checkout trusted default-branch code | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: main | |
| - name: Setup Node and pnpm | |
| uses: ./.github/actions/setup | |
| # Recheck the exact source run and current PR immediately before any | |
| # privileged report or status mutation. | |
| - name: Reconfirm trusted PR identity | |
| id: identity | |
| uses: actions/github-script@v9 | |
| env: | |
| PR_NUMBER: ${{ needs.resolve.outputs.pr_number }} | |
| HEAD_SHA: ${{ needs.resolve.outputs.head_sha }} | |
| HEAD_REPOSITORY: ${{ needs.resolve.outputs.head_repo }} | |
| HEAD_REPOSITORY_ID: ${{ needs.resolve.outputs.head_repo_id }} | |
| HEAD_REF: ${{ needs.resolve.outputs.head_ref }} | |
| BASE_REPOSITORY: ${{ needs.resolve.outputs.base_repo }} | |
| SOURCE_RUN_ID: ${{ needs.resolve.outputs.run_id }} | |
| SOURCE_RUN_ATTEMPT: ${{ needs.resolve.outputs.run_attempt }} | |
| SOURCE_CONCLUSION: ${{ needs.resolve.outputs.source_conclusion }} | |
| with: | |
| retries: 3 | |
| script: | | |
| const {pathToFileURL} = require('node:url'); | |
| const {confirmSourceRunIdentity} = await import( | |
| pathToFileURL(`${process.env.GITHUB_WORKSPACE}/.github/scripts/lib/pr-preview.mjs`).href | |
| ); | |
| const identity = await confirmSourceRunIdentity({ | |
| github, | |
| ...context.repo, | |
| expected: { | |
| prNumber: Number(process.env.PR_NUMBER), | |
| headSha: process.env.HEAD_SHA, | |
| headRepository: process.env.HEAD_REPOSITORY, | |
| headRepositoryId: process.env.HEAD_REPOSITORY_ID, | |
| headRef: process.env.HEAD_REF, | |
| baseRepository: process.env.BASE_REPOSITORY, | |
| sourceRunId: Number(process.env.SOURCE_RUN_ID), | |
| sourceRunAttempt: Number(process.env.SOURCE_RUN_ATTEMPT), | |
| sourceConclusion: process.env.SOURCE_CONCLUSION, | |
| }, | |
| }); | |
| core.setOutput('valid', 'true'); | |
| core.setOutput('pr_number', String(identity.prNumber)); | |
| core.setOutput('head_sha', identity.headSha); | |
| core.setOutput('head_repo', identity.headRepository); | |
| core.setOutput('head_repo_id', identity.headRepositoryId); | |
| core.setOutput('head_ref', identity.headRef); | |
| core.setOutput('base_repo', identity.baseRepository); | |
| core.setOutput('base_sha', identity.baseSha); | |
| core.setOutput('run_id', String(identity.sourceRunId)); | |
| core.setOutput('run_attempt', String(identity.sourceRunAttempt)); | |
| core.setOutput('source_conclusion', identity.sourceConclusion); | |
| - name: Derive trusted stable visual scope | |
| id: scope | |
| if: steps.identity.outputs.valid == 'true' | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const fs = require('node:fs'); | |
| const {spawnSync} = require('node:child_process'); | |
| const {owner, repo} = context.repo; | |
| const pullNumber = Number('${{ steps.identity.outputs.pr_number }}'); | |
| const {data: pr} = await github.rest.pulls.get({owner, repo, pull_number: pullNumber}); | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner, repo, pull_number: pullNumber, per_page: 100, | |
| }); | |
| if (files.length !== pr.changed_files) { | |
| core.setFailed( | |
| `GitHub returned ${files.length} of ${pr.changed_files} changed files; refusing visual-scope classification.`, | |
| ); | |
| return; | |
| } | |
| const manifestPaths = new Set(); | |
| for (const {filename} of files) { | |
| const theme = filename.match(/^packages\/themes\/([^/]+)\//); | |
| const pkg = filename.match(/^packages\/([^/]+)\//); | |
| if (theme) manifestPaths.add(`packages/themes/${theme[1]}/package.json`); | |
| else if (pkg) manifestPaths.add(`packages/${pkg[1]}/package.json`); | |
| } | |
| const manifests = {}; | |
| const [headOwner, headRepo] = pr.head.repo.full_name.split('/'); | |
| for (const manifestPath of manifestPaths) { | |
| try { | |
| const {data} = await github.rest.repos.getContent({ | |
| owner: headOwner, repo: headRepo, path: manifestPath, ref: pr.head.sha, | |
| }); | |
| if (!Array.isArray(data) && data.content) { | |
| manifests[manifestPath] = JSON.parse(Buffer.from(data.content, 'base64').toString()); | |
| } | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| } | |
| } | |
| fs.writeFileSync('visual-scope-manifests.json', JSON.stringify(manifests)); | |
| const result = spawnSync( | |
| process.execPath, | |
| ['.github/scripts/visual-scope.mjs', '--manifests', 'visual-scope-manifests.json'], | |
| {input: `${files.map((file) => file.filename).join('\n')}\n`, encoding: 'utf8'}, | |
| ); | |
| if (result.status !== 0) { | |
| core.setFailed(result.stderr || 'Stable visual scope classification failed.'); | |
| return; | |
| } | |
| const scope = JSON.parse(result.stdout); | |
| fs.writeFileSync('trusted-scope.json', JSON.stringify(scope)); | |
| core.setOutput('stable', String(scope.hasStableVisual)); | |
| core.setOutput('broad', String(scope.broadStableVisual)); | |
| - name: Download analysis artifact from the CI run | |
| if: steps.identity.outputs.valid == 'true' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: pr-analysis | |
| path: pr-analysis | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| continue-on-error: true | |
| - name: Download a11y artifact from the CI run | |
| if: steps.identity.outputs.valid == 'true' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: a11y-report | |
| path: a11y | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| continue-on-error: true | |
| - name: Download trusted preview deployment result | |
| if: steps.identity.outputs.valid == 'true' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: preview-deployment-${{ steps.identity.outputs.run_id }}-${{ steps.identity.outputs.run_attempt }} | |
| path: preview-deployment | |
| continue-on-error: true | |
| - name: Download Storybook for trusted visual capture | |
| if: >- | |
| steps.identity.outputs.valid == 'true' && | |
| steps.scope.outputs.stable == 'true' && | |
| steps.scope.outputs.broad != 'true' | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: storybook-* | |
| path: apps/storybook/dist | |
| merge-multiple: true | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Cross-check artifact identity | |
| if: steps.identity.outputs.valid == 'true' && hashFiles('pr-analysis/pr-meta.json') != '' | |
| env: | |
| EXPECTED_PR: ${{ steps.identity.outputs.pr_number }} | |
| EXPECTED_HEAD: ${{ steps.identity.outputs.head_sha }} | |
| EXPECTED_HEAD_REPO: ${{ steps.identity.outputs.head_repo }} | |
| EXPECTED_BASE_REPO: ${{ steps.identity.outputs.base_repo }} | |
| EXPECTED_RUN: ${{ steps.identity.outputs.run_id }} | |
| EXPECTED_ATTEMPT: ${{ steps.identity.outputs.run_attempt }} | |
| run: | | |
| node --input-type=module - <<'NODE' | |
| import fs from 'node:fs'; | |
| import {validateAnalysisMetadata} from './.github/scripts/lib/pr-preview.mjs'; | |
| const metadata = JSON.parse(fs.readFileSync('pr-analysis/pr-meta.json', 'utf8')); | |
| validateAnalysisMetadata(metadata, { | |
| prNumber: process.env.EXPECTED_PR, | |
| headSha: process.env.EXPECTED_HEAD, | |
| headRepository: process.env.EXPECTED_HEAD_REPO, | |
| baseRepository: process.env.EXPECTED_BASE_REPO, | |
| sourceRunId: process.env.EXPECTED_RUN, | |
| sourceRunAttempt: process.env.EXPECTED_ATTEMPT, | |
| }); | |
| NODE | |
| - name: Fetch the trusted visual baseline | |
| if: steps.identity.outputs.valid == 'true' && steps.scope.outputs.stable == 'true' | |
| run: | | |
| rm -rf /tmp/gh-pages | |
| git clone --depth=1 --single-branch --branch gh-pages \ | |
| "https://github.com/${GITHUB_REPOSITORY}.git" /tmp/gh-pages | |
| - name: Capture the trusted stable visual scope | |
| if: >- | |
| steps.identity.outputs.valid == 'true' && | |
| steps.scope.outputs.stable == 'true' && | |
| steps.scope.outputs.broad != 'true' | |
| env: | |
| ASTRYX_VISUAL_SHA: ${{ steps.identity.outputs.head_sha }} | |
| ASTRYX_VISUAL_RUN_ID: ${{ steps.identity.outputs.run_id }} | |
| ASTRYX_VISUAL_RUN_ATTEMPT: ${{ steps.identity.outputs.run_attempt }} | |
| ASTRYX_PR_HEAD_SHA: ${{ steps.identity.outputs.head_sha }} | |
| ASTRYX_PR_BASE_SHA: ${{ steps.identity.outputs.base_sha }} | |
| run: | | |
| npx playwright install chromium | |
| node .github/scripts/visual-gate/visual-acceptance.mjs trusted-plan \ | |
| --scope trusted-scope.json \ | |
| --baseline /tmp/gh-pages/visual-gate/baseline \ | |
| --storybook-dir apps/storybook/dist \ | |
| --output trusted-plan.json | |
| node .github/scripts/visual-gate/gate.mjs capture \ | |
| --storybook-dir apps/storybook/dist \ | |
| --out trusted-capture \ | |
| --plan-file trusted-plan.json | |
| - name: Derive trusted broad visual deferral | |
| if: >- | |
| steps.identity.outputs.valid == 'true' && | |
| steps.scope.outputs.stable == 'true' && | |
| steps.scope.outputs.broad == 'true' | |
| env: | |
| PR_NUMBER: ${{ steps.identity.outputs.pr_number }} | |
| HEAD_SHA: ${{ steps.identity.outputs.head_sha }} | |
| BASE_SHA: ${{ steps.identity.outputs.base_sha }} | |
| RUN_ID: ${{ steps.identity.outputs.run_id }} | |
| RUN_ATTEMPT: ${{ steps.identity.outputs.run_attempt }} | |
| run: | | |
| node .github/scripts/visual-gate/visual-acceptance.mjs trusted-defer \ | |
| --scope trusted-scope.json \ | |
| --baseline /tmp/gh-pages/visual-gate/baseline \ | |
| --output trusted-visual \ | |
| --pr "$PR_NUMBER" \ | |
| --head "$HEAD_SHA" \ | |
| --base "$BASE_SHA" \ | |
| --run-id "$RUN_ID" \ | |
| --run-attempt "$RUN_ATTEMPT" | |
| # The Storybook bundle is untrusted. Default-branch code chooses the plan, | |
| # captures it in an off-origin-blocked browser, derives the comparison, | |
| # validates/re-encodes PNGs, and generates the report. PR-authored verdicts | |
| # and job conclusions are never authority for the required status. | |
| - name: Derive trusted visual evidence and report | |
| if: >- | |
| steps.identity.outputs.valid == 'true' && | |
| steps.scope.outputs.stable == 'true' && | |
| steps.scope.outputs.broad != 'true' | |
| env: | |
| PR_NUMBER: ${{ steps.identity.outputs.pr_number }} | |
| HEAD_SHA: ${{ steps.identity.outputs.head_sha }} | |
| RUN_ID: ${{ steps.identity.outputs.run_id }} | |
| RUN_ATTEMPT: ${{ steps.identity.outputs.run_attempt }} | |
| run: | | |
| node .github/scripts/visual-gate/publish-pr-report.mjs \ | |
| --input trusted-capture \ | |
| --output trusted-visual \ | |
| --baseline /tmp/gh-pages/visual-gate/baseline \ | |
| --scope trusted-scope.json \ | |
| --pr "$PR_NUMBER" \ | |
| --head-sha "$HEAD_SHA" \ | |
| --run-id "$RUN_ID" \ | |
| --run-attempt "$RUN_ATTEMPT" | |
| - name: Resolve trusted visual evidence path | |
| id: visual | |
| if: >- | |
| steps.identity.outputs.valid == 'true' && | |
| steps.scope.outputs.stable == 'true' | |
| env: | |
| PR_NUMBER: ${{ steps.identity.outputs.pr_number }} | |
| HEAD_SHA: ${{ steps.identity.outputs.head_sha }} | |
| RUN_ID: ${{ steps.identity.outputs.run_id }} | |
| RUN_ATTEMPT: ${{ steps.identity.outputs.run_attempt }} | |
| run: | | |
| test -f trusted-visual/evidence.json | |
| test -f trusted-visual/verdict.json | |
| test -f trusted-visual/index.html | |
| echo "ready=true" >> "$GITHUB_OUTPUT" | |
| echo "path=pr/${PR_NUMBER}/visual/${HEAD_SHA}/${RUN_ID}/${RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" | |
| - name: Publish immutable visual evidence | |
| id: publish | |
| if: steps.visual.outputs.ready == 'true' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| EVIDENCE_PATH: ${{ steps.visual.outputs.path }} | |
| run: > | |
| node .github/scripts/gh-pages-publisher.mjs immutable-path | |
| --source trusted-visual | |
| --destination "$EVIDENCE_PATH" | |
| --scope pr-visual/evidence | |
| --message "Visual evidence: ${EVIDENCE_PATH}" | |
| - name: Refresh trusted visual state checkout | |
| if: steps.publish.outputs.published == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR_NUMBER: ${{ steps.identity.outputs.pr_number }} | |
| HEAD_SHA: ${{ steps.identity.outputs.head_sha }} | |
| run: > | |
| rm -rf /tmp/gh-pages && | |
| git clone --depth=1 --filter=blob:none --sparse --single-branch --branch gh-pages | |
| "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" /tmp/gh-pages && | |
| git -C /tmp/gh-pages sparse-checkout set | |
| visual-gate/baseline | |
| "pr/${PR_NUMBER}/visual/${HEAD_SHA}" | |
| - name: Generate and post PR comment | |
| if: always() && steps.identity.outputs.valid == 'true' | |
| uses: actions/github-script@v9 | |
| env: | |
| PR_NUMBER: ${{ steps.identity.outputs.pr_number }} | |
| HEAD_SHA: ${{ steps.identity.outputs.head_sha }} | |
| HEAD_REPOSITORY: ${{ steps.identity.outputs.head_repo }} | |
| HEAD_REPOSITORY_ID: ${{ steps.identity.outputs.head_repo_id }} | |
| HEAD_REF: ${{ steps.identity.outputs.head_ref }} | |
| BASE_REPOSITORY: ${{ steps.identity.outputs.base_repo }} | |
| BASE_SHA: ${{ steps.identity.outputs.base_sha }} | |
| RUN_ID: ${{ steps.identity.outputs.run_id }} | |
| RUN_ATTEMPT: ${{ steps.identity.outputs.run_attempt }} | |
| SOURCE_CONCLUSION: ${{ steps.identity.outputs.source_conclusion }} | |
| VISUAL_PUBLISHED: ${{ steps.publish.outputs.published }} | |
| VISUAL_PATH: ${{ steps.visual.outputs.path }} | |
| with: | |
| retries: 3 | |
| script: | | |
| const {pathToFileURL} = require('node:url'); | |
| const {reconcilePrComment} = await import( | |
| pathToFileURL(`${process.env.GITHUB_WORKSPACE}/.github/scripts/lib/pr-preview.mjs`).href | |
| ); | |
| await reconcilePrComment({ | |
| github, | |
| core, | |
| context, | |
| expectedIdentity: { | |
| prNumber: Number(process.env.PR_NUMBER), | |
| headSha: process.env.HEAD_SHA, | |
| headRepository: process.env.HEAD_REPOSITORY, | |
| headRepositoryId: process.env.HEAD_REPOSITORY_ID, | |
| headRef: process.env.HEAD_REF, | |
| baseRepository: process.env.BASE_REPOSITORY, | |
| baseSha: process.env.BASE_SHA, | |
| sourceRunId: Number(process.env.RUN_ID), | |
| sourceRunAttempt: Number(process.env.RUN_ATTEMPT), | |
| sourceConclusion: process.env.SOURCE_CONCLUSION, | |
| }, | |
| visualPublished: process.env.VISUAL_PUBLISHED === 'true', | |
| visualReportPath: process.env.VISUAL_PATH, | |
| }); | |
| - name: Evaluate visual acceptance state | |
| if: always() && steps.identity.outputs.valid == 'true' | |
| env: | |
| SCOPE_OUTCOME: ${{ steps.scope.outcome }} | |
| HAS_STABLE_VISUAL: ${{ steps.scope.outputs.stable }} | |
| VISUAL_READY: ${{ steps.visual.outputs.ready }} | |
| VISUAL_PUBLISHED: ${{ steps.publish.outputs.published }} | |
| PR_NUMBER: ${{ steps.identity.outputs.pr_number }} | |
| HEAD_SHA: ${{ steps.identity.outputs.head_sha }} | |
| run: | | |
| if [ "$SCOPE_OUTCOME" != "success" ]; then | |
| printf '%s\n' '{"state":"failure","reason":"scope","description":"Stable visual scope could not be evaluated."}' > visual-state.json | |
| elif [ "$HAS_STABLE_VISUAL" != "true" ]; then | |
| printf '%s\n' '{"state":"success","reason":"scope","description":"No stable visual scope."}' > visual-state.json | |
| elif [ "$VISUAL_READY" != "true" ] || [ "$VISUAL_PUBLISHED" != "true" ]; then | |
| printf '%s\n' '{"state":"failure","reason":"capture","description":"Stable visual evidence could not be published."}' > visual-state.json | |
| else | |
| node .github/scripts/visual-gate/visual-acceptance.mjs state \ | |
| --pages /tmp/gh-pages --pr "$PR_NUMBER" --head "$HEAD_SHA" > visual-state.json | |
| fi | |
| - name: Publish visual acceptance status | |
| if: always() && steps.identity.outputs.valid == 'true' | |
| uses: actions/github-script@v9 | |
| env: | |
| PR_NUMBER: ${{ steps.identity.outputs.pr_number }} | |
| HEAD_SHA: ${{ steps.identity.outputs.head_sha }} | |
| VISUAL_PATH: ${{ steps.visual.outputs.path }} | |
| with: | |
| retries: 3 | |
| script: | | |
| const fs = require('node:fs'); | |
| const {owner, repo} = context.repo; | |
| const pr = Number(process.env.PR_NUMBER); | |
| let state = {state: 'failure', reason: 'state', description: 'Visual acceptance state could not be evaluated.'}; | |
| if (fs.existsSync('visual-state.json')) { | |
| try { | |
| state = JSON.parse(fs.readFileSync('visual-state.json', 'utf8')); | |
| } catch (error) { | |
| core.warning(`Could not read visual state: ${error.message}`); | |
| } | |
| } | |
| await github.rest.repos.createCommitStatus({ | |
| owner, repo, sha: process.env.HEAD_SHA, context: 'visual-acceptance', | |
| state: state.state, description: state.description.slice(0, 140), | |
| ...(process.env.VISUAL_PATH | |
| ? {target_url: `https://facebook.github.io/astryx/${process.env.VISUAL_PATH}/`} | |
| : {}), | |
| }); | |
| const label = 'visual-approved'; | |
| if (state.reason === 'accepted') { | |
| try { | |
| await github.rest.issues.getLabel({owner, repo, name: label}); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| await github.rest.issues.createLabel({ | |
| owner, repo, name: label, color: '2da44e', | |
| description: 'Current visual bundle has an explicit acceptance record', | |
| }); | |
| } | |
| await github.rest.issues.addLabels({owner, repo, issue_number: pr, labels: [label]}); | |
| } else { | |
| try { | |
| await github.rest.issues.removeLabel({owner, repo, issue_number: pr, name: label}); | |
| } catch (error) { | |
| if (error.status !== 404) throw error; | |
| } | |
| } |