Repository: facebook/react
Pull request: [ci] Dont sign builds originating from anything other than facebook/react


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

Changed files:
filename: .github/workflows/runtime_build_and_test.yml
patch: @@ -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

filename: scripts/release/download-experimental-build.js
patch: @@ -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);

filename: scripts/release/prepare-release-from-ci.js
patch: @@ -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);

filename: scripts/release/shared-commands/download-build-artifacts.js
patch: @@ -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}`
   );
 }