From cf8bde168952171efd62115bd09258d6d574c9b7 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Thu, 4 Sep 2025 11:25:39 -0700 Subject: [PATCH] Distribute React Native DevTools binaries via GitHub Releases (#52930) 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. [Internal] Mirror React Native DevTools binaries in GitHub Releases Pull Request resolved: https://github.com/facebook/react-native/pull/52930 Test Plan: 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`). 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: image 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): image 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: image 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 --- .../__tests__/createDraftRelease-test.js | 18 +- .../workflow-scripts/createDraftRelease.js | 9 +- .github/workflows/create-draft-release.yml | 17 +- .../workflows/validate-dotslash-artifacts.yml | 48 ++ flow-typed/npm/@expo/spawn-async_v1.x.x.js | 46 ++ flow-typed/npm/@octokit/rest_v22.x.x.js | 61 +++ flow-typed/npm/fb-dotslash_v0.x.x.js | 13 + flow-typed/npm/jsonc-parser_v2.2.x.js | 421 ++++++++++++++++++ package.json | 5 + .../debugger-shell/src/node/index.flow.js | 6 +- .../src/node/private/LaunchUtils.js | 10 +- ...d-release-assets-for-dotslash-test.js.snap | 203 +++++++++ ...e-dotslash-release-asset-urls-test.js.snap | 128 ++++++ scripts/releases/__tests__/snapshot-utils.js | 92 ++++ ...upload-release-assets-for-dotslash-test.js | 351 +++++++++++++++ .../write-dotslash-release-asset-urls-test.js | 203 +++++++++ scripts/releases/create-release-commit.js | 6 + .../upload-release-assets-for-dotslash.js | 395 ++++++++++++++++ .../__snapshots__/dotslash-utils-test.js.snap | 102 +++++ .../utils/__tests__/curl-utils-test.js | 60 +++ .../utils/__tests__/dotslash-utils-test.js | 267 +++++++++++ scripts/releases/utils/curl-utils.js | 73 +++ scripts/releases/utils/dotslash-utils.js | 214 +++++++++ scripts/releases/utils/octokit-utils.js | 58 +++ .../releases/validate-dotslash-artifacts.js | 99 ++++ .../write-dotslash-release-asset-urls.js | 171 +++++++ yarn.lock | 161 ++++++- 27 files changed, 3215 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/validate-dotslash-artifacts.yml create mode 100644 flow-typed/npm/@expo/spawn-async_v1.x.x.js create mode 100644 flow-typed/npm/@octokit/rest_v22.x.x.js create mode 100644 flow-typed/npm/fb-dotslash_v0.x.x.js create mode 100644 flow-typed/npm/jsonc-parser_v2.2.x.js create mode 100644 scripts/releases/__tests__/__snapshots__/upload-release-assets-for-dotslash-test.js.snap create mode 100644 scripts/releases/__tests__/__snapshots__/write-dotslash-release-asset-urls-test.js.snap create mode 100644 scripts/releases/__tests__/snapshot-utils.js create mode 100644 scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js create mode 100644 scripts/releases/__tests__/write-dotslash-release-asset-urls-test.js create mode 100644 scripts/releases/upload-release-assets-for-dotslash.js create mode 100644 scripts/releases/utils/__tests__/__snapshots__/dotslash-utils-test.js.snap create mode 100644 scripts/releases/utils/__tests__/curl-utils-test.js create mode 100644 scripts/releases/utils/__tests__/dotslash-utils-test.js create mode 100644 scripts/releases/utils/curl-utils.js create mode 100644 scripts/releases/utils/dotslash-utils.js create mode 100644 scripts/releases/utils/octokit-utils.js create mode 100644 scripts/releases/validate-dotslash-artifacts.js create mode 100644 scripts/releases/write-dotslash-release-asset-urls.js diff --git a/.github/workflow-scripts/__tests__/createDraftRelease-test.js b/.github/workflow-scripts/__tests__/createDraftRelease-test.js index 77901d4df0999e..587e48e6d320bc 100644 --- a/.github/workflow-scripts/__tests__/createDraftRelease-test.js +++ b/.github/workflow-scripts/__tests__/createDraftRelease-test.js @@ -188,6 +188,7 @@ View the whole changelog in the [CHANGELOG.md file](https://github.com/facebook/ status: 201, json: () => Promise.resolve({ + id: 1, html_url: 'https://github.com/facebook/react-native/releases/tag/v0.77.1', }), @@ -208,9 +209,11 @@ View the whole changelog in the [CHANGELOG.md file](https://github.com/facebook/ body: fetchBody, }, ); - expect(response).toEqual( - 'https://github.com/facebook/react-native/releases/tag/v0.77.1', - ); + expect(response).toEqual({ + id: 1, + html_url: + 'https://github.com/facebook/react-native/releases/tag/v0.77.1', + }); }); it('creates a draft release for prerelease on GitHub', async () => { @@ -238,6 +241,7 @@ View the whole changelog in the [CHANGELOG.md file](https://github.com/facebook/ status: 201, json: () => Promise.resolve({ + id: 1, html_url: 'https://github.com/facebook/react-native/releases/tag/v0.77.1', }), @@ -258,9 +262,11 @@ View the whole changelog in the [CHANGELOG.md file](https://github.com/facebook/ body: fetchBody, }, ); - expect(response).toEqual( - 'https://github.com/facebook/react-native/releases/tag/v0.77.1', - ); + expect(response).toEqual({ + id: 1, + html_url: + 'https://github.com/facebook/react-native/releases/tag/v0.77.1', + }); }); it('throws if the post failes', async () => { diff --git a/.github/workflow-scripts/createDraftRelease.js b/.github/workflow-scripts/createDraftRelease.js index f8737c3cbfda4c..7b7692ce2975d4 100644 --- a/.github/workflow-scripts/createDraftRelease.js +++ b/.github/workflow-scripts/createDraftRelease.js @@ -101,7 +101,11 @@ async function _createDraftReleaseOnGitHub(version, body, latest, token) { } const data = await response.json(); - return data.html_url; + const {html_url, id} = data; + return { + html_url, + id, + }; } function moveToChangelogBranch(version) { @@ -124,7 +128,8 @@ async function createDraftRelease(version, latest, token) { latest, token, ); - log(`Created draft release: ${release}`); + log(`Created draft release: ${release.html_url}, ID ${release.id}`); + return release; } module.exports = { diff --git a/.github/workflows/create-draft-release.yml b/.github/workflows/create-draft-release.yml index d3b89dba90d805..5852b8fec7105c 100644 --- a/.github/workflows/create-draft-release.yml +++ b/.github/workflows/create-draft-release.yml @@ -21,9 +21,24 @@ jobs: git config --local user.name "React Native Bot" - name: Create draft release uses: actions/github-script@v6 + id: create-draft-release with: script: | const {createDraftRelease} = require('./.github/workflow-scripts/createDraftRelease.js'); const version = '${{ github.ref_name }}'; const {isLatest} = require('./.github/workflow-scripts/publishTemplate.js'); - await createDraftRelease(version, isLatest(), '${{secrets.REACT_NATIVE_BOT_GITHUB_TOKEN}}'); + return (await createDraftRelease(version, isLatest(), '${{secrets.REACT_NATIVE_BOT_GITHUB_TOKEN}}')).id; + result-encoding: string + - name: Upload release assets for DotSlash + uses: actions/github-script@v6 + env: + RELEASE_ID: ${{ steps.create-draft-release.outputs.result }} + with: + script: | + const {uploadReleaseAssetsForDotSlashFiles} = require('./scripts/releases/upload-release-assets-for-dotslash.js'); + const version = '${{ github.ref_name }}'; + await uploadReleaseAssetsForDotSlashFiles({ + version, + token: '${{secrets.REACT_NATIVE_BOT_GITHUB_TOKEN}}', + releaseId: process.env.RELEASE_ID, + }); diff --git a/.github/workflows/validate-dotslash-artifacts.yml b/.github/workflows/validate-dotslash-artifacts.yml new file mode 100644 index 00000000000000..2b62d3b34c8eff --- /dev/null +++ b/.github/workflows/validate-dotslash-artifacts.yml @@ -0,0 +1,48 @@ +name: Validate DotSlash Artifacts + +on: + workflow_dispatch: + release: + types: [published] + push: + branches: + - main + paths: + - packages/debugger-shell/bin/react-native-devtools + - "scripts/releases/**" + - package.json + - yarn.lock + pull_request: + branches: + - main + paths: + - packages/debugger-shell/bin/react-native-devtools + - "scripts/releases/**" + - package.json + - yarn.lock + # Same time as the nightly build: 2:15 AM UTC + schedule: + - cron: "15 2 * * *" + +jobs: + validate-dotslash-artifacts: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Install dependencies + uses: ./.github/actions/yarn-install + - name: Configure Git + shell: bash + run: | + git config --local user.email "bot@reactnative.dev" + git config --local user.name "React Native Bot" + - name: Validate DotSlash artifacts + uses: actions/github-script@v6 + with: + script: | + const {validateDotSlashArtifacts} = require('./scripts/releases/validate-dotslash-artifacts.js'); + await validateDotSlashArtifacts(); diff --git a/flow-typed/npm/@expo/spawn-async_v1.x.x.js b/flow-typed/npm/@expo/spawn-async_v1.x.x.js new file mode 100644 index 00000000000000..45c6187c9aac80 --- /dev/null +++ b/flow-typed/npm/@expo/spawn-async_v1.x.x.js @@ -0,0 +1,46 @@ +/** + * 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 + */ + +declare module '@expo/spawn-async' { + type SpawnOptions = { + cwd?: string, + env?: Object, + argv0?: string, + stdio?: string | Array, + detached?: boolean, + uid?: number, + gid?: number, + shell?: boolean | string, + windowsVerbatimArguments?: boolean, + windowsHide?: boolean, + encoding?: string, + ignoreStdio?: boolean, + }; + + declare class SpawnPromise extends Promise { + child: child_process$ChildProcess; + } + type SpawnResult = { + pid?: number, + output: string[], + stdout: string, + stderr: string, + status: number | null, + signal: string | null, + }; + + declare function spawnAsync( + command: string, + args?: $ReadOnlyArray, + options?: SpawnOptions, + ): SpawnPromise; + + declare module.exports: typeof spawnAsync; +} diff --git a/flow-typed/npm/@octokit/rest_v22.x.x.js b/flow-typed/npm/@octokit/rest_v22.x.x.js new file mode 100644 index 00000000000000..9c193173e2cbc4 --- /dev/null +++ b/flow-typed/npm/@octokit/rest_v22.x.x.js @@ -0,0 +1,61 @@ +/** + * 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 + */ + +// Partial types for Octokit based on the usage in react-native-github +declare module '@octokit/rest' { + declare class Octokit { + constructor(options?: {auth?: string, ...}): this; + + repos: $ReadOnly<{ + listReleaseAssets: ( + params: $ReadOnly<{ + owner: string, + repo: string, + release_id: string, + }>, + ) => Promise<{ + data: Array<{ + id: string, + name: string, + ... + }>, + ... + }>, + uploadReleaseAsset: ( + params: $ReadOnly<{ + owner: string, + repo: string, + release_id: string, + name: string, + data: Buffer, + headers: $ReadOnly<{ + 'content-type': string, + ... + }>, + ... + }>, + ) => Promise<{ + data: { + browser_download_url: string, + ... + }, + ... + }>, + deleteReleaseAsset: (params: { + owner: string, + repo: string, + asset_id: string, + ... + }) => Promise, + }>; + } + + declare export {Octokit}; +} diff --git a/flow-typed/npm/fb-dotslash_v0.x.x.js b/flow-typed/npm/fb-dotslash_v0.x.x.js new file mode 100644 index 00000000000000..41c01f297da70f --- /dev/null +++ b/flow-typed/npm/fb-dotslash_v0.x.x.js @@ -0,0 +1,13 @@ +/** + * 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 + */ + +declare module 'fb-dotslash' { + declare module.exports: string; +} diff --git a/flow-typed/npm/jsonc-parser_v2.2.x.js b/flow-typed/npm/jsonc-parser_v2.2.x.js new file mode 100644 index 00000000000000..50316cb13e9139 --- /dev/null +++ b/flow-typed/npm/jsonc-parser_v2.2.x.js @@ -0,0 +1,421 @@ +/** + * 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 + */ + +declare module 'jsonc-parser' { + /** + * Creates a JSON scanner on the given text. + * If ignoreTrivia is set, whitespaces or comments are ignored. + */ + declare export const createScanner: ( + text: string, + ignoreTrivia?: boolean, + ) => JSONScanner; + export type ScanError = number; + export type SyntaxKind = number; + /** + * The scanner object, representing a JSON scanner at a position in the input string. + */ + export type JSONScanner = $ReadOnly<{ + /** + * Sets the scan position to a new offset. A call to 'scan' is needed to get the first token. + */ + setPosition(pos: number): void, + /** + * Read the next token. Returns the token code. + */ + scan(): SyntaxKind, + /** + * Returns the zero-based current scan position, which is after the last read token. + */ + getPosition(): number, + /** + * Returns the last read token. + */ + getToken(): SyntaxKind, + /** + * Returns the last read token value. The value for strings is the decoded string content. For numbers it's of type number, for boolean it's true or false. + */ + getTokenValue(): string, + /** + * The zero-based start offset of the last read token. + */ + getTokenOffset(): number, + /** + * The length of the last read token. + */ + getTokenLength(): number, + /** + * The zero-based start line number of the last read token. + */ + getTokenStartLine(): number, + /** + * The zero-based start character (column) of the last read token. + */ + getTokenStartCharacter(): number, + /** + * An error code of the last scan. + */ + getTokenError(): ScanError, + }>; + /** + * For a given offset, evaluate the location in the JSON document. Each segment in the location path is either a property name or an array index. + */ + declare export const getLocation: ( + text: string, + position: number, + ) => Location; + /** + * Parses the given text and returns the object the JSON content represents. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result. + * Therefore, always check the errors list to find out if the input was valid. + */ + declare export const parse: ( + text: string, + errors?: ParseError[], + options?: ParseOptions, + ) => any; + /** + * Parses the given text and returns a tree representation the JSON content. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result. + */ + declare export const parseTree: ( + text: string, + errors?: ParseError[], + options?: ParseOptions, + ) => Node | void; + /** + * Finds the node at the given path in a JSON DOM. + */ + declare export const findNodeAtLocation: ( + root: Node, + path: JSONPath, + ) => Node | void; + /** + * Finds the innermost node at the given offset. If includeRightBound is set, also finds nodes that end at the given offset. + */ + declare export const findNodeAtOffset: ( + root: Node, + offset: number, + includeRightBound?: boolean, + ) => Node | void; + /** + * Gets the JSON path of the given JSON DOM node + */ + declare export const getNodePath: (node: Node) => JSONPath; + /** + * Evaluates the JavaScript object of the given JSON DOM node + */ + declare export const getNodeValue: (node: Node) => any; + /** + * Parses the given text and invokes the visitor functions for each object, array and literal reached. + */ + declare export const visit: ( + text: string, + visitor: JSONVisitor, + options?: ParseOptions, + ) => any; + /** + * Takes JSON with JavaScript-style comments and remove + * them. Optionally replaces every none-newline character + * of comments with a replaceCharacter + */ + declare export const stripComments: ( + text: string, + replaceCh?: string, + ) => string; + export type ParseError = { + error: ParseErrorCode, + offset: number, + length: number, + }; + export type ParseErrorCode = number; + declare export function printParseErrorCode( + code: ParseErrorCode, + ): + | 'InvalidSymbol' + | 'InvalidNumberFormat' + | 'PropertyNameExpected' + | 'ValueExpected' + | 'ColonExpected' + | 'CommaExpected' + | 'CloseBraceExpected' + | 'CloseBracketExpected' + | 'EndOfFileExpected' + | 'InvalidCommentToken' + | 'UnexpectedEndOfComment' + | 'UnexpectedEndOfString' + | 'UnexpectedEndOfNumber' + | 'InvalidUnicode' + | 'InvalidEscapeCharacter' + | 'InvalidCharacter' + | ''; + export type NodeType = + | 'object' + | 'array' + | 'property' + | 'string' + | 'number' + | 'boolean' + | 'null'; + export type Node = { + type: NodeType, + value?: any, + offset: number, + length: number, + colonOffset?: number, + parent?: Node, + children?: Node[], + }; + /** + * A {@linkcode JSONPath} segment. Either a string representing an object property name + * or a number (starting at 0) for array indices. + */ + export type Segment = string | number; + export type JSONPath = Segment[]; + export type Location = { + /** + * The previous property key or literal value (string, number, boolean or null) or undefined. + */ + previousNode?: Node, + /** + * The path describing the location in the JSON document. The path consists of a sequence of strings + * representing an object property or numbers for array indices. + */ + path: JSONPath, + /** + * Matches the locations path against a pattern consisting of strings (for properties) and numbers (for array indices). + * '*' will match a single segment of any property name or index. + * '**' will match a sequence of segments of any property name or index, or no segment. + */ + matches: (patterns: JSONPath) => boolean, + /** + * If set, the location's offset is at a property key. + */ + isAtPropertyKey: boolean, + }; + export type ParseOptions = { + disallowComments?: boolean, + allowTrailingComma?: boolean, + allowEmptyContent?: boolean, + }; + /** + * Visitor called by {@linkcode visit} when parsing JSON. + * + * The visitor functions have the following common parameters: + * - `offset`: Global offset within the JSON document, starting at 0 + * - `startLine`: Line number, starting at 0 + * - `startCharacter`: Start character (column) within the current line, starting at 0 + * + * Additionally some functions have a `pathSupplier` parameter which can be used to obtain the + * current `JSONPath` within the document. + */ + export type JSONVisitor = { + /** + * Invoked when an open brace is encountered and an object is started. The offset and length represent the location of the open brace. + */ + onObjectBegin?: ( + offset: number, + length: number, + startLine: number, + startCharacter: number, + pathSupplier: () => JSONPath, + ) => void, + /** + * Invoked when a property is encountered. The offset and length represent the location of the property name. + * The `JSONPath` created by the `pathSupplier` refers to the enclosing JSON object, it does not include the + * property name yet. + */ + onObjectProperty?: ( + property: string, + offset: number, + length: number, + startLine: number, + startCharacter: number, + pathSupplier: () => JSONPath, + ) => void, + /** + * Invoked when a closing brace is encountered and an object is completed. The offset and length represent the location of the closing brace. + */ + onObjectEnd?: ( + offset: number, + length: number, + startLine: number, + startCharacter: number, + ) => void, + /** + * Invoked when an open bracket is encountered. The offset and length represent the location of the open bracket. + */ + onArrayBegin?: ( + offset: number, + length: number, + startLine: number, + startCharacter: number, + pathSupplier: () => JSONPath, + ) => void, + /** + * Invoked when a closing bracket is encountered. The offset and length represent the location of the closing bracket. + */ + onArrayEnd?: ( + offset: number, + length: number, + startLine: number, + startCharacter: number, + ) => void, + /** + * Invoked when a literal value is encountered. The offset and length represent the location of the literal value. + */ + onLiteralValue?: ( + value: any, + offset: number, + length: number, + startLine: number, + startCharacter: number, + pathSupplier: () => JSONPath, + ) => void, + /** + * Invoked when a comma or colon separator is encountered. The offset and length represent the location of the separator. + */ + onSeparator?: ( + character: string, + offset: number, + length: number, + startLine: number, + startCharacter: number, + ) => void, + /** + * When comments are allowed, invoked when a line or block comment is encountered. The offset and length represent the location of the comment. + */ + onComment?: ( + offset: number, + length: number, + startLine: number, + startCharacter: number, + ) => void, + /** + * Invoked on an error. + */ + onError?: ( + error: ParseErrorCode, + offset: number, + length: number, + startLine: number, + startCharacter: number, + ) => void, + }; + /** + * An edit result describes a textual edit operation. It is the result of a {@linkcode format} and {@linkcode modify} operation. + * It consist of one or more edits describing insertions, replacements or removals of text segments. + * * The offsets of the edits refer to the original state of the document. + * * No two edits change or remove the same range of text in the original document. + * * Multiple edits can have the same offset if they are multiple inserts, or an insert followed by a remove or replace. + * * The order in the array defines which edit is applied first. + * To apply an edit result use {@linkcode applyEdits}. + * In general multiple EditResults must not be concatenated because they might impact each other, producing incorrect or malformed JSON data. + */ + export type EditResult = Edit[]; + /** + * Represents a text modification + */ + export type Edit = { + /** + * The start offset of the modification. + */ + offset: number, + /** + * The length of the modification. Must not be negative. Empty length represents an *insert*. + */ + length: number, + /** + * The new content. Empty content represents a *remove*. + */ + content: string, + }; + /** + * A text range in the document + */ + export type Range = { + /** + * The start offset of the range. + */ + offset: number, + /** + * The length of the range. Must not be negative. + */ + length: number, + }; + /** + * Options used by {@linkcode format} when computing the formatting edit operations + */ + export type FormattingOptions = $ReadOnly<{ + /** + * If indentation is based on spaces (`insertSpaces` = true), the number of spaces that make an indent. + */ + tabSize?: number, + /** + * Is indentation based on spaces? + */ + insertSpaces?: boolean, + /** + * The default 'end of line' character. If not set, '\n' is used as default. + */ + eol?: string, + }>; + /** + * Computes the edit operations needed to format a JSON document. + * + * @param documentText The input text + * @param range The range to format or `undefined` to format the full content + * @param options The formatting options + * @returns The edit operations describing the formatting changes to the original document following the format described in {@linkcode EditResult}. + * To apply the edit operations to the input, use {@linkcode applyEdits}. + */ + declare export function format( + documentText: string, + range: Range | void, + options: FormattingOptions, + ): EditResult; + /** + * Options used by {@linkcode modify} when computing the modification edit operations + */ + export type ModificationOptions = { + /** + * Formatting options. + */ + formattingOptions: FormattingOptions, + /** + * Optional function to define the insertion index given an existing list of properties. + */ + getInsertionIndex?: (properties: string[]) => number, + }; + /** + * Computes the edit operations needed to modify a value in the JSON document. + * + * @param documentText The input text + * @param path The path of the value to change. The path represents either to the document root, a property or an array item. + * If the path points to an non-existing property or item, it will be created. + * @param value The new value for the specified property or item. If the value is undefined, + * the property or item will be removed. + * @param options Options + * @returns The edit operations describing the changes to the original document, following the format described in {@linkcode EditResult}. + * To apply the edit operations to the input, use {@linkcode applyEdits}. + */ + declare export function modify( + text: string, + path: JSONPath, + value: any, + options: ModificationOptions, + ): EditResult; + /** + * Applies edits to an input string. + * @param text The input text + * @param edits Edit operations following the format described in {@linkcode EditResult}. + * @returns The text with the applied edits. + * @throws An error if the edit operations are not well-formed as described in {@linkcode EditResult}. + */ + declare export function applyEdits(text: string, edits: EditResult): string; +} diff --git a/package.json b/package.json index 0c777468893b29..9f4527332198e4 100644 --- a/package.json +++ b/package.json @@ -54,13 +54,16 @@ "@babel/preset-env": "^7.25.3", "@babel/preset-flow": "^7.24.7", "@electron/packager": "^18.3.6", + "@expo/spawn-async": "^1.7.2", "@jest/create-cache-key-function": "^29.7.0", "@microsoft/api-extractor": "^7.52.2", + "@octokit/rest": "^22.0.0", "@react-native/metro-babel-transformer": "0.82.0-rc.0", "@react-native/metro-config": "0.82.0-rc.0", "@tsconfig/node22": "22.0.2", "@types/react": "^19.1.0", "@typescript-eslint/parser": "^8.36.0", + "ansi-regex": "^5.0.0", "ansi-styles": "^4.2.1", "babel-plugin-minify-dead-code-elimination": "^0.5.2", "babel-plugin-syntax-hermes-parser": "0.32.0", @@ -81,6 +84,7 @@ "eslint-plugin-react-native": "^4.0.0", "eslint-plugin-redundant-undefined": "^0.4.0", "eslint-plugin-relay": "^1.8.3", + "fb-dotslash": "0.5.8", "flow-api-translator": "0.32.0", "flow-bin": "^0.280.0", "glob": "^7.1.1", @@ -93,6 +97,7 @@ "jest-diff": "^29.7.0", "jest-junit": "^16.0.0", "jest-snapshot": "^29.7.0", + "jsonc-parser": "2.2.1", "markdownlint-cli2": "^0.17.2", "markdownlint-rule-relative-links": "^3.0.0", "memfs": "^4.7.7", diff --git a/packages/debugger-shell/src/node/index.flow.js b/packages/debugger-shell/src/node/index.flow.js index 06ba4a9eab67db..f76fbc2af241e6 100644 --- a/packages/debugger-shell/src/node/index.flow.js +++ b/packages/debugger-shell/src/node/index.flow.js @@ -152,11 +152,7 @@ function getShellBinaryAndArgs( ): [string, Array] { switch (flavor) { case 'prebuilt': - return [ - // $FlowFixMe[cannot-resolve-module] fb-dotslash includes Flow types but Flow does not pick them up - require('fb-dotslash'), - [DEVTOOLS_BINARY_DOTSLASH_FILE], - ]; + return [require('fb-dotslash'), [DEVTOOLS_BINARY_DOTSLASH_FILE]]; case 'dev': return [ // NOTE: Internally at Meta, this is aliased to a workspace that is diff --git a/packages/debugger-shell/src/node/private/LaunchUtils.js b/packages/debugger-shell/src/node/private/LaunchUtils.js index f73b36201498ff..a0692f64ee09ae 100644 --- a/packages/debugger-shell/src/node/private/LaunchUtils.js +++ b/packages/debugger-shell/src/node/private/LaunchUtils.js @@ -44,11 +44,11 @@ async function spawnAndGetStderr( async function prepareDebuggerShellFromDotSlashFile( filePath: string, ): Promise { - const {code, stderr} = await spawnAndGetStderr( - // $FlowFixMe[cannot-resolve-module] fb-dotslash includes Flow types but Flow does not pick them up - require('fb-dotslash'), - ['--', 'fetch', filePath], - ); + const {code, stderr} = await spawnAndGetStderr(require('fb-dotslash'), [ + '--', + 'fetch', + filePath, + ]); if (code === 0) { return {code: 'success'}; } diff --git a/scripts/releases/__tests__/__snapshots__/upload-release-assets-for-dotslash-test.js.snap b/scripts/releases/__tests__/__snapshots__/upload-release-assets-for-dotslash-test.js.snap new file mode 100644 index 00000000000000..210fcdc260dfe0 --- /dev/null +++ b/scripts/releases/__tests__/__snapshots__/upload-release-assets-for-dotslash-test.js.snap @@ -0,0 +1,203 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`uploadReleaseAssetsForDotSlashFile deletes and reuploads the asset if force is true: console.log calls 1`] = ` +Array [ + Array [ + "Uploading assets for /entry-point...", + ], + Array [ + "[test.tar.gz] Deleting existing release asset...", + ], + Array [ + "[test.tar.gz] Downloading from ...", + ], + Array [ + "[test.tar.gz] Validating download...", + ], + Array [ + "[test.tar.gz] Uploading to release...", + ], + Array [ + "[test.tar.gz] Uploaded to https://github.com/facebook/react-native/releases/download/untagged-0b602d8af97c6d3b784c/test.tar.gz", + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile deletes and reuploads the asset if force is true: deleteReleaseAsset calls 1`] = ` +Array [ + Array [ + Object { + "asset_id": "1", + "owner": "facebook", + "repo": "react-native", + }, + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile deletes and reuploads the asset if force is true: uploadReleaseAsset calls 1`] = ` +Array [ + Array [ + Object { + "data": Object { + "data": Array [], + "type": "Buffer", + }, + "headers": Object { + "content-type": "text/plain", + }, + "name": "test.tar.gz", + "owner": "facebook", + "release_id": "1", + "repo": "react-native", + }, + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile does not overwrite an existing asset if dryRun is true: console.log calls 1`] = ` +Array [ + Array [ + "Uploading assets for /entry-point...", + ], + Array [ + "[test.tar.gz] Skipping existing release asset...", + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile does not upload the asset if dryRun is true: console.log calls 1`] = ` +Array [ + Array [ + "Uploading assets for /entry-point...", + ], + Array [ + "[test.tar.gz] Downloading from ...", + ], + Array [ + "[test.tar.gz] Validating download...", + ], + Array [ + "[test.tar.gz] Dry run: Not uploading to release.", + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile fails loudly if asset has been renamed by GitHub 1`] = `"Asset name was changed while uploading to the draft release: expected test.tar.gz, got test-renamed.tar.gz. /entry-point has already been published to npm with the following URL, which will not work when the release is published on GitHub: https://github.com/facebook/react-native/releases/download/v1000.0.1/test.tar.gz"`; + +exports[`uploadReleaseAssetsForDotSlashFile fails loudly if asset has been renamed by GitHub: console.log calls 1`] = ` +Array [ + Array [ + "Uploading assets for /entry-point...", + ], + Array [ + "[test.tar.gz] Downloading from ...", + ], + Array [ + "[test.tar.gz] Validating download...", + ], + Array [ + "[test.tar.gz] Uploading to release...", + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile fails loudly if asset has been renamed by GitHub: uploadReleaseAsset calls 1`] = ` +Array [ + Array [ + Object { + "data": Object { + "data": Array [], + "type": "Buffer", + }, + "headers": Object { + "content-type": "text/plain", + }, + "name": "test.tar.gz", + "owner": "facebook", + "release_id": "1", + "repo": "react-native", + }, + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile fails loudly if the upstream asset is corrupt 1`] = `"size mismatch: expected 1, got 0"`; + +exports[`uploadReleaseAssetsForDotSlashFile fails loudly if the upstream asset is corrupt: console.log calls 1`] = ` +Array [ + Array [ + "Uploading assets for /entry-point...", + ], + Array [ + "[test.tar.gz] Downloading from ...", + ], + Array [ + "[test.tar.gz] Validating download...", + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile fails loudly if the upstream asset is unreachable 1`] = `"curl --silent --location --output /data /error --write-out %{content_type} --fail exited with non-zero code: 22"`; + +exports[`uploadReleaseAssetsForDotSlashFile fails loudly if the upstream asset is unreachable: console.log calls 1`] = ` +Array [ + Array [ + "Uploading assets for /entry-point...", + ], + Array [ + "[test.tar.gz] Downloading from /error...", + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile skips uploading the asset if already present: console.log calls 1`] = ` +Array [ + Array [ + "Uploading assets for /entry-point...", + ], + Array [ + "[test.tar.gz] Skipping existing release asset...", + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile uploads the asset if not already present: console.log calls 1`] = ` +Array [ + Array [ + "Uploading assets for /entry-point...", + ], + Array [ + "[test.tar.gz] Downloading from ...", + ], + Array [ + "[test.tar.gz] Validating download...", + ], + Array [ + "[test.tar.gz] Uploading to release...", + ], + Array [ + "[test.tar.gz] Uploaded to https://github.com/facebook/react-native/releases/download/untagged-0b602d8af97c6d3b784c/test.tar.gz", + ], +] +`; + +exports[`uploadReleaseAssetsForDotSlashFile uploads the asset if not already present: uploadReleaseAsset calls 1`] = ` +Array [ + Array [ + Object { + "data": Object { + "data": Array [], + "type": "Buffer", + }, + "headers": Object { + "content-type": "text/plain", + }, + "name": "test.tar.gz", + "owner": "facebook", + "release_id": "1", + "repo": "react-native", + }, + ], +] +`; diff --git a/scripts/releases/__tests__/__snapshots__/write-dotslash-release-asset-urls-test.js.snap b/scripts/releases/__tests__/__snapshots__/write-dotslash-release-asset-urls-test.js.snap new file mode 100644 index 00000000000000..8639e3c11a7390 --- /dev/null +++ b/scripts/releases/__tests__/__snapshots__/write-dotslash-release-asset-urls-test.js.snap @@ -0,0 +1,128 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`writeReleaseAssetUrlsToDotSlashFile adds a new release asset provider if missing (first release commit in a branch): console.log calls 1`] = ` +Array [ + Array [ + "Updating /entry-point...", + ], + Array [ + "Downloading from for integrity validation...", + ], + Array [ + "Providers: +", + "- Original ++ Updated + + Array [ + Object { ++ \\"url\\": \\"https://github.com/facebook/react-native/releases/download/v1000.0.1/test-linux-x86_64\\", ++ }, ++ Object { + \\"url\\": \\"\\", + }, + ]", + ], +] +`; + +exports[`writeReleaseAssetUrlsToDotSlashFile adds a new release asset provider if missing (first release commit in a branch): updated dotslash file 1`] = ` +"#!/usr/bin/env dotslash +// @generated SignedSource<> +{ + \\"name\\": \\"test\\", + \\"platforms\\": { + \\"linux-x86_64\\": { + \\"providers\\": [ + { + \\"url\\": \\"https://github.com/facebook/react-native/releases/download/v1000.0.1/test-linux-x86_64\\" + }, + { + \\"url\\": \\"\\" + } + ], + \\"size\\": 0, + \\"hash\\": \\"sha256\\", + \\"digest\\": \\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\\", + \\"path\\": \\"bar\\" + } + } +} +" +`; + +exports[`writeReleaseAssetUrlsToDotSlashFile fails if there are no upstream providers 1`] = `"No upstream HTTP providers found for asset: test-linux-x86_64.tar.gz"`; + +exports[`writeReleaseAssetUrlsToDotSlashFile fails if there are no upstream providers: console.log calls 1`] = ` +Array [ + Array [ + "Updating /entry-point...", + ], +] +`; + +exports[`writeReleaseAssetUrlsToDotSlashFile fails if upstream returns an incorrect asset 1`] = `"size mismatch: expected 1, got 0"`; + +exports[`writeReleaseAssetUrlsToDotSlashFile fails if upstream returns an incorrect asset: console.log calls 1`] = ` +Array [ + Array [ + "Updating /entry-point...", + ], + Array [ + "Downloading from for integrity validation...", + ], +] +`; + +exports[`writeReleaseAssetUrlsToDotSlashFile replaces the old release asset provider if exists (Nth release commit in a branch): console.log calls 1`] = ` +Array [ + Array [ + "Updating /entry-point...", + ], + Array [ + "Downloading from for integrity validation...", + ], + Array [ + "Providers: +", + "- Original ++ Updated + + Array [ + Object { +- \\"url\\": \\"\\", ++ \\"url\\": \\"https://github.com/facebook/react-native/releases/download/v1000.0.1/test-linux-x86_64\\", + }, + Object { +- \\"url\\": \\"https://github.com/facebook/react-native/releases/download/v1000.0.0/test.tar.gz\\", ++ \\"url\\": \\"\\", + }, + ]", + ], +] +`; + +exports[`writeReleaseAssetUrlsToDotSlashFile replaces the old release asset provider if exists (Nth release commit in a branch): updated dotslash file 1`] = ` +"#!/usr/bin/env dotslash +// @generated SignedSource<> +{ + \\"name\\": \\"test\\", + \\"platforms\\": { + \\"linux-x86_64\\": { + \\"providers\\": [ + { + \\"url\\": \\"https://github.com/facebook/react-native/releases/download/v1000.0.1/test-linux-x86_64\\" + }, + { + \\"url\\": \\"\\" + } + ], + \\"size\\": 0, + \\"hash\\": \\"sha256\\", + \\"digest\\": \\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\\", + \\"path\\": \\"bar\\" + } + } +} +" +`; diff --git a/scripts/releases/__tests__/snapshot-utils.js b/scripts/releases/__tests__/snapshot-utils.js new file mode 100644 index 00000000000000..5a0103a88c2efa --- /dev/null +++ b/scripts/releases/__tests__/snapshot-utils.js @@ -0,0 +1,92 @@ +/** + * 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'; + +import ansiRegex from 'ansi-regex'; + +const { + getTempDirPatternForTests: getCurlTempDirPattern, +} = require('../utils/curl-utils'); +const invariant = require('invariant'); + +/** + * Returns a Jest snapshot serializer that replaces the given token or pattern + * with the given replacement. + */ +function sanitizeSnapshots( + tokenOrPattern: string | RegExp | (() => string | RegExp), + replacement: string, +): JestPrettyFormatPlugin { + const test = (val: mixed) => { + if (typeof val !== 'string') { + return false; + } + let tokenOrPatternToTest = tokenOrPattern; + if (typeof tokenOrPatternToTest === 'function') { + tokenOrPatternToTest = tokenOrPatternToTest(); + } + if (typeof tokenOrPatternToTest === 'string') { + return val.includes(tokenOrPatternToTest); + } + return tokenOrPatternToTest.test(val); + }; + const serialize = ( + val: mixed, + config: mixed, + indentation: mixed, + depth: mixed, + refs: mixed, + // $FlowFixMe[unclear-type] TODO: add up-to-date and accurate types for Jest snapshot serializers. + printer: any, + ) => { + invariant(typeof val === 'string', 'Received non-string value.'); + let tokenOrPatternToTest = tokenOrPattern; + if (typeof tokenOrPatternToTest === 'function') { + tokenOrPatternToTest = tokenOrPatternToTest(); + } + const replacedVal = val.replaceAll(tokenOrPatternToTest, replacement); + if (test(replacedVal)) { + // Recursion breaker. + throw new Error( + `Failed to sanitize snapshot: ${replacedVal} still contains ${tokenOrPatternToTest.toString()}`, + ); + } + return printer(replacedVal, config, indentation, depth, refs, printer); + }; + return { + serialize, + test, + // $FlowFixMe[unclear-type] expect.addSnapshotSerializer is typed inaccurately + } as any as JestPrettyFormatPlugin; +} + +/** + * A Jest snapshot serializer that removes ANSI color codes from strings. + */ +const removeAnsiColors = sanitizeSnapshots( + ansiRegex(), + '', +) as JestPrettyFormatPlugin; + +/** + * A Jest snapshot serializer that redacts the exact temporary directory path + * used by curl-utils. + */ +const removeCurlPaths = sanitizeSnapshots( + getCurlTempDirPattern(), + '', +) as JestPrettyFormatPlugin; + +module.exports = { + sanitizeSnapshots, + removeAnsiColors, + removeCurlPaths, +}; diff --git a/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js b/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js new file mode 100644 index 00000000000000..4eae57114c44ba --- /dev/null +++ b/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js @@ -0,0 +1,351 @@ +/** + * 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 { + getReleaseAssetMap, + uploadReleaseAssetsForDotSlashFile, +} = require('../upload-release-assets-for-dotslash'); +const { + removeAnsiColors, + removeCurlPaths, + sanitizeSnapshots, +} = require('./snapshot-utils'); +const fs = require('fs/promises'); +const http = require('http'); +const os = require('os'); +const path = require('path'); + +let server, serverUrl, tmpDir, consoleLog; + +expect.addSnapshotSerializer(sanitizeSnapshots(() => tmpDir, '')); +expect.addSnapshotSerializer(sanitizeSnapshots(() => serverUrl, '')); +expect.addSnapshotSerializer(removeAnsiColors); +expect.addSnapshotSerializer(removeCurlPaths); + +const mockAssets: Array<{ + id: string, + ... +}> = []; + +let nextAssetId = 1; + +const octokit = { + repos: { + listReleaseAssets: jest.fn().mockImplementation(() => { + return { + data: mockAssets, + }; + }), + deleteReleaseAsset: jest.fn().mockImplementation(({asset_id}) => { + const index = mockAssets.findIndex(asset => asset.id === asset_id); + if (index === -1) { + throw new Error('Asset not found'); + } + mockAssets.splice(index, 1); + }), + uploadReleaseAsset: jest.fn().mockImplementation(() => { + let assetId; + do { + assetId = String(nextAssetId++); + } while (mockAssets.some(asset => asset.id === assetId)); + mockAssets.push({ + id: assetId, + }); + return { + data: { + id: assetId, + browser_download_url: `https://github.com/facebook/react-native/releases/download/untagged-0b602d8af97c6d3b784c/test.tar.gz`, + }, + }; + }), + }, +}; + +beforeEach(async () => { + mockAssets.length = 0; + octokit.repos.listReleaseAssets.mockClear(); + octokit.repos.deleteReleaseAsset.mockClear(); + octokit.repos.uploadReleaseAsset.mockClear(); + + consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'upload-release-assets-for-dotslash-test-'), + ); + await new Promise((resolve, reject) => { + server = http.createServer((req, res) => { + if (req.url !== '/') { + res.writeHead(404); + res.end(); + return; + } + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.end(''); + }); + server.on('error', reject); + server.listen(0, 'localhost', () => { + const {port} = server.address(); + serverUrl = `http://localhost:${port}`; + resolve(); + }); + }); +}); + +afterEach(async () => { + consoleLog.mockRestore(); + await new Promise((resolve, reject) => { + server.close(err => { + if (err) { + reject(err); + } + resolve(); + }); + }); + await fs.rm(tmpDir, {recursive: true, force: true}); +}); + +describe('uploadReleaseAssetsForDotSlashFile', () => { + beforeEach(async () => { + // Simulate the repo in a state where the DotSlash file has been updated + // (by write-release-asset-urls-to-dotslash-file) but the release assets + // have not been uploaded yet. + const dotslashContents = `#!/usr/bin/env dotslash +{ + "name": "test", + "platforms": { + "linux-x86_64": { + "providers": [ + {"url": "https://github.com/facebook/react-native/releases/download/v1000.0.1/test.tar.gz"}, + {"url": "${serverUrl}"} + ], + "size": 0, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": "tar.gz", + "path": "bar" + }, + }, +}`; + await fs.writeFile(path.join(tmpDir, 'entry-point'), dotslashContents); + }); + + const releaseId = '1'; + + test('uploads the asset if not already present', async () => { + await uploadReleaseAssetsForDotSlashFile( + path.join(tmpDir, 'entry-point'), + { + releaseId, + releaseTag: 'v1000.0.1', + existingAssetsByName: await getReleaseAssetMap({releaseId}, octokit), + }, + { + force: false, + dryRun: false, + }, + octokit, + ); + + expect(octokit.repos.deleteReleaseAsset).not.toHaveBeenCalled(); + expect(octokit.repos.uploadReleaseAsset.mock.calls).toMatchSnapshot( + 'uploadReleaseAsset calls', + ); + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); + + test('skips uploading the asset if already present', async () => { + mockAssets.push({ + id: '1', + name: 'test.tar.gz', + }); + await uploadReleaseAssetsForDotSlashFile( + path.join(tmpDir, 'entry-point'), + { + releaseId, + releaseTag: 'v1000.0.1', + existingAssetsByName: await getReleaseAssetMap({releaseId}, octokit), + }, + { + force: false, + dryRun: false, + }, + octokit, + ); + + expect(octokit.repos.deleteReleaseAsset).not.toHaveBeenCalled(); + expect(octokit.repos.uploadReleaseAsset).not.toHaveBeenCalled(); + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); + + test('deletes and reuploads the asset if force is true', async () => { + mockAssets.push({ + id: '1', + name: 'test.tar.gz', + }); + await uploadReleaseAssetsForDotSlashFile( + path.join(tmpDir, 'entry-point'), + { + releaseId, + releaseTag: 'v1000.0.1', + existingAssetsByName: await getReleaseAssetMap({releaseId}, octokit), + }, + { + force: true, + dryRun: false, + }, + octokit, + ); + + expect(octokit.repos.deleteReleaseAsset.mock.calls).toMatchSnapshot( + 'deleteReleaseAsset calls', + ); + expect(octokit.repos.uploadReleaseAsset.mock.calls).toMatchSnapshot( + 'uploadReleaseAsset calls', + ); + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); + + test('does not upload the asset if dryRun is true', async () => { + await uploadReleaseAssetsForDotSlashFile( + path.join(tmpDir, 'entry-point'), + { + releaseId, + releaseTag: 'v1000.0.1', + existingAssetsByName: await getReleaseAssetMap({releaseId}, octokit), + }, + { + force: false, + dryRun: true, + }, + octokit, + ); + + expect(octokit.repos.deleteReleaseAsset).not.toHaveBeenCalled(); + expect(octokit.repos.uploadReleaseAsset).not.toHaveBeenCalled(); + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); + + test('does not overwrite an existing asset if dryRun is true', async () => { + mockAssets.push({ + id: '1', + name: 'test.tar.gz', + }); + await uploadReleaseAssetsForDotSlashFile( + path.join(tmpDir, 'entry-point'), + { + releaseId, + releaseTag: 'v1000.0.1', + existingAssetsByName: await getReleaseAssetMap({releaseId}, octokit), + }, + { + force: false, + dryRun: true, + }, + octokit, + ); + + expect(octokit.repos.deleteReleaseAsset).not.toHaveBeenCalled(); + expect(octokit.repos.uploadReleaseAsset).not.toHaveBeenCalled(); + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); + + test('fails loudly if asset has been renamed by GitHub', async () => { + octokit.repos.uploadReleaseAsset.mockImplementationOnce(async () => { + return { + data: { + id: '1', + browser_download_url: `https://github.com/facebook/react-native/releases/download/untagged-0b602d8af97c6d3b784c/test-renamed.tar.gz`, + }, + }; + }); + await expect( + uploadReleaseAssetsForDotSlashFile( + path.join(tmpDir, 'entry-point'), + { + releaseId, + releaseTag: 'v1000.0.1', + existingAssetsByName: await getReleaseAssetMap({releaseId}, octokit), + }, + { + force: false, + dryRun: false, + }, + octokit, + ), + ).rejects.toThrowErrorMatchingSnapshot(); + + expect(octokit.repos.deleteReleaseAsset).not.toHaveBeenCalled(); + expect(octokit.repos.uploadReleaseAsset.mock.calls).toMatchSnapshot( + 'uploadReleaseAsset calls', + ); + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); + + test('fails loudly if the upstream asset is unreachable', async () => { + const dotslashContents = await fs.readFile( + path.join(tmpDir, 'entry-point'), + 'utf8', + ); + await fs.writeFile( + path.join(tmpDir, 'entry-point'), + dotslashContents.replace(serverUrl, `${serverUrl}/error`), + ); + await expect( + uploadReleaseAssetsForDotSlashFile( + path.join(tmpDir, 'entry-point'), + { + releaseId, + releaseTag: 'v1000.0.1', + existingAssetsByName: await getReleaseAssetMap({releaseId}, octokit), + }, + { + force: false, + dryRun: false, + }, + octokit, + ), + ).rejects.toThrowErrorMatchingSnapshot(); + + expect(octokit.repos.deleteReleaseAsset).not.toHaveBeenCalled(); + expect(octokit.repos.uploadReleaseAsset).not.toHaveBeenCalled(); + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); + + test('fails loudly if the upstream asset is corrupt', async () => { + const dotslashContents = await fs.readFile( + path.join(tmpDir, 'entry-point'), + 'utf8', + ); + await fs.writeFile( + path.join(tmpDir, 'entry-point'), + dotslashContents.replace('"size": 0', `"size": 1`), + ); + await expect( + uploadReleaseAssetsForDotSlashFile( + path.join(tmpDir, 'entry-point'), + { + releaseId, + releaseTag: 'v1000.0.1', + existingAssetsByName: await getReleaseAssetMap({releaseId}, octokit), + }, + { + force: false, + dryRun: false, + }, + octokit, + ), + ).rejects.toThrowErrorMatchingSnapshot(); + + expect(octokit.repos.deleteReleaseAsset).not.toHaveBeenCalled(); + expect(octokit.repos.uploadReleaseAsset).not.toHaveBeenCalled(); + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); +}); diff --git a/scripts/releases/__tests__/write-dotslash-release-asset-urls-test.js b/scripts/releases/__tests__/write-dotslash-release-asset-urls-test.js new file mode 100644 index 00000000000000..ea8acf48720c8f --- /dev/null +++ b/scripts/releases/__tests__/write-dotslash-release-asset-urls-test.js @@ -0,0 +1,203 @@ +/** + * 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 { + writeReleaseAssetUrlsToDotSlashFile, +} = require('../write-dotslash-release-asset-urls'); +const {removeAnsiColors, sanitizeSnapshots} = require('./snapshot-utils'); +const fs = require('fs/promises'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const signedsource = require('signedsource'); + +let server, serverUrl, tmpDir, consoleLog; + +expect.addSnapshotSerializer(sanitizeSnapshots(() => tmpDir, '')); +expect.addSnapshotSerializer(sanitizeSnapshots(() => serverUrl, '')); +expect.addSnapshotSerializer( + sanitizeSnapshots( + /SignedSource<<[a-f0-9]{32}>>/g, + 'SignedSource<>', + ), +); +expect.addSnapshotSerializer(removeAnsiColors); + +beforeEach(async () => { + consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'write-dotslash-release-asset-urls-test-'), + ); + await new Promise((resolve, reject) => { + server = http.createServer((req, res) => { + if (req.url !== '/') { + res.writeHead(404); + res.end(); + return; + } + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.end(''); + }); + server.on('error', reject); + server.listen(0, 'localhost', () => { + const {port} = server.address(); + serverUrl = `http://localhost:${port}`; + resolve(); + }); + }); +}); + +afterEach(async () => { + consoleLog.mockRestore(); + await new Promise((resolve, reject) => { + server.close(err => { + if (err) { + reject(err); + } + resolve(); + }); + }); + await fs.rm(tmpDir, {recursive: true, force: true}); +}); + +describe('writeReleaseAssetUrlsToDotSlashFile', () => { + test('fails if there are no upstream providers', async () => { + const dotslashContents = `#!/usr/bin/env dotslash +{ + "name": "test", + "platforms": { + "linux-x86_64": { + "providers": [ + {"url": "https://github.com/facebook/react-native/releases/download/v1000.0.0/test.tar.gz"}, + ], + "size": 0, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": "tar.gz", + "path": "bar" + } + } +} +`; + await fs.writeFile(`${tmpDir}/entry-point`, dotslashContents); + + await expect( + writeReleaseAssetUrlsToDotSlashFile({ + filename: `${tmpDir}/entry-point`, + releaseTag: 'v1000.0.1', + }), + ).rejects.toThrowErrorMatchingSnapshot(); + + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); + + test('adds a new release asset provider if missing (first release commit in a branch)', async () => { + const dotslashContents = `#!/usr/bin/env dotslash +// @${'generated SignedSource<<00000000000000000000000000000000>>'} +{ + "name": "test", + "platforms": { + "linux-x86_64": { + "providers": [ + {"url": "${serverUrl}"}, + ], + "size": 0, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "path": "bar" + } + } +} +`; + await fs.writeFile(`${tmpDir}/entry-point`, dotslashContents); + + await expect( + writeReleaseAssetUrlsToDotSlashFile({ + filename: `${tmpDir}/entry-point`, + releaseTag: 'v1000.0.1', + }), + ).resolves.toBeUndefined(); + + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + + const updatedContents = await fs.readFile(`${tmpDir}/entry-point`, 'utf8'); + + expect(updatedContents).toMatchSnapshot('updated dotslash file'); + expect(signedsource.verifySignature(updatedContents)).toBe(true); + }); + + test('replaces the old release asset provider if exists (Nth release commit in a branch)', async () => { + const dotslashContents = `#!/usr/bin/env dotslash +// @${'generated SignedSource<<00000000000000000000000000000000>>'} +{ + "name": "test", + "platforms": { + "linux-x86_64": { + "providers": [ + {"url": "${serverUrl}"}, + {"url": "https://github.com/facebook/react-native/releases/download/v1000.0.0/test.tar.gz"} + ], + "size": 0, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "path": "bar" + } + } +} +`; + await fs.writeFile(`${tmpDir}/entry-point`, dotslashContents); + + await expect( + writeReleaseAssetUrlsToDotSlashFile({ + filename: `${tmpDir}/entry-point`, + releaseTag: 'v1000.0.1', + }), + ).resolves.toBeUndefined(); + + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + + const updatedContents = await fs.readFile(`${tmpDir}/entry-point`, 'utf8'); + + expect(updatedContents).toMatchSnapshot('updated dotslash file'); + expect(signedsource.verifySignature(updatedContents)).toBe(true); + }); + + test('fails if upstream returns an incorrect asset', async () => { + const dotslashContents = `#!/usr/bin/env dotslash +// @${'generated SignedSource<<00000000000000000000000000000000>>'} +{ + "name": "test", + "platforms": { + "linux-x86_64": { + "providers": [ + {"url": "${serverUrl}"}, + ], + "size": 1, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "path": "bar" + } + } +} +`; + await fs.writeFile(`${tmpDir}/entry-point`, dotslashContents); + + await expect( + writeReleaseAssetUrlsToDotSlashFile({ + filename: `${tmpDir}/entry-point`, + releaseTag: 'v1001.0.0', + }), + ).rejects.toThrowErrorMatchingSnapshot(); + + expect(consoleLog.mock.calls).toMatchSnapshot('console.log calls'); + }); +}); diff --git a/scripts/releases/create-release-commit.js b/scripts/releases/create-release-commit.js index 50d2f8a8acd99d..09d4f7cc888dff 100644 --- a/scripts/releases/create-release-commit.js +++ b/scripts/releases/create-release-commit.js @@ -10,6 +10,9 @@ const {setVersion} = require('../releases/set-version'); const {getBranchName} = require('../releases/utils/scm-utils'); +const { + writeReleaseAssetUrlsToDotSlashFiles, +} = require('../releases/write-dotslash-release-asset-urls'); const {parseVersion} = require('./utils/version-utils'); const {execSync} = require('child_process'); const yargs = require('yargs'); @@ -49,6 +52,9 @@ async function main() { console.info('Setting version for monorepo packages and react-native'); await setVersion(version, false); // version, skip-react-native + console.info('Writing release asset URLs to DotSlash files'); + await writeReleaseAssetUrlsToDotSlashFiles(version); + if (dryRun) { console.info('Running in dry-run mode, skipping git commit'); console.info( diff --git a/scripts/releases/upload-release-assets-for-dotslash.js b/scripts/releases/upload-release-assets-for-dotslash.js new file mode 100644 index 00000000000000..2d3006f5f061ca --- /dev/null +++ b/scripts/releases/upload-release-assets-for-dotslash.js @@ -0,0 +1,395 @@ +/** + * 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; + +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 --release_id --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: + 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 */ { + 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 */ { + 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 */ { + 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, + 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 */ { + 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 */ { + 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(); +} diff --git a/scripts/releases/utils/__tests__/__snapshots__/dotslash-utils-test.js.snap b/scripts/releases/utils/__tests__/__snapshots__/dotslash-utils-test.js.snap new file mode 100644 index 00000000000000..d090e4b0b44794 --- /dev/null +++ b/scripts/releases/utils/__tests__/__snapshots__/dotslash-utils-test.js.snap @@ -0,0 +1,102 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`processDotSlashFileInPlace comments, multiple platforms, providers + replacement: contents after processing 1`] = ` +"#!/usr/bin/env dotslash +// Top-level comment +{ + \\"name\\": \\"test\\", + \\"platforms\\": { + // Comment on linux-x86_64 + \\"linux-x86_64\\": { + \\"size\\": 0, + \\"hash\\": \\"sha256\\", + \\"digest\\": \\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\\", + \\"providers\\": [ + { + \\"url\\": \\"https://example.com/replaced/test-linux-x86_64.tar.gz\\" + } + ], + \\"format\\": \\"tar.gz\\", + \\"path\\": \\"bar\\" + }, + // Comment on macos-aarch64 + \\"macos-aarch64\\": { + \\"size\\": 0, + \\"hash\\": \\"sha256\\", + \\"digest\\": \\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\\", + \\"providers\\": [ + { + \\"url\\": \\"https://primary.example.com/foo-mac.zip\\", + \\"weight\\": 3 + }, + { + \\"url\\": \\"https://mirror1.example.com/foo-mac.zip\\", + \\"weight\\": 1 + }, + { + \\"url\\": \\"https://example.com/added/test-macos-aarch64.zip\\" + } + ], + \\"format\\": \\"zip\\", + \\"path\\": \\"bar\\", + } + } +}" +`; + +exports[`processDotSlashFileInPlace comments, multiple platforms, providers + replacement: transformProviders calls 1`] = ` +Array [ + Array [ + Array [ + Object { + "url": "https://primary.example.com/foo-linux.tar.gz", + "weight": 3, + }, + Object { + "url": "https://mirror1.example.com/foo-linux.tar.gz", + "weight": 1, + }, + Object { + "url": "https://mirror2.example.com/foo-linux.tar.gz", + "weight": 1, + }, + Object { + "url": "https://mirror3.example.com/foo-linux.tar.gz", + "weight": 1, + }, + ], + "test-linux-x86_64.tar.gz", + Object { + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "hash": "sha256", + "size": 0, + }, + ], + Array [ + Array [ + Object { + "url": "https://primary.example.com/foo-mac.zip", + "weight": 3, + }, + Object { + "url": "https://mirror1.example.com/foo-mac.zip", + "weight": 1, + }, + ], + "test-macos-aarch64.zip", + Object { + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "hash": "sha256", + "size": 0, + }, + ], +] +`; + +exports[`validateDotSlashArtifactData blake3 failure on digest mismatch 1`] = `"blake3 mismatch: expected 2623f14eac39a9cc7b211cda9c52bcb9949ccd63aed4040a6a1a9f5f9b9431fa, got af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"`; + +exports[`validateDotSlashArtifactData blake3 failure on size mismatch 1`] = `"size mismatch: expected 1, got 0"`; + +exports[`validateDotSlashArtifactData sha256 failure on digest mismatch 1`] = `"sha256 mismatch: expected 558b2587b199594ac439b9464e14ea72429bf6998c4fbfa941c1cf89244c0b3e, got e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`; + +exports[`validateDotSlashArtifactData sha256 failure on size mismatch 1`] = `"size mismatch: expected 1, got 0"`; diff --git a/scripts/releases/utils/__tests__/curl-utils-test.js b/scripts/releases/utils/__tests__/curl-utils-test.js new file mode 100644 index 00000000000000..77592e53fdbe8b --- /dev/null +++ b/scripts/releases/utils/__tests__/curl-utils-test.js @@ -0,0 +1,60 @@ +/** + * 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 {getWithCurl} = require('../curl-utils'); +const http = require('http'); + +let server, serverUrl; + +beforeEach(async () => { + await new Promise((resolve, reject) => { + server = http.createServer((req, res) => { + if (req.url !== '/') { + res.writeHead(404); + res.end(); + return; + } + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.end('Hello World\n'); + }); + server.on('error', reject); + server.listen(0, 'localhost', () => { + const {port} = server.address(); + serverUrl = `http://localhost:${port}`; + resolve(); + }); + }); +}); + +afterEach(async () => { + await new Promise((resolve, reject) => { + server.close(err => { + if (err) { + reject(err); + } + resolve(); + }); + }); +}); + +describe('getWithCurl', () => { + test('success', async () => { + await expect(getWithCurl(serverUrl)).resolves.toEqual({ + data: Buffer.from('Hello World\n'), + contentType: 'text/plain', + }); + }); + + test('fails on 404', async () => { + await expect(getWithCurl(serverUrl + '/error')).rejects.toThrowError(); + }); +}); diff --git a/scripts/releases/utils/__tests__/dotslash-utils-test.js b/scripts/releases/utils/__tests__/dotslash-utils-test.js new file mode 100644 index 00000000000000..321ece59bc3791 --- /dev/null +++ b/scripts/releases/utils/__tests__/dotslash-utils-test.js @@ -0,0 +1,267 @@ +/** + * 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 { + dangerouslyResignGeneratedFile, + processDotSlashFileInPlace, + validateAndParseDotSlashFile, + validateDotSlashArtifactData, +} = require('../dotslash-utils'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +jest.useRealTimers(); + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dotslash-utils-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, {recursive: true}); +}); + +describe('validateAndParseDotSlashFile', () => { + test('succeeds on a minimal valid DotSlash file', async () => { + const contents = `#!/usr/bin/env dotslash +{ + "name": "test", + "platforms": {} +}`; + await fs.promises.writeFile(`${tmpDir}/entry-point`, contents); + await expect( + validateAndParseDotSlashFile(`${tmpDir}/entry-point`), + ).resolves.toEqual({ + name: 'test', + platforms: {}, + }); + }); +}); + +describe('processDotSlashFileInPlace', () => { + test('succeeds on a minimal valid DotSlash file', async () => { + const transformProviders = jest.fn(); + const contentsBefore = `#!/usr/bin/env dotslash +{ + "name": "test", + "platforms": {} +}`; + await fs.promises.writeFile(`${tmpDir}/entry-point`, contentsBefore); + await processDotSlashFileInPlace( + `${tmpDir}/entry-point`, + transformProviders, + ); + expect(transformProviders).not.toHaveBeenCalled(); + expect(fs.readFileSync(`${tmpDir}/entry-point`, 'utf8')).toBe( + contentsBefore, + ); + }); + + test('comments, multiple platforms, providers + replacement', async () => { + const transformProviders = jest.fn(); + const contentsBefore = `#!/usr/bin/env dotslash +// Top-level comment +{ + "name": "test", + "platforms": { + // Comment on linux-x86_64 + "linux-x86_64": { + "size": 0, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + {"url": "https://primary.example.com/foo-linux.tar.gz", "weight": 3}, + {"url": "https://mirror1.example.com/foo-linux.tar.gz", "weight": 1}, + {"url": "https://mirror2.example.com/foo-linux.tar.gz", "weight": 1}, + {"url": "https://mirror3.example.com/foo-linux.tar.gz", "weight": 1} + ], + "format": "tar.gz", + "path": "bar" + }, + // Comment on macos-aarch64 + "macos-aarch64": { + "size": 0, + "hash": "sha256", + "digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "providers": [ + {"url": "https://primary.example.com/foo-mac.zip", "weight": 3}, + {"url": "https://mirror1.example.com/foo-mac.zip", "weight": 1}, + ], + "format": "zip", + "path": "bar", + } + } +}`; + fs.writeFileSync(`${tmpDir}/entry-point`, contentsBefore); + transformProviders.mockImplementationOnce( + (providers, suggestedFilename) => { + return [ + { + url: + 'https://example.com/replaced/' + + encodeURIComponent(suggestedFilename), + }, + ]; + }, + ); + transformProviders.mockImplementationOnce( + (providers, suggestedFilename) => { + return [ + ...providers, + { + url: + 'https://example.com/added/' + + encodeURIComponent(suggestedFilename), + }, + ]; + }, + ); + await processDotSlashFileInPlace( + `${tmpDir}/entry-point`, + transformProviders, + ); + expect(transformProviders.mock.calls).toMatchSnapshot( + 'transformProviders calls', + ); + expect(fs.readFileSync(`${tmpDir}/entry-point`, 'utf8')).toMatchSnapshot( + 'contents after processing', + ); + }); + + test('fails on an invalid DotSlash file (no shebang line)', async () => { + const transformProviders = jest.fn(); + const contentsBefore = `{ + "name": "test", + "platforms": {} +}`; + fs.writeFileSync(`${tmpDir}/entry-point`, contentsBefore); + await expect( + processDotSlashFileInPlace(`${tmpDir}/entry-point`, transformProviders), + ).rejects.toThrow(); + expect(transformProviders).not.toHaveBeenCalled(); + expect(fs.readFileSync(`${tmpDir}/entry-point`, 'utf8')).toBe( + contentsBefore, + ); + }); + + test('fails on an invalid DotSlash file (no platforms)', async () => { + const transformProviders = jest.fn(); + const contentsBefore = `#!/usr/bin/env dotslash +{ + "name": "test" +}`; + fs.writeFileSync(`${tmpDir}/entry-point`, contentsBefore); + await expect( + processDotSlashFileInPlace(`${tmpDir}/entry-point`, transformProviders), + ).rejects.toThrow(); + expect(transformProviders).not.toHaveBeenCalled(); + expect(fs.readFileSync(`${tmpDir}/entry-point`, 'utf8')).toBe( + contentsBefore, + ); + }); +}); + +describe('dangerouslyResignGeneratedFile', () => { + test('successfully re-signs a file', async () => { + const contentsBefore = `#!/usr/bin/env dotslash +// @${'generated SignedSource<<00000000000000000000000000000000' + '>>'} +{ + "name": "test", + "platforms": {} +}`; + fs.writeFileSync(`${tmpDir}/entry-point`, contentsBefore); + await dangerouslyResignGeneratedFile(`${tmpDir}/entry-point`); + expect(fs.readFileSync(`${tmpDir}/entry-point`, 'utf8')) + .toBe(`#!/usr/bin/env dotslash +// @${'generated SignedSource<<5ccb2839bdbd070dffcda52c6aa922a3' + '>>'} +{ + "name": "test", + "platforms": {} +}`); + }); +}); + +describe('validateDotSlashArtifactData', () => { + test('blake3 success', async () => { + await expect( + validateDotSlashArtifactData(Buffer.from([]), { + hash: 'blake3', + digest: + 'af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262', + size: 0, + }), + ).resolves.toBeUndefined(); + }); + + test('blake3 failure on size mismatch', async () => { + await expect( + validateDotSlashArtifactData(Buffer.from([]), { + hash: 'blake3', + digest: + 'af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262', + size: 1, + }), + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('blake3 failure on digest mismatch', async () => { + await expect( + validateDotSlashArtifactData(Buffer.from([]), { + hash: 'blake3', + digest: + 'af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262' + .split('') + .reverse() + .join(''), + size: 0, + }), + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('sha256 success', async () => { + await expect( + validateDotSlashArtifactData(Buffer.from([]), { + hash: 'sha256', + digest: + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + size: 0, + }), + ).resolves.toBeUndefined(); + }); + + test('sha256 failure on size mismatch', async () => { + await expect( + validateDotSlashArtifactData(Buffer.from([]), { + hash: 'sha256', + digest: + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + size: 1, + }), + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('sha256 failure on digest mismatch', async () => { + await expect( + validateDotSlashArtifactData(Buffer.from([]), { + hash: 'sha256', + digest: + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + .split('') + .reverse() + .join(''), + size: 0, + }), + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/scripts/releases/utils/curl-utils.js b/scripts/releases/utils/curl-utils.js new file mode 100644 index 00000000000000..6b39eb79b36f58 --- /dev/null +++ b/scripts/releases/utils/curl-utils.js @@ -0,0 +1,73 @@ +/** + * 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 spawnAsync = require('@expo/spawn-async'); +const {promises: fs} = require('fs'); +const os = require('os'); +const path = require('path'); + +/*:: +type CurlResult = { + data: Buffer, + contentType?: string, +}; +*/ + +async function getWithCurl(url /*: string */) /*: Promise */ { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'get-with-curl-')); + const tempFile = path.join(tempDir, 'data'); + try { + const { + output: [curlStdout], + } = await spawnAsync( + 'curl', + [ + '--silent', + '--location', + '--output', + tempFile, + url, + '--write-out', + '%{content_type}', + '--fail', + ], + {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}, + ); + const data = await fs.readFile(tempFile); + const contentType = curlStdout.trim(); + if (contentType === '') { + return {data}; + } + return {data, contentType}; + } finally { + await fs.rm(tempDir, {recursive: true, force: true}); + } +} + +function getTempDirPatternForTests() /*: RegExp */ { + return new RegExp( + escapeRegex(path.join(os.tmpdir(), 'get-with-curl-')) + + '.[^\\s' + + escapeRegex(path.sep) + + ']+', + 'g', + ); +} + +function escapeRegex(str /*: string */) /*: string */ { + return str.replace(/[-[\]\\/{}()*+?.^$|]/g, '\\$&'); +} + +module.exports = { + getWithCurl, + getTempDirPatternForTests, +}; diff --git a/scripts/releases/utils/dotslash-utils.js b/scripts/releases/utils/dotslash-utils.js new file mode 100644 index 00000000000000..eb435fffd71fe1 --- /dev/null +++ b/scripts/releases/utils/dotslash-utils.js @@ -0,0 +1,214 @@ +/** + * 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 dotslash = require('fb-dotslash'); +const {promises: fs} = require('fs'); +const {applyEdits, modify, parse} = require('jsonc-parser'); +const os = require('os'); +const path = require('path'); +const signedsource = require('signedsource'); +const execFile = require('util').promisify(require('child_process').execFile); + +/*:: +export type DotSlashHttpProvider = { + type?: 'http', + url: string, +}; + +export type DotSlashProvider = DotSlashHttpProvider | { + type: 'github-release', + repo: string, + tag: string, + name: string, +}; + +type DotSlashPlatformSpec = { + providers: DotSlashProvider[], + hash: 'blake3' | 'sha256', + digest: string, + size: number, + format?: string, + ... +}; + +export type DotSlashArtifactInfo = $ReadOnly<{ + size: number, + hash: 'blake3' | 'sha256', + digest: string, + ... +}>; + +type JSONCFormattingOptions = { + tabSize?: number, + insertSpaces?: boolean, + eol?: string, +}; + +type DotSlashProvidersTransformFn = ( + providers: $ReadOnlyArray, + suggestedFilename: string, + artifactInfo: DotSlashArtifactInfo, +) => ?$ReadOnlyArray | Promise>; +*/ + +const DEFAULT_FORMATTING_OPTIONS /*: $ReadOnly */ = { + tabSize: 4, + insertSpaces: true, + eol: '\n', +}; + +/** + * Process a DotSlash file and call a callback with the providers for each platform. + * The callback can return a new providers array to update the file. + * The function will preserve formatting and comments in the file (except any comments + * that are within the providers array). + */ +async function processDotSlashFileInPlace( + filename /*: string */, + transformProviders /*: DotSlashProvidersTransformFn */, + formattingOptions /*: $ReadOnly */ = DEFAULT_FORMATTING_OPTIONS, +) /*: Promise */ { + // Validate the file using `dotslash` itself so we can be reasonably sure that it conforms + // to the expected format. + await validateAndParseDotSlashFile(filename); + + const originalContents = await fs.readFile(filename, 'utf-8'); + const [shebang, originalContentsJson] = + splitShebangFromContents(originalContents); + const json = parse(originalContentsJson); + let intermediateContentsJson = originalContentsJson; + for (const [platform, platformSpec] of Object.entries(json.platforms) /*:: + as $ReadOnlyArray<[string, DotSlashPlatformSpec]> + */) { + const providers = platformSpec.providers; + const suggestedFilename = + `${sanitizeFileNameComponent(json.name)}-${platform}` + + (platformSpec.format != null ? `.${platformSpec.format}` : ''); + const {hash, digest, size} = platformSpec; + const newProviders = + (await transformProviders(providers, suggestedFilename, { + hash, + digest, + size, + })) ?? providers; + if (newProviders !== providers) { + const edits = modify( + intermediateContentsJson, + ['platforms', platform, 'providers'], + newProviders, + { + formattingOptions, + }, + ); + intermediateContentsJson = applyEdits(intermediateContentsJson, edits); + } + } + if (originalContentsJson !== intermediateContentsJson) { + await fs.writeFile(filename, shebang + intermediateContentsJson); + // Validate the modified file to make sure we haven't broken it. + await validateAndParseDotSlashFile(filename); + } +} + +function sanitizeFileNameComponent( + fileNameComponent /*: string */, +) /*: string */ { + return fileNameComponent.replace(/[^a-zA-Z0-9.]/g, '.'); +} + +function splitShebangFromContents( + contents /*: string */, +) /*: [string, string] */ { + const shebangMatch = contents.match(/^#!.*\n/); + const shebang = shebangMatch ? shebangMatch[0] : ''; + const contentsWithoutShebang = shebang + ? contents.substring(shebang.length) + : contents; + return [shebang, contentsWithoutShebang]; +} + +/** + * Validate a DotSlash file and return its parsed contents. + * Throws an error if the file is not valid. + * + * See https://dotslash-cli.com/docs/dotslash-file/ + */ +async function validateAndParseDotSlashFile( + filename /*: string */, +) /*: mixed */ { + const {stdout} = await execFile(dotslash, ['--', 'parse', filename]); + return JSON.parse(stdout); +} + +/** + * Re-sign a file previously signed with `signedsource`. Use with caution. + */ +async function dangerouslyResignGeneratedFile( + filename /*: string */, +) /*: Promise */ { + const GENERATED = '@' + 'generated'; + const PATTERN = new RegExp(`${GENERATED} (?:SignedSource<<([a-f0-9]{32})>>)`); + const originalContents = await fs.readFile(filename, 'utf-8'); + + const newContents = signedsource.signFile( + originalContents.replace(PATTERN, signedsource.getSigningToken()), + ); + await fs.writeFile(filename, newContents); +} + +/** + * Checks that the given buffer matches the given hash and size. This is + * equivalent to the validation that DotSlash performs after fetching a blob + * and before extracting/executing it. + */ +async function validateDotSlashArtifactData( + data /*: Buffer */, + artifactInfo /*: DotSlashArtifactInfo */, +) /*: Promise */ { + const {digest: expectedDigest, hash, size} = artifactInfo; + if (data.length !== size) { + throw new Error(`size mismatch: expected ${size}, got ${data.length}`); + } + const hashFunction = hash === 'blake3' ? 'b3sum' : 'sha256'; + + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'validate-artifact-hash-'), + ); + try { + const tempFile = path.join(tempDir, 'data'); + await fs.writeFile(tempFile, data); + const {stdout} = await execFile(dotslash, ['--', hashFunction, tempFile]); + const actualDigest = stdout.trim(); + if (actualDigest !== expectedDigest) { + throw new Error( + `${hash} mismatch: expected ${expectedDigest}, got ${actualDigest}`, + ); + } + } finally { + await fs.rm(tempDir, {recursive: true, force: true}); + } +} + +function isHttpProvider( + provider /*: DotSlashProvider */, +) /*: implies provider is DotSlashHttpProvider */ { + return provider.type === 'http' || provider.type == null; +} + +module.exports = { + DEFAULT_FORMATTING_OPTIONS, + dangerouslyResignGeneratedFile, + isHttpProvider, + processDotSlashFileInPlace, + validateAndParseDotSlashFile, + validateDotSlashArtifactData, +}; diff --git a/scripts/releases/utils/octokit-utils.js b/scripts/releases/utils/octokit-utils.js new file mode 100644 index 00000000000000..ce5f7e07be4e6d --- /dev/null +++ b/scripts/releases/utils/octokit-utils.js @@ -0,0 +1,58 @@ +/** + * 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'; + +// An interface shaped like a subset of the Octokit class from `@octokit/rest`. +// Used to allow mocking in tests. +export interface IOctokit { + +repos: $ReadOnly<{ + listReleaseAssets: ( + params: $ReadOnly<{ + owner: string, + repo: string, + release_id: string, + }>, + ) => Promise<{ + data: Array<{ + id: string, + name: string, + ... + }>, + ... + }>, + uploadReleaseAsset: ( + params: $ReadOnly<{ + owner: string, + repo: string, + release_id: string, + name: string, + data: Buffer, + headers: $ReadOnly<{ + 'content-type': string, + ... + }>, + ... + }>, + ) => Promise<{ + data: { + browser_download_url: string, + ... + }, + ... + }>, + deleteReleaseAsset: (params: { + owner: string, + repo: string, + asset_id: string, + ... + }) => Promise, + }>; +} diff --git a/scripts/releases/validate-dotslash-artifacts.js b/scripts/releases/validate-dotslash-artifacts.js new file mode 100644 index 00000000000000..7c4841341b6357 --- /dev/null +++ b/scripts/releases/validate-dotslash-artifacts.js @@ -0,0 +1,99 @@ +/** + * 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 path = require('path'); +const {parseArgs, styleText} = require('util'); + +async function main() { + const { + positionals: [], + values: {help}, + } = parseArgs({ + allowPositionals: true, + options: { + help: {type: 'boolean'}, + }, + }); + + if (help) { + console.log(` + Usage: node ./scripts/releases/validate-dotslash-artifacts.js + + Ensures that the first-party DotSlash files in the current commit all point to + valid URLs that return the described artifacts. This script is intended to run + in two key scenarios: + + 1. Continuously on main - this verifies the output of the Meta-internal CI pipeline + that publishes DotSlash files to the repo. + 2. After a release is published - this verifies the behavior of the + write-dotslash-release-asset-urls.js and upload-release-assets-for-dotslash.js + scripts, as well as any commits (e.g. merges, picks) that touched the DotSlash + files in the release branch since the branch was cut. + Release asset URLs are only valid once the release is published, so we can't + run this continuously on commits in the release branch (specifically, it would + fail on the release commit itself). +`); + return; + } + + await validateDotSlashArtifacts(); +} + +async function validateDotSlashArtifacts() /*: Promise */ { + for (const filename of FIRST_PARTY_DOTSLASH_FILES) { + const fullPath = path.join(REPO_ROOT, filename); + console.log(`Validating all HTTP providers for ${filename}...`); + await processDotSlashFileInPlace( + fullPath, + async (providers, suggestedFilename, artifactInfo) => { + for (const provider of providers) { + if (!isHttpProvider(provider)) { + console.log( + styleText( + 'dim', + ` `, + ), + ); + continue; + } + console.log( + styleText( + 'dim', + ` ${provider.url} (expected ${artifactInfo.size} bytes, ${artifactInfo.hash} ${artifactInfo.digest})`, + ), + ); + const {data} = await getWithCurl(provider.url); + await validateDotSlashArtifactData(data, artifactInfo); + } + return providers; + }, + ); + } +} + +module.exports = { + validateDotSlashArtifacts, +}; + +if (require.main === module) { + void main(); +} diff --git a/scripts/releases/write-dotslash-release-asset-urls.js b/scripts/releases/write-dotslash-release-asset-urls.js new file mode 100644 index 00000000000000..8d09f26e85a9d4 --- /dev/null +++ b/scripts/releases/write-dotslash-release-asset-urls.js @@ -0,0 +1,171 @@ +/** + * 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'; + +/*:: +import type {DotSlashHttpProvider, DotSlashProvider, DotSlashArtifactInfo} from './utils/dotslash-utils'; +*/ + +const {REPO_ROOT} = require('../shared/consts'); +const {getWithCurl} = require('./utils/curl-utils'); +const { + dangerouslyResignGeneratedFile, + isHttpProvider, + processDotSlashFileInPlace, + validateAndParseDotSlashFile, + validateDotSlashArtifactData, +} = require('./utils/dotslash-utils'); +const {diff: jestDiff} = require('jest-diff'); +const path = require('path'); +const {parseArgs} = require('util'); + +const FIRST_PARTY_DOTSLASH_FILES = [ + 'packages/debugger-shell/bin/react-native-devtools', +]; + +async function main() { + const { + positionals: [version], + values: {help}, + } = parseArgs({ + allowPositionals: true, + options: { + help: {type: 'boolean'}, + }, + }); + + if (help) { + console.log(` + Usage: node ./scripts/releases/write-dotslash-release-asset-urls.js + + Inserts references to release assets URLs into first-party DotSlash files in + the repo, in preparation for publishing a new release and uploading the + assets (which happens in a separate step). +`); + return; + } + + if (version == null) { + throw new Error('Missing version argument'); + } + + await writeReleaseAssetUrlsToDotSlashFiles(version); +} + +async function writeReleaseAssetUrlsToDotSlashFiles( + version /*: string */, +) /*: Promise */ { + const releaseTag = version.startsWith('v') ? version : `v${version}`; + for (const filename of FIRST_PARTY_DOTSLASH_FILES) { + await writeReleaseAssetUrlsToDotSlashFile({ + filename, + releaseTag, + }); + } +} + +async function writeReleaseAssetUrlsToDotSlashFile( + {filename, releaseTag} /*: {filename: string, releaseTag: string} */, +) /*: Promise */ { + const fullPath = path.resolve(REPO_ROOT, filename); + console.log(`Updating ${filename}...`); + await processDotSlashFileInPlace( + fullPath, + async (originalProviders, suggestedFilename, artifactInfo) => { + const updatedProviders = await updateAndVerifyProviders({ + providers: originalProviders, + suggestedFilename, + artifactInfo, + releaseTag, + }); + console.log( + 'Providers:\n', + diffProviderArrays(originalProviders, updatedProviders), + ); + return updatedProviders; + }, + ); + await dangerouslyResignGeneratedFile(fullPath); + await validateAndParseDotSlashFile(fullPath); +} + +async function updateAndVerifyProviders( + {providers: providersArg, suggestedFilename, artifactInfo, releaseTag} /*: + {providers: $ReadOnlyArray, + suggestedFilename: string, + artifactInfo: DotSlashArtifactInfo, + releaseTag: string,} +*/, +) { + const providers = providersArg.filter( + provider => !isPreviousReleaseAssetProvider(provider), + ); + const upstreamHttpProviders = providers.filter(isHttpProvider); + if (upstreamHttpProviders.length === 0) { + throw new Error( + 'No upstream HTTP providers found for asset: ' + suggestedFilename, + ); + } + for (const provider of upstreamHttpProviders) { + console.log(`Downloading from ${provider.url} for integrity validation...`); + const {data} = await getWithCurl(provider.url); + await validateDotSlashArtifactData(data, artifactInfo); + } + providers.unshift( + createReleaseAssetProvider({ + releaseTag, + suggestedFilename, + }), + ); + return providers; +} + +function isPreviousReleaseAssetProvider( + provider /*: DotSlashProvider */, +) /*: boolean */ { + return ( + isHttpProvider(provider) && + provider.url.startsWith( + 'https://github.com/facebook/react-native/releases/download/', + ) + ); +} + +function createReleaseAssetProvider( + { + releaseTag, + suggestedFilename, + } /*: {releaseTag: string, suggestedFilename: string} */, +) /*: DotSlashProvider */ { + return { + url: `https://github.com/facebook/react-native/releases/download/${encodeURIComponent(releaseTag)}/${encodeURIComponent(suggestedFilename)}`, + }; +} + +function diffProviderArrays( + original /*: $ReadOnlyArray */, + updated /*: $ReadOnlyArray */, +) { + return jestDiff(original, updated, { + aAnnotation: 'Original', + bAnnotation: 'Updated', + }); +} + +module.exports = { + FIRST_PARTY_DOTSLASH_FILES, + writeReleaseAssetUrlsToDotSlashFiles, + writeReleaseAssetUrlsToDotSlashFile, +}; + +if (require.main === module) { + void main(); +} diff --git a/yarn.lock b/yarn.lock index b618a2c8bc4ab6..033115077295f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10,7 +10,7 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.27.1": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== @@ -65,6 +65,17 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" +"@babel/generator@^7.26.9": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e" + integrity sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw== + dependencies: + "@babel/parser" "^7.28.3" + "@babel/types" "^7.28.2" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.25.9", "@babel/helper-annotate-as-pure@^7.27.1": version "7.27.3" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" @@ -223,6 +234,13 @@ dependencies: "@babel/types" "^7.28.0" +"@babel/parser@^7.26.9", "@babel/parser@^7.28.3": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.3.tgz#d2d25b814621bca5fe9d172bc93792547e7a2a71" + integrity sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA== + dependencies: + "@babel/types" "^7.28.2" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz#cc2e53ebf0a0340777fff5ed521943e253b4d8fe" @@ -1016,7 +1034,7 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/template@^7.25.0", "@babel/template@^7.25.9", "@babel/template@^7.27.2", "@babel/template@^7.3.3": +"@babel/template@^7.25.0", "@babel/template@^7.25.9", "@babel/template@^7.26.9", "@babel/template@^7.27.2", "@babel/template@^7.3.3": version "7.27.2" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== @@ -1025,7 +1043,20 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9", "@babel/traverse@^7.26.8", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0": +"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9", "@babel/traverse@^7.26.8": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.9.tgz#4398f2394ba66d05d988b2ad13c219a2c857461a" + integrity sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/generator" "^7.26.9" + "@babel/parser" "^7.26.9" + "@babel/template" "^7.26.9" + "@babel/types" "^7.26.9" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.28.0": version "7.28.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.0.tgz#518aa113359b062042379e333db18380b537e34b" integrity sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg== @@ -1046,6 +1077,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" +"@babel/types@^7.26.9", "@babel/types@^7.28.2": + version "7.28.2" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.2.tgz#da9db0856a9a88e0a13b019881d7513588cf712b" + integrity sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -1192,6 +1231,13 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== +"@expo/spawn-async@^1.7.2": + version "1.7.2" + resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58" + integrity sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew== + dependencies: + cross-spawn "^7.0.3" + "@fastify/busboy@^2.0.0": version "2.1.1" resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d" @@ -1595,6 +1641,11 @@ resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-5.1.2.tgz#68a486714d7a7fd1df56cb9bc89a860a0de866de" integrity sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw== +"@octokit/auth-token@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-6.0.0.tgz#b02e9c08a2d8937df09a2a981f226ad219174c53" + integrity sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w== + "@octokit/core@^5.0.2": version "5.2.1" resolved "https://registry.yarnpkg.com/@octokit/core/-/core-5.2.1.tgz#58c21a5f689ee81e0b883b5aa77573a7ff1b4ea1" @@ -1621,6 +1672,19 @@ before-after-hook "^3.0.2" universal-user-agent "^7.0.0" +"@octokit/core@^7.0.2": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-7.0.3.tgz#0b5288995fed66920128d41cfeea34979d48a360" + integrity sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ== + dependencies: + "@octokit/auth-token" "^6.0.0" + "@octokit/graphql" "^9.0.1" + "@octokit/request" "^10.0.2" + "@octokit/request-error" "^7.0.0" + "@octokit/types" "^14.0.0" + before-after-hook "^4.0.0" + universal-user-agent "^7.0.0" + "@octokit/endpoint@^10.1.4": version "10.1.4" resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-10.1.4.tgz#8783be38a32b95af8bcb6523af20ab4eed7a2adb" @@ -1629,6 +1693,14 @@ "@octokit/types" "^14.0.0" universal-user-agent "^7.0.2" +"@octokit/endpoint@^11.0.0": + version "11.0.0" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-11.0.0.tgz#189fcc022721b4c49d0307eea6be3de1cfb53026" + integrity sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ== + dependencies: + "@octokit/types" "^14.0.0" + universal-user-agent "^7.0.2" + "@octokit/endpoint@^9.0.6": version "9.0.6" resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-9.0.6.tgz#114d912108fe692d8b139cfe7fc0846dfd11b6c0" @@ -1655,6 +1727,15 @@ "@octokit/types" "^14.0.0" universal-user-agent "^7.0.0" +"@octokit/graphql@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-9.0.1.tgz#eb258fc9981403d2d751720832652c385b6c1613" + integrity sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg== + dependencies: + "@octokit/request" "^10.0.2" + "@octokit/types" "^14.0.0" + universal-user-agent "^7.0.0" + "@octokit/openapi-types@^24.2.0": version "24.2.0" resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-24.2.0.tgz#3d55c32eac0d38da1a7083a9c3b0cca77924f7d3" @@ -1665,6 +1746,11 @@ resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-25.0.0.tgz#adeead36992abf966e89dcd53518d8b0dc910e0d" integrity sha512-FZvktFu7HfOIJf2BScLKIEYjDsw6RKc7rBJCdvCTfKsVnx2GEB/Nbzjr29DUdb7vQhlzS/j8qDzdditP0OC6aw== +"@octokit/openapi-types@^25.1.0": + version "25.1.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-25.1.0.tgz#5a72a9dfaaba72b5b7db375fd05e90ca90dc9682" + integrity sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA== + "@octokit/plugin-paginate-rest@11.4.4-cjs.2": version "11.4.4-cjs.2" resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz#979a10d577bce7a393e8e65953887e42b0a05000" @@ -1679,6 +1765,13 @@ dependencies: "@octokit/types" "^13.10.0" +"@octokit/plugin-paginate-rest@^13.0.1": + version "13.1.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.1.1.tgz#ca5bb1c7b85a583691263c1f788f607e9bcb74b3" + integrity sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw== + dependencies: + "@octokit/types" "^14.1.0" + "@octokit/plugin-request-log@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-4.0.1.tgz#98a3ca96e0b107380664708111864cb96551f958" @@ -1689,6 +1782,11 @@ resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz#ccb75d9705de769b2aa82bcd105cc96eb0c00f69" integrity sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw== +"@octokit/plugin-request-log@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz#de1c1e557df6c08adb631bf78264fa741e01b317" + integrity sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q== + "@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1": version "13.3.2-cjs.1" resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz#d0a142ff41d8f7892b6ccef45979049f51ecaa8d" @@ -1703,6 +1801,13 @@ dependencies: "@octokit/types" "^13.10.0" +"@octokit/plugin-rest-endpoint-methods@^16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.0.0.tgz#ba30ca387fc2ac8bd93cf9f951174736babebd97" + integrity sha512-kJVUQk6/dx/gRNLWUnAWKFs1kVPn5O5CYZyssyEoNYaFedqZxsfYs7DwI3d67hGz4qOwaJ1dpm07hOAD1BXx6g== + dependencies: + "@octokit/types" "^14.1.0" + "@octokit/request-error@^5.1.1": version "5.1.1" resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-5.1.1.tgz#b9218f9c1166e68bb4d0c89b638edc62c9334805" @@ -1719,6 +1824,24 @@ dependencies: "@octokit/types" "^14.0.0" +"@octokit/request-error@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-7.0.0.tgz#48ae2cd79008315605d00e83664891a10a5ddb97" + integrity sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg== + dependencies: + "@octokit/types" "^14.0.0" + +"@octokit/request@^10.0.2": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-10.0.3.tgz#2ffdb88105ce20d25dcab8a592a7040ea48306c7" + integrity sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA== + dependencies: + "@octokit/endpoint" "^11.0.0" + "@octokit/request-error" "^7.0.0" + "@octokit/types" "^14.0.0" + fast-content-type-parse "^3.0.0" + universal-user-agent "^7.0.2" + "@octokit/request@^8.4.1": version "8.4.1" resolved "https://registry.yarnpkg.com/@octokit/request/-/request-8.4.1.tgz#715a015ccf993087977ea4365c44791fc4572486" @@ -1760,6 +1883,16 @@ "@octokit/plugin-request-log" "^4.0.0" "@octokit/plugin-rest-endpoint-methods" "13.3.2-cjs.1" +"@octokit/rest@^22.0.0": + version "22.0.0" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-22.0.0.tgz#9026f47dacba9c605da3d43cce9432c4c532dc5a" + integrity sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA== + dependencies: + "@octokit/core" "^7.0.2" + "@octokit/plugin-paginate-rest" "^13.0.1" + "@octokit/plugin-request-log" "^6.0.0" + "@octokit/plugin-rest-endpoint-methods" "^16.0.0" + "@octokit/types@^13.0.0", "@octokit/types@^13.1.0", "@octokit/types@^13.10.0", "@octokit/types@^13.7.0", "@octokit/types@^13.8.0": version "13.10.0" resolved "https://registry.yarnpkg.com/@octokit/types/-/types-13.10.0.tgz#3e7c6b19c0236c270656e4ea666148c2b51fd1a3" @@ -1774,6 +1907,13 @@ dependencies: "@octokit/openapi-types" "^25.0.0" +"@octokit/types@^14.1.0": + version "14.1.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-14.1.0.tgz#3bf9b3a3e3b5270964a57cc9d98592ed44f840f2" + integrity sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g== + dependencies: + "@octokit/openapi-types" "^25.1.0" + "@react-native-community/cli-clean@20.0.0": version "20.0.0" resolved "https://registry.yarnpkg.com/@react-native-community/cli-clean/-/cli-clean-20.0.0.tgz#e685f5404195ded69c81d1394e8c5eb332b780bc" @@ -3007,6 +3147,11 @@ before-after-hook@^3.0.2: resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-3.0.2.tgz#d5665a5fa8b62294a5aa0a499f933f4a1016195d" integrity sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A== +before-after-hook@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-4.0.0.tgz#cf1447ab9160df6a40f3621da64d6ffc36050cb9" + integrity sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ== + bl@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" @@ -4433,6 +4578,11 @@ fast-content-type-parse@^2.0.0: resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz#c236124534ee2cb427c8d8e5ba35a4856947847b" integrity sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q== +fast-content-type-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" + integrity sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -6061,6 +6211,11 @@ jsonc-eslint-parser@^2.3.0: espree "^9.0.0" semver "^7.3.5" +jsonc-parser@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-2.2.1.tgz#db73cd59d78cce28723199466b2a03d1be1df2bc" + integrity sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w== + jsonc-parser@3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4"