react/scripts/release/shared-commands/download-build-artifacts.js
lauren 7e4c258e16
[scripts] Verify artifact integrity when downloading (#32728)
Uses https://cli.github.com/manual/gh_attestation_verify to verify that
the downloaded artifact matches the attestation generated during the
build process in runtime_commit_artifacts.

Example:

On a workflow run of runtime_build_and_test.yml with no attestations:
```
$ scripts/release/download-experimental-build.js --commit=ea5f065745b777cb41cc9e54a3b29ed8c727a574

Command failed: gh attestation verify artifacts_combined.zip --repo=facebook/react

Error: failed to fetch attestations from facebook/react: HTTP 404: Not Found (https://api.github.com/repos/facebook/react/attestations/sha256:7adba0992ba477a927aad5a07f95ee2deb7d18427c84279d33fc40a3bc28ebaa?per_page=30)
`gh attestation verify artifacts_combined.zip --repo=facebook/react` (exited with error code 1)
```

On one which does:

```
$ scripts/release/download-experimental-build.js --commit=12e85d74c1c233cdc2f3228a97473a4435d50c3b

✓ Downloading artifacts from GitHub for commit 12e85d74c1c233cdc2f3228a97473a4435d50c3b) 10.5 secs
An experimental build has been downloaded!

You can download this build again by running:
  scripts/download-experimental-build.js --commit=12e85d74c1c233cdc2f3228a97473a4435d50c3b
```
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/32728).
* #32729
* __->__ #32728
2025-03-24 18:24:33 -04:00

221 lines
6.2 KiB
JavaScript

'use strict';
const {join} = require('path');
const theme = require('../theme');
const {exec} = require('child-process-promise');
const {existsSync, mkdtempSync, readFileSync} = require('fs');
const {logPromise} = require('../utils');
const os = require('os');
if (process.env.GH_TOKEN == null) {
console.log(
theme`{error Expected GH_TOKEN to be provided as an env variable}`
);
process.exit(1);
}
const OWNER = 'facebook';
const REPO = 'react';
const WORKFLOW_ID = 'runtime_build_and_test.yml';
const GITHUB_HEADERS = `
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${process.env.GH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28"`.trim();
async function executableIsAvailable(name) {
try {
await exec(`which ${name}`);
return true;
} catch (_error) {
return false;
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getWorkflowId() {
if (
existsSync(join(__dirname, `../../../.github/workflows/${WORKFLOW_ID}`))
) {
return WORKFLOW_ID;
} else {
throw new Error(
`Incorrect workflow ID: .github/workflows/${WORKFLOW_ID} does not exist. Please check the name of the workflow being downloaded from.`
);
}
}
async function getWorkflowRun(commit) {
const res = await exec(
`curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${OWNER}/${REPO}/actions/workflows/${getWorkflowId()}/runs?head_sha=${commit}`
);
const json = JSON.parse(res.stdout);
const workflowRun = json.workflow_runs.find(run => run.head_sha === commit);
if (workflowRun == null || workflowRun.id == null) {
console.log(
theme`{error The workflow run for the specified commit (${commit}) could not be found.}`
);
process.exit(1);
}
return workflowRun;
}
async function getArtifact(workflowRunId, artifactName) {
const res = await exec(
`curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${OWNER}/${REPO}/actions/runs/${workflowRunId}/artifacts?per_page=100&name=${artifactName}`
);
const json = JSON.parse(res.stdout);
const artifact = json.artifacts.find(
_artifact => _artifact.name === artifactName
);
if (artifact == null) {
console.log(
theme`{error The specified workflow run (${workflowRunId}) does not contain any build artifacts.}`
);
process.exit(1);
}
return artifact;
}
async function processArtifact(artifact, commit, releaseChannel) {
// Download and extract artifact
const cwd = join(__dirname, '..', '..', '..');
const tmpDir = mkdtempSync(join(os.tmpdir(), 'react_'));
await exec(`rm -rf ./build`, {cwd});
await exec(
`curl -L ${GITHUB_HEADERS} ${artifact.archive_download_url} > artifacts_combined.zip`,
{
cwd: tmpDir,
}
);
// 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(
`unzip ${tmpDir}/artifacts_combined.zip -d . && rm build2.tgz && tar -xvzf build.tgz && rm build.tgz`,
{
cwd,
}
);
// Copy to staging directory
// TODO: Consider staging the release in a different directory from the CI
// build artifacts: `./build/node_modules` -> `./staged-releases`
if (!existsSync(join(cwd, 'build'))) {
await exec(`mkdir ./build`, {cwd});
} else {
await exec(`rm -rf ./build/node_modules`, {cwd});
}
let sourceDir;
// TODO: Rename release channel to `next`
if (releaseChannel === 'stable') {
sourceDir = 'oss-stable';
} else if (releaseChannel === 'experimental') {
sourceDir = 'oss-experimental';
} else if (releaseChannel === 'rc') {
sourceDir = 'oss-stable-rc';
} else if (releaseChannel === 'latest') {
sourceDir = 'oss-stable-semver';
} else {
console.error('Internal error: Invalid release channel: ' + releaseChannel);
process.exit(releaseChannel);
}
await exec(`cp -r ./build/${sourceDir} ./build/node_modules`, {
cwd,
});
// Validate artifact
const buildSha = readFileSync('./build/COMMIT_SHA', 'utf8').replace(
/[\u0000-\u001F\u007F-\u009F]/g,
''
);
if (buildSha !== commit) {
throw new Error(
`Requested commit sha does not match downloaded artifact. Expected: ${commit}, got: ${buildSha}`
);
}
}
async function downloadArtifactsFromGitHub(commit, releaseChannel) {
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);
if (typeof workflowRun.status === 'string') {
switch (workflowRun.status) {
case 'queued':
case 'in_progress':
case 'waiting': {
retries++;
console.log(theme`Build still in progress, waiting 30s...`);
await sleep(30_000);
break;
}
case 'completed': {
if (workflowRun.conclusion === 'success') {
const artifact = await getArtifact(
workflowRun.id,
'artifacts_combined'
);
await processArtifact(artifact, commit, releaseChannel);
return;
} else {
console.log(
theme`{error Could not download build as its conclusion was: ${workflowRun.conclusion}}`
);
process.exit(1);
}
break;
}
default: {
console.log(
theme`{error Unhandled workflow run status: ${workflowRun.status}}`
);
process.exit(1);
}
}
} else {
retries++;
console.log(
theme`{error Expected workflow run status to be a string, got: ${workflowRun.status}. Retrying...}`
);
}
}
console.log(
theme`{error Could not download build from GitHub. Last workflow run: }
${workflowRun != null ? JSON.stringify(workflowRun, null, '\t') : workflowRun}`
);
process.exit(1);
}
async function downloadBuildArtifacts(commit, releaseChannel) {
const label = theme`commit {commit ${commit}})`;
return logPromise(
downloadArtifactsFromGitHub(commit, releaseChannel),
theme`Downloading artifacts from GitHub for ${label}`
);
}
module.exports = {
downloadBuildArtifacts,
};