[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
This commit is contained in:
lauren 2025-03-25 11:16:19 -04:00 committed by GitHub
parent dc9b74647e
commit 44c4693539
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 61 additions and 33 deletions

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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}`
);
}