| 1 | 'use strict'; |
| 2 | |
| 3 | const {join} = require('path'); |
| 4 | const theme = require('../theme'); |
| 5 | const {exec} = require('child-process-promise'); |
| 6 | const {existsSync, mkdtempSync, readFileSync} = require('fs'); |
| 7 | const {logPromise} = require('../utils'); |
| 8 | const os = require('os'); |
| 9 | |
| 10 | if (process.env.GH_TOKEN == null) { |
| 11 | console.log( |
| 12 | theme`{error Expected GH_TOKEN to be provided as an env variable}` |
| 13 | ); |
| 14 | process.exit(1); |
| 15 | } |
| 16 | |
| 17 | const REPO = process.env.GITHUB_REPOSITORY || 'facebook/react'; |
| 18 | const WORKFLOW_ID = 'runtime_build_and_test.yml'; |
| 19 | const GITHUB_HEADERS = ` |
| 20 | -H "Accept: application/vnd.github+json" \ |
| 21 | -H "Authorization: Bearer ${process.env.GH_TOKEN}" \ |
| 22 | -H "X-GitHub-Api-Version: 2022-11-28"`.trim(); |
| 23 | |
| 24 | async function executableIsAvailable(name) { |
| 25 | try { |
| 26 | await exec(`which ${name}`); |
| 27 | return true; |
| 28 | } catch (_error) { |
| 29 | return false; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | function sleep(ms) { |
| 34 | return new Promise(resolve => setTimeout(resolve, ms)); |
| 35 | } |
| 36 | |
| 37 | function getWorkflowId() { |
| 38 | if ( |
| 39 | existsSync(join(__dirname, `../../../.github/workflows/${WORKFLOW_ID}`)) |
| 40 | ) { |
| 41 | return WORKFLOW_ID; |
| 42 | } else { |
| 43 | throw new Error( |
| 44 | `Incorrect workflow ID: .github/workflows/${WORKFLOW_ID} does not exist. Please check the name of the workflow being downloaded from.` |
| 45 | ); |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | async function getWorkflowRun(commit) { |
| 50 | const res = await exec( |
| 51 | `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${REPO}/actions/workflows/${getWorkflowId()}/runs?head_sha=${commit}` |
| 52 | ); |
| 53 | |
| 54 | const json = JSON.parse(res.stdout); |
| 55 | const workflowRun = json.workflow_runs.find(run => run.head_sha === commit); |
| 56 | |
| 57 | if (workflowRun == null || workflowRun.id == null) { |
| 58 | console.log( |
| 59 | theme`{error The workflow run for the specified commit (${commit}) could not be found.}` |
| 60 | ); |
| 61 | process.exit(1); |
| 62 | } |
| 63 | |
| 64 | return workflowRun; |
| 65 | } |
| 66 | |
| 67 | async function getArtifact(workflowRunId, artifactName) { |
| 68 | const res = await exec( |
| 69 | `curl -L ${GITHUB_HEADERS} https://api.github.com/repos/${REPO}/actions/runs/${workflowRunId}/artifacts?per_page=100&name=${artifactName}` |
| 70 | ); |
| 71 | |
| 72 | const json = JSON.parse(res.stdout); |
| 73 | const artifact = json.artifacts.find( |
| 74 | _artifact => _artifact.name === artifactName |
| 75 | ); |
| 76 | |
| 77 | if (artifact == null) { |
| 78 | console.log( |
| 79 | theme`{error The specified workflow run (${workflowRunId}) does not contain any build artifacts.}` |
| 80 | ); |
| 81 | process.exit(1); |
| 82 | } |
| 83 | |
| 84 | return artifact; |
| 85 | } |
| 86 | |
| 87 | async function processArtifact(artifact, opts) { |
| 88 | // Download and extract artifact |
| 89 | const cwd = join(__dirname, '..', '..', '..'); |
| 90 | const tmpDir = mkdtempSync(join(os.tmpdir(), 'react_')); |
| 91 | await exec(`rm -rf ./build`, {cwd}); |
| 92 | await exec( |
| 93 | `curl -L ${GITHUB_HEADERS} ${artifact.archive_download_url} > artifacts_combined.zip`, |
| 94 | { |
| 95 | cwd: tmpDir, |
| 96 | } |
| 97 | ); |
| 98 | |
| 99 | if (opts.noVerify === true) { |
| 100 | console.log(theme`{caution Skipping verification of build artifact.}`); |
| 101 | } else { |
| 102 | // Use https://cli.github.com/manual/gh_attestation_verify to verify artifact |
| 103 | if (executableIsAvailable('gh')) { |
| 104 | await exec( |
| 105 | `gh attestation verify artifacts_combined.zip --repo=${REPO}`, |
| 106 | { |
| 107 | cwd: tmpDir, |
| 108 | } |
| 109 | ); |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | await exec( |
| 114 | `unzip ${tmpDir}/artifacts_combined.zip -d . && rm build2.tgz && tar -xvzf build.tgz && rm build.tgz`, |
| 115 | { |
| 116 | cwd, |
| 117 | } |
| 118 | ); |
| 119 | |
| 120 | // Copy to staging directory |
| 121 | // TODO: Consider staging the release in a different directory from the CI |
| 122 | // build artifacts: `./build/node_modules` -> `./staged-releases` |
| 123 | if (!existsSync(join(cwd, 'build'))) { |
| 124 | await exec(`mkdir ./build`, {cwd}); |
| 125 | } else { |
| 126 | await exec(`rm -rf ./build/node_modules`, {cwd}); |
| 127 | } |
| 128 | let sourceDir; |
| 129 | // TODO: Rename release channel to `next` |
| 130 | if (opts.releaseChannel === 'stable') { |
| 131 | sourceDir = 'oss-stable'; |
| 132 | } else if (opts.releaseChannel === 'experimental') { |
| 133 | sourceDir = 'oss-experimental'; |
| 134 | } else if (opts.releaseChannel === 'rc') { |
| 135 | sourceDir = 'oss-stable-rc'; |
| 136 | } else if (opts.releaseChannel === 'latest') { |
| 137 | sourceDir = 'oss-stable-semver'; |
| 138 | } else { |
| 139 | console.error( |
| 140 | 'Internal error: Invalid release channel: ' + opts.releaseChannel |
| 141 | ); |
| 142 | process.exit(opts.releaseChannel); |
| 143 | } |
| 144 | await exec(`cp -r ./build/${sourceDir} ./build/node_modules`, { |
| 145 | cwd, |
| 146 | }); |
| 147 | |
| 148 | // Validate artifact |
| 149 | const buildSha = readFileSync('./build/COMMIT_SHA', 'utf8').replace( |
| 150 | /[\u0000-\u001F\u007F-\u009F]/g, |
| 151 | '' |
| 152 | ); |
| 153 | if (buildSha !== opts.commit) { |
| 154 | throw new Error( |
| 155 | `Requested commit sha does not match downloaded artifact. Expected: ${opts.commit}, got: ${buildSha}` |
| 156 | ); |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | async function downloadArtifactsFromGitHub(opts) { |
| 161 | let workflowRun; |
| 162 | let retries = 0; |
| 163 | // wait up to 10 mins for build to finish: 10 * 60 * 1_000) / 30_000 = 20 |
| 164 | while (retries < 20) { |
| 165 | workflowRun = await getWorkflowRun(opts.commit); |
| 166 | if (typeof workflowRun.status === 'string') { |
| 167 | switch (workflowRun.status) { |
| 168 | case 'queued': |
| 169 | case 'in_progress': |
| 170 | case 'waiting': { |
| 171 | retries++; |
| 172 | console.log(theme`Build still in progress, waiting 30s...`); |
| 173 | await sleep(30_000); |
| 174 | break; |
| 175 | } |
| 176 | case 'completed': { |
| 177 | if (workflowRun.conclusion === 'success') { |
| 178 | const artifact = await getArtifact( |
| 179 | workflowRun.id, |
| 180 | 'artifacts_combined' |
| 181 | ); |
| 182 | await processArtifact(artifact, opts); |
| 183 | return; |
| 184 | } else { |
| 185 | console.log( |
| 186 | theme`{error Could not download build as its conclusion was: ${workflowRun.conclusion}}` |
| 187 | ); |
| 188 | process.exit(1); |
| 189 | } |
| 190 | break; |
| 191 | } |
| 192 | default: { |
| 193 | console.log( |
| 194 | theme`{error Unhandled workflow run status: ${workflowRun.status}}` |
| 195 | ); |
| 196 | process.exit(1); |
| 197 | } |
| 198 | } |
| 199 | } else { |
| 200 | retries++; |
| 201 | console.log( |
| 202 | theme`{error Expected workflow run status to be a string, got: ${workflowRun.status}. Retrying...}` |
| 203 | ); |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | console.log( |
| 208 | theme`{error Could not download build from GitHub. Last workflow run: } |
| 209 | |
| 210 | ${workflowRun != null ? JSON.stringify(workflowRun, null, '\t') : workflowRun}` |
| 211 | ); |
| 212 | process.exit(1); |
| 213 | } |
| 214 | |
| 215 | async function downloadBuildArtifacts(opts) { |
| 216 | const label = theme`commit {commit ${opts.commit}})`; |
| 217 | return logPromise( |
| 218 | downloadArtifactsFromGitHub(opts), |
| 219 | theme`Downloading artifacts from GitHub for ${label}` |
| 220 | ); |
| 221 | } |
| 222 | |
| 223 | module.exports = { |
| 224 | downloadBuildArtifacts, |
| 225 | }; |