Skip to content

PR Comment

PR Comment #18547

Workflow file for this run

# 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;
}
}