@samitouri / QOS-React / commits / 44c4693539

[ci] Dont sign builds originating from anything other than facebook/react (#32738)

We now generate attestations in `process_artifacts_combined` so we can verify the provenance of the build later in other workflows. However, this requires `write` permissions for `id-token` and `attestations` so PRs from forks cannot generate this attestation. To get around this, I added a `--no-verify` flag to scripts/release/download-experimental-build.js. This flag is only passed in `runtime_build_and_test.yml` for the sizebot job, since 1) the workflow runs in the `pull_request` trigger which has read-only permissions, and 2) the downloaded artifact is only used for sizebot calculation, and not actually used. The flag is explicitly not passed in `runtime_commit_artifacts.yml` since there we actually use the artifact internally. This is fine as once a PR lands on main, it will then run the build on that new commit and generate an attestation. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32738). * #32739 * __->__ #32738

lauren committed Mar 25, 2025 at 11:16 UTC 44c46935394c22bf69c1935cb0b708d178091024
4 files changed +61 -33
.github/workflows/runtime_build_and_test.yml
+16 -5
@@ -481,6 +481,13 @@ jobs:
481 ./build2.tgz
482 if-no-files-found: error
483 - uses: actions/attest-build-provenance@v2
484 + # We don't verify builds generated from pull requests not originating from facebook/react.
485 + # However, if the PR lands, the run on `main` will generate the attestation which can then
486 + # be used to download a build via scripts/release/download-experimental-build.js.
487 + #
488 + # Note that this means that scripts/release/download-experimental-build.js must be run with
489 + # --no-verify when downloading a build from a fork.
490 + if: github.event.pull_request.head.repo.full_name != github.repository
491 with:
492 subject-name: artifacts_combined.zip
493 subject-digest: sha256:${{ steps.upload_artifacts_combined.outputs.artifact-digest }}
@@ -806,14 +813,18 @@ jobs:
813 - run: yarn --cwd scripts/release install --frozen-lockfile
814 if: steps.node_modules.outputs.cache-hit != 'true'
815 - name: Download artifacts for base revision
816 + # The build could have been generated from a fork, so we must download the build without
817 + # any verification. This is safe since we only use this for sizebot calculation and the
818 + # unverified artifact is not used. Additionally this workflow runs in the pull_request
819 + # trigger so only restricted permissions are available.
820 run: |
810 - GH_TOKEN=${{ github.token }} scripts/release/download-experimental-build.js --commit=$(git rev-parse ${{ github.event.pull_request.base.sha }})
821 + GH_TOKEN=${{ github.token }} scripts/release/download-experimental-build.js --commit=$(git rev-parse ${{ github.event.pull_request.base.sha }}) ${{ (github.event.pull_request.head.repo.full_name != github.repository && '--no-verify') || ''}}
822 mv ./build ./base-build
812 - # TODO: The `download-experimental-build` script copies the npm
813 - # packages into the `node_modules` directory. This is a historical
814 - # quirk of how the release script works. Let's pretend they
815 - # don't exist.
823 - name: Delete extraneous files
824 + # TODO: The `download-experimental-build` script copies the npm
825 + # packages into the `node_modules` directory. This is a historical
826 + # quirk of how the release script works. Let's pretend they
827 + # don't exist.
828 run: rm -rf ./base-build/node_modules
829 - name: Display structure of base-build from origin/main
830 run: ls -R base-build
scripts/release/download-experimental-build.js
+12 -1
@@ -27,6 +27,12 @@ const argv = yargs.wrap(yargs.terminalWidth()).options({
27 demandOption: true,
28 type: 'string',
29 },
30 + 'no-verify': {
31 + describe: 'Skip verification',
32 + requiresArg: false,
33 + type: 'boolean',
34 + default: false,
35 + },
36 }).argv;
37
38 function printSummary(commit) {
@@ -48,8 +54,13 @@ function printSummary(commit) {
54 }
55
56 const main = async () => {
57 + const {commit, releaseChannel, noVerify} = argv;
58 try {
52 - await downloadBuildArtifacts(argv.commit, argv.releaseChannel);
59 + await downloadBuildArtifacts({
60 + commit,
61 + releaseChannel,
62 + noVerify,
63 + });
64 printSummary(argv.commit);
65 } catch (error) {
66 handleError(error);
scripts/release/prepare-release-from-ci.js
+4 -4
@@ -19,10 +19,10 @@ const run = async () => {
19 const params = await parseParams();
20 params.cwd = join(__dirname, '..', '..');
21
22 - await downloadBuildArtifacts(
23 - params.commit,
24 - params.releaseChannel ?? process.env.RELEASE_CHANNEL
25 - );
22 + await downloadBuildArtifacts({
23 + commit: params.commit,
24 + releaseChannel: params.releaseChannel ?? process.env.RELEASE_CHANNEL,
25 + });
26
27 if (!params.skipTests) {
28 await testPackagingFixture(params);
scripts/release/shared-commands/download-build-artifacts.js
+29 -23
@@ -85,7 +85,7 @@ async function getArtifact(workflowRunId, artifactName) {
85 return artifact;
86 }
87
88 -async function processArtifact(artifact, commit, releaseChannel) {
88 +async function processArtifact(artifact, opts) {
89 // Download and extract artifact
90 const cwd = join(__dirname, '..', '..', '..');
91 const tmpDir = mkdtempSync(join(os.tmpdir(), 'react_'));
@@ -97,14 +97,18 @@ async function processArtifact(artifact, commit, releaseChannel) {
97 }
98 );
99
100 - // Use https://cli.github.com/manual/gh_attestation_verify to verify artifact
101 - if (executableIsAvailable('gh')) {
102 - await exec(
103 - `gh attestation verify artifacts_combined.zip --repo=${OWNER}/${REPO}`,
104 - {
105 - cwd: tmpDir,
106 - }
107 - );
100 + if (opts.noVerify === true) {
101 + console.log(theme`{caution Skipping verification of build artifact.}`);
102 + } else {
103 + // Use https://cli.github.com/manual/gh_attestation_verify to verify artifact
104 + if (executableIsAvailable('gh')) {
105 + await exec(
106 + `gh attestation verify artifacts_combined.zip --repo=${OWNER}/${REPO}`,
107 + {
108 + cwd: tmpDir,
109 + }
110 + );
111 + }
112 }
113
114 await exec(
@@ -124,17 +128,19 @@ async function processArtifact(artifact, commit, releaseChannel) {
128 }
129 let sourceDir;
130 // TODO: Rename release channel to `next`
127 - if (releaseChannel === 'stable') {
131 + if (opts.releaseChannel === 'stable') {
132 sourceDir = 'oss-stable';
129 - } else if (releaseChannel === 'experimental') {
133 + } else if (opts.releaseChannel === 'experimental') {
134 sourceDir = 'oss-experimental';
131 - } else if (releaseChannel === 'rc') {
135 + } else if (opts.releaseChannel === 'rc') {
136 sourceDir = 'oss-stable-rc';
133 - } else if (releaseChannel === 'latest') {
137 + } else if (opts.releaseChannel === 'latest') {
138 sourceDir = 'oss-stable-semver';
139 } else {
136 - console.error('Internal error: Invalid release channel: ' + releaseChannel);
137 - process.exit(releaseChannel);
140 + console.error(
141 + 'Internal error: Invalid release channel: ' + opts.releaseChannel
142 + );
143 + process.exit(opts.releaseChannel);
144 }
145 await exec(`cp -r ./build/${sourceDir} ./build/node_modules`, {
146 cwd,
@@ -145,19 +151,19 @@ async function processArtifact(artifact, commit, releaseChannel) {
151 /[\u0000-\u001F\u007F-\u009F]/g,
152 ''
153 );
148 - if (buildSha !== commit) {
154 + if (buildSha !== opts.commit) {
155 throw new Error(
150 - `Requested commit sha does not match downloaded artifact. Expected: ${commit}, got: ${buildSha}`
156 + `Requested commit sha does not match downloaded artifact. Expected: ${opts.commit}, got: ${buildSha}`
157 );
158 }
159 }
160
155 -async function downloadArtifactsFromGitHub(commit, releaseChannel) {
161 +async function downloadArtifactsFromGitHub(opts) {
162 let workflowRun;
163 let retries = 0;
164 // wait up to 10 mins for build to finish: 10 * 60 * 1_000) / 30_000 = 20
165 while (retries < 20) {
160 - workflowRun = await getWorkflowRun(commit);
166 + workflowRun = await getWorkflowRun(opts.commit);
167 if (typeof workflowRun.status === 'string') {
168 switch (workflowRun.status) {
169 case 'queued':
@@ -174,7 +180,7 @@ async function downloadArtifactsFromGitHub(commit, releaseChannel) {
180 workflowRun.id,
181 'artifacts_combined'
182 );
177 - await processArtifact(artifact, commit, releaseChannel);
183 + await processArtifact(artifact, opts);
184 return;
185 } else {
186 console.log(
@@ -207,10 +213,10 @@ ${workflowRun != null ? JSON.stringify(workflowRun, null, '\t') : workflowRun}`
213 process.exit(1);
214 }
215
210 -async function downloadBuildArtifacts(commit, releaseChannel) {
211 - const label = theme`commit {commit ${commit}})`;
216 +async function downloadBuildArtifacts(opts) {
217 + const label = theme`commit {commit ${opts.commit}})`;
218 return logPromise(
213 - downloadArtifactsFromGitHub(commit, releaseChannel),
219 + downloadArtifactsFromGitHub(opts),
220 theme`Downloading artifacts from GitHub for ${label}`
221 );
222 }