From 44c46935394c22bf69c1935cb0b708d178091024 Mon Sep 17 00:00:00 2001 From: lauren Date: Tue, 25 Mar 2025 11:16:19 -0400 Subject: [PATCH] [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 --- .github/workflows/runtime_build_and_test.yml | 21 ++++++-- .../release/download-experimental-build.js | 13 ++++- scripts/release/prepare-release-from-ci.js | 8 +-- .../download-build-artifacts.js | 52 +++++++++++-------- 4 files changed, 61 insertions(+), 33 deletions(-) diff --git a/.github/workflows/runtime_build_and_test.yml b/.github/workflows/runtime_build_and_test.yml index 4478647aa0..c4a1ba2d4f 100644 --- a/.github/workflows/runtime_build_and_test.yml +++ b/.github/workflows/runtime_build_and_test.yml @@ -481,6 +481,13 @@ jobs: ./build2.tgz if-no-files-found: error - uses: actions/attest-build-provenance@v2 + # We don't verify builds generated from pull requests not originating from facebook/react. + # However, if the PR lands, the run on `main` will generate the attestation which can then + # be used to download a build via scripts/release/download-experimental-build.js. + # + # Note that this means that scripts/release/download-experimental-build.js must be run with + # --no-verify when downloading a build from a fork. + if: github.event.pull_request.head.repo.full_name != github.repository with: subject-name: artifacts_combined.zip subject-digest: sha256:${{ steps.upload_artifacts_combined.outputs.artifact-digest }} @@ -806,14 +813,18 @@ jobs: - run: yarn --cwd scripts/release install --frozen-lockfile if: steps.node_modules.outputs.cache-hit != 'true' - name: Download artifacts for base revision + # The build could have been generated from a fork, so we must download the build without + # any verification. This is safe since we only use this for sizebot calculation and the + # unverified artifact is not used. Additionally this workflow runs in the pull_request + # trigger so only restricted permissions are available. run: | - GH_TOKEN=${{ github.token }} scripts/release/download-experimental-build.js --commit=$(git rev-parse ${{ github.event.pull_request.base.sha }}) + 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') || ''}} mv ./build ./base-build - # TODO: The `download-experimental-build` script copies the npm - # packages into the `node_modules` directory. This is a historical - # quirk of how the release script works. Let's pretend they - # don't exist. - name: Delete extraneous files + # TODO: The `download-experimental-build` script copies the npm + # packages into the `node_modules` directory. This is a historical + # quirk of how the release script works. Let's pretend they + # don't exist. run: rm -rf ./base-build/node_modules - name: Display structure of base-build from origin/main run: ls -R base-build diff --git a/scripts/release/download-experimental-build.js b/scripts/release/download-experimental-build.js index a698ad00cb..756af0ca55 100755 --- a/scripts/release/download-experimental-build.js +++ b/scripts/release/download-experimental-build.js @@ -27,6 +27,12 @@ const argv = yargs.wrap(yargs.terminalWidth()).options({ demandOption: true, type: 'string', }, + 'no-verify': { + describe: 'Skip verification', + requiresArg: false, + type: 'boolean', + default: false, + }, }).argv; function printSummary(commit) { @@ -48,8 +54,13 @@ function printSummary(commit) { } const main = async () => { + const {commit, releaseChannel, noVerify} = argv; try { - await downloadBuildArtifacts(argv.commit, argv.releaseChannel); + await downloadBuildArtifacts({ + commit, + releaseChannel, + noVerify, + }); printSummary(argv.commit); } catch (error) { handleError(error); diff --git a/scripts/release/prepare-release-from-ci.js b/scripts/release/prepare-release-from-ci.js index 344c1d665e..19943e36d4 100755 --- a/scripts/release/prepare-release-from-ci.js +++ b/scripts/release/prepare-release-from-ci.js @@ -19,10 +19,10 @@ const run = async () => { const params = await parseParams(); params.cwd = join(__dirname, '..', '..'); - await downloadBuildArtifacts( - params.commit, - params.releaseChannel ?? process.env.RELEASE_CHANNEL - ); + await downloadBuildArtifacts({ + commit: params.commit, + releaseChannel: params.releaseChannel ?? process.env.RELEASE_CHANNEL, + }); if (!params.skipTests) { await testPackagingFixture(params); diff --git a/scripts/release/shared-commands/download-build-artifacts.js b/scripts/release/shared-commands/download-build-artifacts.js index 23e3541110..79d4ac6c6e 100644 --- a/scripts/release/shared-commands/download-build-artifacts.js +++ b/scripts/release/shared-commands/download-build-artifacts.js @@ -85,7 +85,7 @@ async function getArtifact(workflowRunId, artifactName) { return artifact; } -async function processArtifact(artifact, commit, releaseChannel) { +async function processArtifact(artifact, opts) { // Download and extract artifact const cwd = join(__dirname, '..', '..', '..'); const tmpDir = mkdtempSync(join(os.tmpdir(), 'react_')); @@ -97,14 +97,18 @@ async function processArtifact(artifact, commit, releaseChannel) { } ); - // Use https://cli.github.com/manual/gh_attestation_verify to verify artifact - if (executableIsAvailable('gh')) { - await exec( - `gh attestation verify artifacts_combined.zip --repo=${OWNER}/${REPO}`, - { - cwd: tmpDir, - } - ); + if (opts.noVerify === true) { + console.log(theme`{caution Skipping verification of build artifact.}`); + } else { + // Use https://cli.github.com/manual/gh_attestation_verify to verify artifact + if (executableIsAvailable('gh')) { + await exec( + `gh attestation verify artifacts_combined.zip --repo=${OWNER}/${REPO}`, + { + cwd: tmpDir, + } + ); + } } await exec( @@ -124,17 +128,19 @@ async function processArtifact(artifact, commit, releaseChannel) { } let sourceDir; // TODO: Rename release channel to `next` - if (releaseChannel === 'stable') { + if (opts.releaseChannel === 'stable') { sourceDir = 'oss-stable'; - } else if (releaseChannel === 'experimental') { + } else if (opts.releaseChannel === 'experimental') { sourceDir = 'oss-experimental'; - } else if (releaseChannel === 'rc') { + } else if (opts.releaseChannel === 'rc') { sourceDir = 'oss-stable-rc'; - } else if (releaseChannel === 'latest') { + } else if (opts.releaseChannel === 'latest') { sourceDir = 'oss-stable-semver'; } else { - console.error('Internal error: Invalid release channel: ' + releaseChannel); - process.exit(releaseChannel); + console.error( + 'Internal error: Invalid release channel: ' + opts.releaseChannel + ); + process.exit(opts.releaseChannel); } await exec(`cp -r ./build/${sourceDir} ./build/node_modules`, { cwd, @@ -145,19 +151,19 @@ async function processArtifact(artifact, commit, releaseChannel) { /[\u0000-\u001F\u007F-\u009F]/g, '' ); - if (buildSha !== commit) { + if (buildSha !== opts.commit) { throw new Error( - `Requested commit sha does not match downloaded artifact. Expected: ${commit}, got: ${buildSha}` + `Requested commit sha does not match downloaded artifact. Expected: ${opts.commit}, got: ${buildSha}` ); } } -async function downloadArtifactsFromGitHub(commit, releaseChannel) { +async function downloadArtifactsFromGitHub(opts) { let workflowRun; let retries = 0; // wait up to 10 mins for build to finish: 10 * 60 * 1_000) / 30_000 = 20 while (retries < 20) { - workflowRun = await getWorkflowRun(commit); + workflowRun = await getWorkflowRun(opts.commit); if (typeof workflowRun.status === 'string') { switch (workflowRun.status) { case 'queued': @@ -174,7 +180,7 @@ async function downloadArtifactsFromGitHub(commit, releaseChannel) { workflowRun.id, 'artifacts_combined' ); - await processArtifact(artifact, commit, releaseChannel); + await processArtifact(artifact, opts); return; } else { console.log( @@ -207,10 +213,10 @@ ${workflowRun != null ? JSON.stringify(workflowRun, null, '\t') : workflowRun}` process.exit(1); } -async function downloadBuildArtifacts(commit, releaseChannel) { - const label = theme`commit {commit ${commit}})`; +async function downloadBuildArtifacts(opts) { + const label = theme`commit {commit ${opts.commit}})`; return logPromise( - downloadArtifactsFromGitHub(commit, releaseChannel), + downloadArtifactsFromGitHub(opts), theme`Downloading artifacts from GitHub for ${label}` ); }