Skip to content

Commit 34fb6d0

Browse files
committed
Approve visual changes with a label
Adds Visual Approve RNTester, which runs on `pull_request_target` when a maintainer applies `visual-change-approved`. It records an approval for the current fingerprint of differences and re-evaluates the gate check. A later push with different differences removes the label and needs a new review. Changelog: [Internal]
1 parent 8cf5c81 commit 34fb6d0

2 files changed

Lines changed: 249 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
*/
9+
10+
/**
11+
* Records a maintainer's approval of a PR's visual differences and updates
12+
* the approval gate. Runs from the default branch on `pull_request_target`
13+
* when the approval label is added or removed. It never checks out or runs
14+
* pull request code.
15+
*
16+
* node approve-run.js prepare Decide, and stage an approval record to
17+
* upload under OUT_DIR.
18+
* node approve-run.js report After the upload, update the gate check.
19+
*/
20+
21+
const {
22+
APPROVAL_LABEL,
23+
APPROVER_PERMISSIONS,
24+
GATE_CHECK_NAME,
25+
gateDecision,
26+
} = require('./approval');
27+
const {createGitHubClient} = require('./github');
28+
const {
29+
approvalKey,
30+
fetchJsonOrNull,
31+
listPrefixes,
32+
publicUrl,
33+
} = require('./storage');
34+
const fs = require('fs');
35+
const path = require('path');
36+
37+
function requireEnv(name) {
38+
const value = process.env[name];
39+
if (value == null || value === '') {
40+
throw new Error(`${name} is required`);
41+
}
42+
return value;
43+
}
44+
45+
/** The results of the most recent comparison for a PR head, or null. */
46+
async function latestResults({bucket, publicBaseUrl, prNumber, headSha}) {
47+
const runs = await listPrefixes(
48+
bucket,
49+
`reports/pull/${prNumber}/${headSha}/`,
50+
);
51+
const latest = runs
52+
.map(prefix => {
53+
const match = /\/(\d+)-(\d+)\/$/.exec(prefix);
54+
return match == null
55+
? null
56+
: {prefix, runId: Number(match[1]), attempt: Number(match[2])};
57+
})
58+
.filter(Boolean)
59+
.sort((a, b) => b.runId - a.runId || b.attempt - a.attempt)[0];
60+
return latest == null
61+
? null
62+
: fetchJsonOrNull(publicUrl(publicBaseUrl, `${latest.prefix}results.json`));
63+
}
64+
65+
async function prepare() {
66+
const github = createGitHubClient();
67+
const outDir = requireEnv('OUT_DIR');
68+
const event = JSON.parse(
69+
fs.readFileSync(requireEnv('GITHUB_EVENT_PATH'), 'utf8'),
70+
);
71+
const writeState = state =>
72+
fs.writeFileSync(
73+
path.join(outDir, 'state.json'),
74+
`${JSON.stringify(state)}\n`,
75+
);
76+
fs.mkdirSync(outDir, {recursive: true});
77+
78+
if (event.label?.name !== APPROVAL_LABEL) {
79+
writeState(null);
80+
return;
81+
}
82+
83+
const prNumber = event.pull_request.number;
84+
const headSha = event.pull_request.head.sha;
85+
const results = await latestResults({
86+
bucket: requireEnv('VISUAL_BUCKET'),
87+
publicBaseUrl: requireEnv('VISUAL_PUBLIC_BASE_URL'),
88+
prNumber,
89+
headSha,
90+
});
91+
const fingerprint = results?.fingerprint ?? null;
92+
93+
if (event.action === 'labeled') {
94+
const approver = event.sender.login;
95+
const permission = await github.permission(approver);
96+
if (!APPROVER_PERMISSIONS.includes(permission)) {
97+
await github.removeLabel(prNumber, APPROVAL_LABEL);
98+
await github.comment(
99+
prNumber,
100+
`@${approver} \`${APPROVAL_LABEL}\` needs write access to this repository, so it was removed.`,
101+
);
102+
writeState(null);
103+
return;
104+
}
105+
if (results == null) {
106+
await github.removeLabel(prNumber, APPROVAL_LABEL);
107+
await github.comment(
108+
prNumber,
109+
`There is no visual report for \`${headSha.slice(0, 12)}\` yet, so \`${APPROVAL_LABEL}\` was removed. Apply it again once the report is posted.`,
110+
);
111+
writeState(null);
112+
return;
113+
}
114+
if (fingerprint != null) {
115+
const key = approvalKey(prNumber, fingerprint);
116+
const record = {
117+
fingerprint,
118+
approver,
119+
approvedAt: new Date().toISOString(),
120+
headSha,
121+
};
122+
const recordPath = path.join(outDir, 'upload', key);
123+
fs.mkdirSync(path.dirname(recordPath), {recursive: true});
124+
fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
125+
}
126+
}
127+
128+
writeState({
129+
prNumber,
130+
headSha,
131+
fingerprint,
132+
labelPresent: event.action === 'labeled',
133+
});
134+
}
135+
136+
async function report() {
137+
const github = createGitHubClient();
138+
const outDir = requireEnv('OUT_DIR');
139+
const state = JSON.parse(
140+
fs.readFileSync(path.join(outDir, 'state.json'), 'utf8'),
141+
);
142+
if (state == null) {
143+
return;
144+
}
145+
const {prNumber, headSha, fingerprint, labelPresent} = state;
146+
const approval =
147+
fingerprint == null
148+
? null
149+
: await fetchJsonOrNull(
150+
publicUrl(
151+
requireEnv('VISUAL_PUBLIC_BASE_URL'),
152+
approvalKey(prNumber, fingerprint),
153+
),
154+
);
155+
const decision = gateDecision({fingerprint, labelPresent, approval});
156+
await github.createCheckRun({
157+
name: GATE_CHECK_NAME,
158+
headSha,
159+
conclusion: decision.conclusion,
160+
output: {title: decision.summary, summary: decision.summary},
161+
});
162+
console.info(decision.summary);
163+
}
164+
165+
async function main(command = process.argv[2]) {
166+
if (command === 'prepare') {
167+
await prepare();
168+
} else if (command === 'report') {
169+
await report();
170+
} else {
171+
throw new Error('Usage: node approve-run.js prepare|report');
172+
}
173+
}
174+
175+
if (require.main === module) {
176+
main().catch(error => {
177+
console.error(error);
178+
process.exitCode = 1;
179+
});
180+
}
181+
182+
module.exports = {latestResults};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Records a maintainer's approval of a pull request's visual differences when
2+
# the visual-change-approved label is added, and updates the approval gate
3+
# when it is added or removed. Runs from the default branch on
4+
# pull_request_target and never checks out pull request code.
5+
name: Visual Approve RNTester
6+
7+
on:
8+
pull_request_target:
9+
types: [labeled, unlabeled]
10+
11+
permissions:
12+
contents: read
13+
14+
concurrency:
15+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
16+
cancel-in-progress: false
17+
18+
env:
19+
OUT_DIR: /tmp/visual-approval
20+
VISUAL_BUCKET: ${{ vars.VISUAL_BUCKET }}
21+
VISUAL_PUBLIC_BASE_URL: ${{ vars.VISUAL_PUBLIC_BASE_URL }}
22+
23+
jobs:
24+
approve:
25+
if: github.event.label.name == 'visual-change-approved'
26+
runs-on: ubuntu-latest
27+
permissions:
28+
checks: write
29+
contents: read
30+
id-token: write
31+
issues: write
32+
pull-requests: write
33+
steps:
34+
- name: Checkout default branch
35+
uses: actions/checkout@v6
36+
with:
37+
# Never the pull request: this workflow has write permissions.
38+
ref: ${{ github.event.repository.default_branch }}
39+
persist-credentials: false
40+
- name: Setup node.js
41+
uses: ./.github/actions/setup-node
42+
- name: Decide
43+
env:
44+
GITHUB_TOKEN: ${{ github.token }}
45+
run: node .github/workflow-scripts/visual/approve-run.js prepare
46+
- name: Check for an approval to record
47+
id: staged
48+
run: |
49+
if [[ -d "$OUT_DIR/upload" ]]; then
50+
echo "upload=true" >> "$GITHUB_OUTPUT"
51+
fi
52+
- name: Authenticate to Google Cloud
53+
if: steps.staged.outputs.upload == 'true'
54+
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
55+
with:
56+
project_id: ${{ vars.VISUAL_GCP_PROJECT }}
57+
workload_identity_provider: ${{ vars.VISUAL_WORKLOAD_IDENTITY_PROVIDER }}
58+
- name: Setup gcloud
59+
if: steps.staged.outputs.upload == 'true'
60+
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
61+
- name: Record approval
62+
if: steps.staged.outputs.upload == 'true'
63+
run: gcloud storage cp --recursive --no-clobber "$OUT_DIR/upload/*" "gs://$VISUAL_BUCKET/"
64+
- name: Update gate
65+
env:
66+
GITHUB_TOKEN: ${{ github.token }}
67+
run: node .github/workflow-scripts/visual/approve-run.js report

0 commit comments

Comments
 (0)