'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, opts) { // 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, } ); 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( `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 (opts.releaseChannel === 'stable') { sourceDir = 'oss-stable'; } else if (opts.releaseChannel === 'experimental') { sourceDir = 'oss-experimental'; } else if (opts.releaseChannel === 'rc') { sourceDir = 'oss-stable-rc'; } else if (opts.releaseChannel === 'latest') { sourceDir = 'oss-stable-semver'; } else { console.error( 'Internal error: Invalid release channel: ' + opts.releaseChannel ); process.exit(opts.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 !== opts.commit) { throw new Error( `Requested commit sha does not match downloaded artifact. Expected: ${opts.commit}, got: ${buildSha}` ); } } 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(opts.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, opts); 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(opts) { const label = theme`commit {commit ${opts.commit}})`; return logPromise( downloadArtifactsFromGitHub(opts), theme`Downloading artifacts from GitHub for ${label}` ); } module.exports = { downloadBuildArtifacts, };