mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
1f57ae5249
Summary: bypass-github-export-checks OSS release infrastructure for the (experimental) React Native DevTools standalone shell. Currently, binaries are built continuously on Meta infra and served from the Meta CDN using fbcdn.net URLs checked into a DotSlash file in the repo, e.g.: https://github.com/facebook/react-native/blob/15373218ec572c0e43325845b80a849ad5174cc3/packages/debugger-shell/bin/react-native-devtools#L9-L18 For open source releases we want to primarily distribute the binaries as GitHub release assets, while keeping the Meta CDN URLs as a secondary option. This PR makes the necessary changes to the release workflows to support this: * `workflows/create-release.yml` (modified): As part of the release commit, rewrite the DotSlash file to include the release asset URLs. * **NOTE:** After this commit, **the new URLs don't work yet**, because they refer to a release that hasn't been published. Despite this, the DotSlash file remains valid and usable (because DotSlash will happily fall back to the Meta CDN URLs, which are still in the file). * `workflows/create-draft-release.yml` (modified): After creating a draft release, fetch the binaries from the Meta CDN and reupload them to GitHub as release assets. This is based on the contents of the DotSlash file rewritten by `create-release.yml`. * `workflows/validate-dotslash-artifacts.yml` (new): After the release is published, all URLs referenced by the DotSlash (both Meta CDN URL and GH release asset URLs) should be valid and refer to the same artifacts. This workflow checks that this is the case. * If this workflow fails on a published release, the release may need to be burned or a hotfix release may be necessary - as the release will stop working correctly once the Meta CDN stops serving the assets. * This workflow will also be running continuously on `main`. If it fails on a commit in `main`, there might be a connectivity issue between the GHA runner and the Meta CDN, or there might be an issue on the Meta side. NOTE: These changes to the release pipeline are generic and reusable; if we later add another DotSlash-based tool whose binaries need to be mirrored as GitHub release assets, we just need to add it to the `FIRST_PARTY_DOTSLASH_FILES` array. ## Changelog: [Internal] Mirror React Native DevTools binaries in GitHub Releases Pull Request resolved: https://github.com/facebook/react-native/pull/52930 Test Plan: ### Step 0: Unit tests I've added unit tests for `dotslash-utils`, `curl-utils`, and for the majority of the logic that makes up the new release scripts (`write-dotslash-release-assets-urls`, `upload-release-assets-for-dotslash`, `validate-dotslash-artifacts`). ### Step 1: Test release commit Created a test branch and draft PR: https://github.com/facebook/react-native/pull/53147. Locally created a release commit, simulating the create-release GH workflow: ``` node scripts/releases/create-release-commit.js --reactNativeVersion 0.82.0-20250903-0830 --no-dry-run ``` This updated the DotSlash file in the branch: https://github.com/facebook/react-native/pull/53147/commits/2deeb7e70376ee80b99f27bea4825789f22a89a3#diff-205a9ff6005e30be061eaa64b9cb50b15b0e909dd188e0866189e952655a3483 NOTE: I've also ensured that the `create-release-commit` script correctly updates the DotSlash file when running from a branch that already has a release commit - see screenshot: <img width="1483" height="587" alt="image" src="https://github.com/user-attachments/assets/1ffd859b-e02b-483d-8067-9cc9116829a4" /> ### Step 2: Test draft release Enabled testing the create-draft-release GH workflow in the test branch using these temporary hacks: * https://github.com/facebook/react-native/pull/53147/commits/81f334eac5147d4dbf5f6d7d627ddfa52cd197be * https://github.com/facebook/react-native/pull/53147/commits/6d8851657629de7e0b710ed8f5dd7d0f7b9847cc * https://github.com/facebook/react-native/pull/53147/commits/1428a8da8b9fb29c45fc33d79f311dd1fe273433 Workflow run: https://github.com/facebook/react-native/actions/runs/17426711373/job/49475327346 Draft release: https://github.com/facebook/react-native/releases/tag/untagged-c6a62a58e5baa37936e1 Draft release screenshot for posterity (since we'll likely delete the draft release after landing this): <img width="1024" height="814" alt="image" src="https://github.com/user-attachments/assets/1900da15-48f6-4274-b29c-0ac2019d92c0" /> ### Step 3: Test post-release validation script For obvious reasons, I've avoided actually publishing the above draft release. But I have run the `validate-dotslash-artifacts` workflow on the *current* branch to ensure that the logic is correct: https://github.com/motiz88/react-native/actions/runs/17426885205/job/49475888486 Running `node scripts/releases/validate-dotslash-artifacts.js` in the release branch (without publishing the release first) fails, as expected: <img width="1105" height="748" alt="image" src="https://github.com/user-attachments/assets/ed23a2e2-7a31-42eb-a324-f1d50eafe2fb" /> ## Next steps This PR is all the infra needed ahead of the 0.82 ~~branch cut~~ infra freeze to support the React Native DevTools standalone shell, at least on the GitHub side. ~~Some minor infra work remains on the Meta side, plus some product/logic changes to the React Native DevTools standalone shell that I'm intending to finish in time for 0.82 (for an experimental rollout).~~ EDIT: All the planned work has landed; the feature is code-complete on `main` as well as in `0.82-stable` (apart from this infra change). As a one-off, once we've actually published 0.82.0-rc.1, we'll want to have a human look at the published artifacts and CI workflow logs to ensure everything is in order. (I'll make sure to communicate this to the 0.82 release crew.) Afterwards, the automation added in this PR should be sufficient. Reviewed By: huntie Differential Revision: D81578704 Pulled By: motiz88 fbshipit-source-id: 6a4a48c3713221a89dd5fc88851674c1ddc6bb10
396 lines
11 KiB
JavaScript
396 lines
11 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow strict-local
|
|
* @format
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const {REPO_ROOT} = require('../shared/consts');
|
|
const {getWithCurl} = require('./utils/curl-utils');
|
|
const {
|
|
isHttpProvider,
|
|
processDotSlashFileInPlace,
|
|
validateDotSlashArtifactData,
|
|
} = require('./utils/dotslash-utils');
|
|
const {
|
|
FIRST_PARTY_DOTSLASH_FILES,
|
|
} = require('./write-dotslash-release-asset-urls');
|
|
const {Octokit} = require('@octokit/rest');
|
|
const nullthrows = require('nullthrows');
|
|
const path = require('path');
|
|
const {parseArgs} = require('util');
|
|
|
|
/*::
|
|
import type {DotSlashProvider, DotSlashHttpProvider, DotSlashArtifactInfo} from './utils/dotslash-utils';
|
|
import type {IOctokit} from './utils/octokit-utils';
|
|
|
|
type GitHubReleaseAsset = {id: string, ...};
|
|
type ReleaseAssetMap = $ReadOnlyMap<string, GitHubReleaseAsset>;
|
|
|
|
type ReleaseInfo = $ReadOnly<{
|
|
releaseId: string,
|
|
releaseTag: string,
|
|
existingAssetsByName: ReleaseAssetMap,
|
|
}>;
|
|
|
|
type ExecutionOptions = $ReadOnly<{
|
|
force: boolean,
|
|
dryRun: boolean,
|
|
}>;
|
|
*/
|
|
|
|
async function main() {
|
|
const {
|
|
positionals: [version],
|
|
values: {help, token, releaseId, force, dryRun},
|
|
} = parseArgs({
|
|
allowPositionals: true,
|
|
options: {
|
|
token: {type: 'string'},
|
|
releaseId: {type: 'string'},
|
|
force: {type: 'boolean', default: false},
|
|
dryRun: {type: 'boolean', default: false},
|
|
help: {type: 'boolean'},
|
|
},
|
|
});
|
|
|
|
if (help) {
|
|
console.log(`
|
|
Usage: node ./scripts/releases/upload-release-assets-for-dotslash.js <version> --release_id <id> --token <github-token> [--force] [--dry-run]
|
|
|
|
Scans first-party DotSlash files in the repo for URLs referencing assets of
|
|
an upcoming release, and uploads the actual assets to the GitHub release
|
|
identified by the given release ID.
|
|
|
|
Options:
|
|
<version> The version of the release to upload assets for, with or
|
|
without the 'v' prefix.
|
|
--dry-run Do not upload release assets.
|
|
--force Overwrite existing release assets.
|
|
--release_id The ID of the GitHub release to upload assets to.
|
|
--token A GitHub token with write access to the release.
|
|
`);
|
|
return;
|
|
}
|
|
|
|
if (version == null) {
|
|
throw new Error('Missing version argument');
|
|
}
|
|
|
|
await uploadReleaseAssetsForDotSlashFiles({
|
|
version,
|
|
token,
|
|
releaseId,
|
|
force,
|
|
dryRun,
|
|
});
|
|
}
|
|
|
|
async function uploadReleaseAssetsForDotSlashFiles(
|
|
{version, token, releaseId, force = false, dryRun = false} /*: {
|
|
version: string,
|
|
token: string,
|
|
releaseId: string,
|
|
force?: boolean,
|
|
dryRun?: boolean,
|
|
} */,
|
|
) /*: Promise<void> */ {
|
|
const releaseTag = version.startsWith('v') ? version : `v${version}`;
|
|
const octokit = new Octokit({auth: token});
|
|
const existingAssetsByName = await getReleaseAssetMap(
|
|
{
|
|
releaseId,
|
|
},
|
|
octokit,
|
|
);
|
|
const releaseInfo = {
|
|
releaseId,
|
|
releaseTag,
|
|
existingAssetsByName,
|
|
};
|
|
const executionOptions = {
|
|
force,
|
|
dryRun,
|
|
};
|
|
for (const filename of FIRST_PARTY_DOTSLASH_FILES) {
|
|
await uploadReleaseAssetsForDotSlashFile(
|
|
filename,
|
|
releaseInfo,
|
|
executionOptions,
|
|
octokit,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all release assets for a particular GitHub release ID, and return them
|
|
* as a map keyed by asset names.
|
|
*/
|
|
async function getReleaseAssetMap(
|
|
{releaseId} /*: {
|
|
releaseId: string,
|
|
} */,
|
|
octokit /*: IOctokit */,
|
|
) /*: Promise<ReleaseAssetMap> */ {
|
|
const existingAssets = await octokit.repos.listReleaseAssets({
|
|
owner: 'facebook',
|
|
repo: 'react-native',
|
|
release_id: releaseId,
|
|
});
|
|
return new Map(existingAssets.data.map(asset => [asset.name, asset]));
|
|
}
|
|
|
|
/**
|
|
* Given a first-party DotSlash file path in the repo, reupload the referenced
|
|
* binaries from the upstream provider (typically: Meta CDN) to the draft
|
|
* release (hosted on GitHub).
|
|
*/
|
|
async function uploadReleaseAssetsForDotSlashFile(
|
|
filename /*: string */,
|
|
releaseInfo /*: ReleaseInfo */,
|
|
executionOptions /*: ExecutionOptions */,
|
|
octokit /*: IOctokit */,
|
|
) /*: Promise<void> */ {
|
|
const fullPath = path.resolve(REPO_ROOT, filename);
|
|
console.log(`Uploading assets for ${filename}...`);
|
|
await processDotSlashFileInPlace(
|
|
fullPath,
|
|
async (providers, suggestedFilename, artifactInfo) => {
|
|
await fetchUpstreamAssetAndUploadToRelease(
|
|
{
|
|
providers,
|
|
suggestedFilename,
|
|
artifactInfo,
|
|
dotslashFilename: filename,
|
|
},
|
|
releaseInfo,
|
|
executionOptions,
|
|
octokit,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Given a description of a DotSlash artifact for a particular platform,
|
|
* infers the upstream URL ( = where the binary is currently available) and
|
|
* release asset URL ( = where the binary will be hosted after the release),
|
|
* then downloads the asset from the the upstream URL and uploads it to GitHub
|
|
* at the desired URL.
|
|
*/
|
|
async function fetchUpstreamAssetAndUploadToRelease(
|
|
{
|
|
providers,
|
|
// NOTE: We mostly ignore suggestedFilename in favour of reading the actual asset URLs
|
|
suggestedFilename,
|
|
artifactInfo,
|
|
dotslashFilename,
|
|
} /*: {
|
|
providers: $ReadOnlyArray<DotSlashProvider>,
|
|
suggestedFilename: string,
|
|
artifactInfo: DotSlashArtifactInfo,
|
|
dotslashFilename: string,
|
|
} */,
|
|
releaseInfo /*: ReleaseInfo */,
|
|
executionOptions /*: ExecutionOptions */,
|
|
octokit /*: IOctokit */,
|
|
) {
|
|
const targetReleaseAssetInfo = providers
|
|
.map(provider => parseReleaseAssetInfo(provider, releaseInfo.releaseTag))
|
|
.find(Boolean);
|
|
if (targetReleaseAssetInfo == null) {
|
|
console.log(
|
|
`[${suggestedFilename} (suggested)] DotSlash file does not reference any release URLs for this asset - ignoring.`,
|
|
);
|
|
return;
|
|
}
|
|
const upstreamProvider /*: ?DotSlashHttpProvider */ = providers
|
|
.filter(isHttpProvider)
|
|
.find(provider => !parseReleaseAssetInfo(provider, releaseInfo.releaseTag));
|
|
if (upstreamProvider == null) {
|
|
throw new Error(
|
|
`No upstream URL found for release asset ${targetReleaseAssetInfo.name}`,
|
|
);
|
|
}
|
|
const existingAsset = releaseInfo.existingAssetsByName.get(
|
|
targetReleaseAssetInfo.name,
|
|
);
|
|
if (existingAsset && !executionOptions.force) {
|
|
console.log(
|
|
`[${targetReleaseAssetInfo.name}] Skipping existing release asset...`,
|
|
);
|
|
return;
|
|
}
|
|
await maybeDeleteExistingReleaseAsset(
|
|
{
|
|
name: targetReleaseAssetInfo.name,
|
|
existingAsset,
|
|
},
|
|
executionOptions,
|
|
octokit,
|
|
);
|
|
const {data, contentType} = await fetchAndValidateUpstreamAsset({
|
|
name: targetReleaseAssetInfo.name,
|
|
url: upstreamProvider.url,
|
|
artifactInfo,
|
|
});
|
|
if (executionOptions.dryRun) {
|
|
console.log(
|
|
`[${targetReleaseAssetInfo.name}] Dry run: Not uploading to release.`,
|
|
);
|
|
return;
|
|
}
|
|
await uploadAndVerifyReleaseAsset(
|
|
{
|
|
name: targetReleaseAssetInfo.name,
|
|
url: targetReleaseAssetInfo.url,
|
|
data,
|
|
contentType,
|
|
releaseId: releaseInfo.releaseId,
|
|
dotslashFilename,
|
|
},
|
|
octokit,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks whether the given DotSlash artifact provider refers to an asset URL
|
|
* that is part of the current release. Returns the asset name as well as the
|
|
* full URL if that is the case. Returns null otherwise.
|
|
*/
|
|
function parseReleaseAssetInfo(
|
|
provider /*: DotSlashProvider */,
|
|
releaseTag /*: string */,
|
|
) /*:
|
|
?{
|
|
name: string,
|
|
url: string,
|
|
}
|
|
*/ {
|
|
const releaseAssetPrefix = `https://github.com/facebook/react-native/releases/download/${encodeURIComponent(releaseTag)}/`;
|
|
|
|
if (isHttpProvider(provider) && provider.url.startsWith(releaseAssetPrefix)) {
|
|
return {
|
|
name: decodeURIComponent(provider.url.slice(releaseAssetPrefix.length)),
|
|
url: provider.url,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Deletes the specified release asset if it exists, unless we are in dry run
|
|
* mode (in which case this is a noop).
|
|
*/
|
|
async function maybeDeleteExistingReleaseAsset(
|
|
{name, existingAsset} /*: {
|
|
name: string,
|
|
existingAsset: ?GitHubReleaseAsset,
|
|
}
|
|
*/,
|
|
{dryRun} /*: ExecutionOptions */,
|
|
octokit /*: IOctokit */,
|
|
) /*: Promise<void> */ {
|
|
if (!existingAsset) {
|
|
return;
|
|
}
|
|
if (dryRun) {
|
|
console.log(`[${name}] Dry run: Not deleting existing release asset.`);
|
|
return;
|
|
}
|
|
console.log(`[${name}] Deleting existing release asset...`);
|
|
await octokit.repos.deleteReleaseAsset({
|
|
owner: 'facebook',
|
|
repo: 'react-native',
|
|
asset_id: existingAsset.id,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Given a description of a DotSlash artifact, downloads it and verifies its
|
|
* size and hash (similarly to how DotSlash itself would do it after release).
|
|
*/
|
|
async function fetchAndValidateUpstreamAsset(
|
|
{name, url, artifactInfo} /*: {
|
|
name: string,
|
|
url: string,
|
|
artifactInfo: DotSlashArtifactInfo,
|
|
} */,
|
|
) /*: Promise<{
|
|
data: Buffer,
|
|
contentType: string,
|
|
}> */ {
|
|
console.log(`[${name}] Downloading from ${url}...`);
|
|
// NOTE: Using curl because we have seen issues with fetch() on GHA
|
|
// and the Meta CDN. ¯\_(ツ)_/¯
|
|
const {data, contentType} = await getWithCurl(url);
|
|
console.log(`[${name}] Validating download...`);
|
|
await validateDotSlashArtifactData(data, artifactInfo);
|
|
return {
|
|
data,
|
|
contentType: contentType ?? 'application/octet-stream',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Uploads the specified asset to a GitHub release.
|
|
*
|
|
* By the time we call this function, we have already commited (and published!)
|
|
* a reference to the asset's eventual URL, so we also verify that the URL path
|
|
* hasn't changed in the process.
|
|
*/
|
|
async function uploadAndVerifyReleaseAsset(
|
|
{name, data, contentType, url, releaseId, dotslashFilename} /*: {
|
|
name: string,
|
|
data: Buffer,
|
|
contentType: string,
|
|
url: string,
|
|
releaseId: string,
|
|
dotslashFilename: string,
|
|
}
|
|
*/,
|
|
octokit /*: IOctokit */,
|
|
) /*: Promise<void> */ {
|
|
console.log(`[${name}] Uploading to release...`);
|
|
const {
|
|
data: {browser_download_url},
|
|
} = await octokit.repos.uploadReleaseAsset({
|
|
owner: 'facebook',
|
|
repo: 'react-native',
|
|
release_id: releaseId,
|
|
name,
|
|
data,
|
|
headers: {
|
|
'content-type': contentType,
|
|
},
|
|
});
|
|
|
|
// Once uploaded, check that the name didn't get mangled.
|
|
const actualUrlPathname = new URL(browser_download_url).pathname;
|
|
const actualAssetName = decodeURIComponent(
|
|
nullthrows(/[^/]*$/.exec(actualUrlPathname))[0],
|
|
);
|
|
if (actualAssetName !== name) {
|
|
throw new Error(
|
|
`Asset name was changed while uploading to the draft release: expected ${name}, got ${actualAssetName}. ` +
|
|
`${dotslashFilename} has already been published to npm with the following URL, which will not work when the release is published on GitHub: ${url}`,
|
|
);
|
|
}
|
|
console.log(`[${name}] Uploaded to ${browser_download_url}`);
|
|
}
|
|
|
|
module.exports = {
|
|
uploadReleaseAssetsForDotSlashFiles,
|
|
getReleaseAssetMap,
|
|
uploadReleaseAssetsForDotSlashFile,
|
|
};
|
|
|
|
if (require.main === module) {
|
|
void main();
|
|
}
|