From b5cb4758ec3ae9cb2a5c68b02c551feb0c8410d7 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 01/12] DotSlash utils Summary: Adds utilities for working with DotSlash files in React Native's release scripts. The main ones are: * `processDotSlashFileInPlace` for reading/manipulating the providers (source URLs) and artifact descriptions, while preserving formatting and comments. * `validateAndParseDotSlashFile` for reading the contents of a DotSlash in a reasonably type-safe manner (by spawning `dotslash` itself). * `validateDotSlashArtifactData` for verifying the actual binary data described by a DotSlash file. * `dangerouslyResignGeneratedFile` for fixing up the generated file signature on a DotSlash file after manipulating it. Includes unit tests. --- package.json | 2 + .../__snapshots__/dotslash-utils-test.js.snap | 102 +++++++ .../utils/__tests__/dotslash-utils-test.js | 267 ++++++++++++++++++ scripts/releases/utils/dotslash-utils.js | 215 ++++++++++++++ yarn.lock | 62 +++- 5 files changed, 643 insertions(+), 5 deletions(-) create mode 100644 scripts/releases/utils/__tests__/__snapshots__/dotslash-utils-test.js.snap create mode 100644 scripts/releases/utils/__tests__/dotslash-utils-test.js create mode 100644 scripts/releases/utils/dotslash-utils.js diff --git a/package.json b/package.json index ecaaf19ed11d57..4cd060a7360c88 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,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 +94,7 @@ "jest-diff": "^29.7.0", "jest-junit": "^16.0.0", "jest-snapshot": "^29.7.0", + "jsonc-parser": "^3.3.1", "markdownlint-cli2": "^0.17.2", "markdownlint-rule-relative-links": "^3.0.0", "memfs": "^4.7.7", 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__/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/dotslash-utils.js b/scripts/releases/utils/dotslash-utils.js new file mode 100644 index 00000000000000..f74a73a5e9bf04 --- /dev/null +++ b/scripts/releases/utils/dotslash-utils.js @@ -0,0 +1,215 @@ +/** + * 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'); +// $FlowFixMe[untyped-import] TODO: add types for jsonc-parser +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/yarn.lock b/yarn.lock index b618a2c8bc4ab6..5677596ce7ee76 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,33 @@ "@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": + 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.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 +1090,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" @@ -4483,7 +4535,7 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" -fb-dotslash@0.5.8: +fb-dotslash@0.5.8, fb-dotslash@^0.5.8: version "0.5.8" resolved "https://registry.yarnpkg.com/fb-dotslash/-/fb-dotslash-0.5.8.tgz#c5ef3dacd75e1ddb2197c367052464ddde0115f5" integrity sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA== @@ -6061,7 +6113,7 @@ jsonc-eslint-parser@^2.3.0: espree "^9.0.0" semver "^7.3.5" -jsonc-parser@3.3.1: +jsonc-parser@3.3.1, jsonc-parser@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== From 72ef1f0190c93f5e707a4b9f83143fa54b5c9bc3 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 02/12] curl utils Summary: Adds the `getWithCurl` utility which uses `curl` to fetch some resource based on its URL. This is useful in cases where the built-in `fetch()` isn't working properly, such as when fetching assets from the Meta CDN in a GitHub Actions worker. Comes with unit tests. --- package.json | 1 + .../utils/__tests__/curl-utils-test.js | 62 +++++++++++++++++++ scripts/releases/utils/curl-utils.js | 56 +++++++++++++++++ yarn.lock | 7 +++ 4 files changed, 126 insertions(+) create mode 100644 scripts/releases/utils/__tests__/curl-utils-test.js create mode 100644 scripts/releases/utils/curl-utils.js diff --git a/package.json b/package.json index 4cd060a7360c88..ecac9c8e10d672 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@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", "@react-native/metro-babel-transformer": "0.82.0-main", 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..13dbdb248efef0 --- /dev/null +++ b/scripts/releases/utils/__tests__/curl-utils-test.js @@ -0,0 +1,62 @@ +/** + * 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'), + headers: expect.objectContaining({ + 'content-type': ['text/plain'], + }), + }); + }); + + test('fails on 404', async () => { + await expect(getWithCurl(serverUrl + '/error')).rejects.toThrowError(); + }); +}); diff --git a/scripts/releases/utils/curl-utils.js b/scripts/releases/utils/curl-utils.js new file mode 100644 index 00000000000000..bbe770855e1c5e --- /dev/null +++ b/scripts/releases/utils/curl-utils.js @@ -0,0 +1,56 @@ +/** + * 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'; + +// $FlowFixMe[untyped-import] TODO: add types for @expo/spawn-async +const spawnAsync = require('@expo/spawn-async'); +const {promises: fs} = require('fs'); +const os = require('os'); +const path = require('path'); + +/*:: +type CurlResult = { + data: Buffer, + headers: {[string]: Array}, +}; +*/ + +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', + '%{header_json}', + '--fail', + ], + {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}, + ); + const data = await fs.readFile(tempFile); + const headers = JSON.parse(curlStdout); + return {data, headers}; + } finally { + await fs.rm(tempDir, {recursive: true, force: true}); + } +} + +module.exports = { + getWithCurl, +}; diff --git a/yarn.lock b/yarn.lock index 5677596ce7ee76..1adc080e5ff18d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1244,6 +1244,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" From e090b2ab1b5fd4db1f3b240a1a519c749b1d9dc5 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 03/12] Jest snapshot utils Summary: Adds some reusable Jest snapshot serializers used later in this PR. --- package.json | 1 + scripts/releases/__tests__/snapshot-utils.js | 92 ++++++++++++++++++++ scripts/releases/utils/curl-utils.js | 15 ++++ 3 files changed, 108 insertions(+) create mode 100644 scripts/releases/__tests__/snapshot-utils.js diff --git a/package.json b/package.json index ecac9c8e10d672..e5f7d3571b0128 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@tsconfig/node22": "22.0.2", "@types/react": "^19.1.0", "@typescript-eslint/parser": "^8.36.0", + "ansi-regex": "^5.0.1", "ansi-styles": "^4.2.1", "babel-plugin-minify-dead-code-elimination": "^0.5.2", "babel-plugin-syntax-hermes-parser": "0.32.0", 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/utils/curl-utils.js b/scripts/releases/utils/curl-utils.js index bbe770855e1c5e..b8ee609613757d 100644 --- a/scripts/releases/utils/curl-utils.js +++ b/scripts/releases/utils/curl-utils.js @@ -51,6 +51,21 @@ async function getWithCurl(url /*: string */) /*: Promise */ { } } +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, }; From afe025d6bc8e5e744f7f58590556cc511def72df Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 04/12] Inject GitHub release asset URLs into first-party DotSlash files Summary: Adds the `write-dotslash-release-asset-urls` script and hooks it up to the `create-release-commit` script. After this script runs, the branch will reference GitHub release assets that haven't been uploaded yet - this is necessary because we publish the branch to npm before we ever create a draft GitHub release we can upload things to. In a separate commit in this PR, we will augment the `create-draft-release` workflow to actually upload the assets, based on the contents of the release commit. Comes with unit tests. --- ...e-dotslash-release-asset-urls-test.js.snap | 128 +++++++++++ .../write-dotslash-release-asset-urls-test.js | 203 ++++++++++++++++++ scripts/releases/create-release-commit.js | 6 + .../write-dotslash-release-asset-urls.js | 171 +++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 scripts/releases/__tests__/__snapshots__/write-dotslash-release-asset-urls-test.js.snap create mode 100644 scripts/releases/__tests__/write-dotslash-release-asset-urls-test.js create mode 100644 scripts/releases/write-dotslash-release-asset-urls.js 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__/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/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(); +} From 3d131339e99bd8916c97c2a8f715234e51c8a6b5 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 05/12] Upload first-party DotSlash binary assets to draft releases Summary: Augments the `create-draft-release` workflow to upload GitHub release assets referenced by the first-party DotSlash files in the repo, based on the output of `create-release-commit`. Comes with unit tests. --- .../__tests__/createDraftRelease-test.js | 18 +- .../workflow-scripts/createDraftRelease.js | 9 +- .github/workflows/create-draft-release.yml | 17 +- package.json | 1 + ...d-release-assets-for-dotslash-test.js.snap | 203 +++++++++ ...upload-release-assets-for-dotslash-test.js | 346 +++++++++++++++ .../upload-release-assets-for-dotslash.js | 398 ++++++++++++++++++ yarn.lock | 104 +++++ 8 files changed, 1087 insertions(+), 9 deletions(-) create mode 100644 scripts/releases/__tests__/__snapshots__/upload-release-assets-for-dotslash-test.js.snap create mode 100644 scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js create mode 100644 scripts/releases/upload-release-assets-for-dotslash.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/package.json b/package.json index e5f7d3571b0128..ca15f887d2b831 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@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-main", "@react-native/metro-config": "0.82.0-main", "@tsconfig/node22": "22.0.2", 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..0a6de7623ec1a9 --- /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 %{header_json} --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__/upload-release-assets-for-dotslash-test.js b/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js new file mode 100644 index 00000000000000..4590d83dc2acf8 --- /dev/null +++ b/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js @@ -0,0 +1,346 @@ +/** + * 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: number, + ... +}> = []; + +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(() => { + const assetId = Math.max(...mockAssets.map(asset => asset.id)) + 1; + 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/upload-release-assets-for-dotslash.js b/scripts/releases/upload-release-assets-for-dotslash.js new file mode 100644 index 00000000000000..401b325f8ee301 --- /dev/null +++ b/scripts/releases/upload-release-assets-for-dotslash.js @@ -0,0 +1,398 @@ +/** + * 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'); +// $FlowFixMe[untyped-import] TODO: add types for @octokit/rest +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'; + +// $FlowFixMe[unclear-type] TODO: add types for @octokit/rest +type OctokitInstance = any; + +type GitHubReleaseAsset = {id: number, ...}; +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 /*: OctokitInstance */, +) /*: 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 /*: OctokitInstance */, +) /*: 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 /*: OctokitInstance */, +) { + 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 /*: OctokitInstance */, +) /*: 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, headers} = await getWithCurl(url); + console.log(`[${name}] Validating download...`); + await validateDotSlashArtifactData(data, artifactInfo); + return { + data, + contentType: headers['content-type']?.[0] ?? '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 /*: OctokitInstance */, +) /*: 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/yarn.lock b/yarn.lock index 1adc080e5ff18d..86f7cce00eafc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1654,6 +1654,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" @@ -1680,6 +1685,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" @@ -1688,6 +1706,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" @@ -1714,6 +1740,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" @@ -1724,6 +1759,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" @@ -1738,6 +1778,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" @@ -1748,6 +1795,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" @@ -1762,6 +1814,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" @@ -1778,6 +1837,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" @@ -1819,6 +1896,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" @@ -1833,6 +1920,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" @@ -3066,6 +3160,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" @@ -4492,6 +4591,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" From dc051712e3f87f114fcd2578d9f7c13c30e30e4e Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 06/12] Validate first-party DotSlash artifacts continuously and after release Summary: Adds a workflow to ensure the following: * Continuously: `main` contains valid DotSlash files that reference valid artifacts. * When a release is published: the release contains valid DotSlash files that reference valid artifacts. This gives us confidence that branch cuts start in a valid state and that releases go out in a valid state. This is particularly important since there is a span of time, between the release commit and the publishing of the GH release, when the job would fail (as the release assets are not hosted at their final URLs yet). This is a fairly trivial script built on top of unit-tested utilities: `processDotSlashFileInPlace`, `getWithCurl`, `validateDotSlashArtifactData`. --- .../workflows/validate-dotslash-artifacts.yml | 32 ++++++ .../releases/validate-dotslash-artifacts.js | 99 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 .github/workflows/validate-dotslash-artifacts.yml create mode 100644 scripts/releases/validate-dotslash-artifacts.js diff --git a/.github/workflows/validate-dotslash-artifacts.yml b/.github/workflows/validate-dotslash-artifacts.yml new file mode 100644 index 00000000000000..02a3172166af8f --- /dev/null +++ b/.github/workflows/validate-dotslash-artifacts.yml @@ -0,0 +1,32 @@ +name: Validate DotSlash Artifacts + +on: + workflow_dispatch: + release: + types: [published] + push: + branches: + - main + +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/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(); +} From 359411e3111e47e83177acefa63227a4bd9e40c8 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 07/12] Improve typing of Octokit --- flow-typed/npm/@octokit/rest_v22.x.x.js | 61 +++++++++++++++++++ ...d-release-assets-for-dotslash-test.js.snap | 2 +- ...upload-release-assets-for-dotslash-test.js | 17 ++++-- .../upload-release-assets-for-dotslash.js | 17 +++--- scripts/releases/utils/octokit-utils.js | 58 ++++++++++++++++++ 5 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 flow-typed/npm/@octokit/rest_v22.x.x.js create mode 100644 scripts/releases/utils/octokit-utils.js 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/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 index 0a6de7623ec1a9..8aeebc22e3495d 100644 --- 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 @@ -27,7 +27,7 @@ exports[`uploadReleaseAssetsForDotSlashFile deletes and reuploads the asset if f Array [ Array [ Object { - "asset_id": 1, + "asset_id": "1", "owner": "facebook", "repo": "react-native", }, diff --git a/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js b/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js index 4590d83dc2acf8..4eae57114c44ba 100644 --- a/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js +++ b/scripts/releases/__tests__/upload-release-assets-for-dotslash-test.js @@ -32,10 +32,12 @@ expect.addSnapshotSerializer(removeAnsiColors); expect.addSnapshotSerializer(removeCurlPaths); const mockAssets: Array<{ - id: number, + id: string, ... }> = []; +let nextAssetId = 1; + const octokit = { repos: { listReleaseAssets: jest.fn().mockImplementation(() => { @@ -51,7 +53,10 @@ const octokit = { mockAssets.splice(index, 1); }), uploadReleaseAsset: jest.fn().mockImplementation(() => { - const assetId = Math.max(...mockAssets.map(asset => asset.id)) + 1; + let assetId; + do { + assetId = String(nextAssetId++); + } while (mockAssets.some(asset => asset.id === assetId)); mockAssets.push({ id: assetId, }); @@ -158,7 +163,7 @@ describe('uploadReleaseAssetsForDotSlashFile', () => { test('skips uploading the asset if already present', async () => { mockAssets.push({ - id: 1, + id: '1', name: 'test.tar.gz', }); await uploadReleaseAssetsForDotSlashFile( @@ -182,7 +187,7 @@ describe('uploadReleaseAssetsForDotSlashFile', () => { test('deletes and reuploads the asset if force is true', async () => { mockAssets.push({ - id: 1, + id: '1', name: 'test.tar.gz', }); await uploadReleaseAssetsForDotSlashFile( @@ -230,7 +235,7 @@ describe('uploadReleaseAssetsForDotSlashFile', () => { test('does not overwrite an existing asset if dryRun is true', async () => { mockAssets.push({ - id: 1, + id: '1', name: 'test.tar.gz', }); await uploadReleaseAssetsForDotSlashFile( @@ -256,7 +261,7 @@ describe('uploadReleaseAssetsForDotSlashFile', () => { octokit.repos.uploadReleaseAsset.mockImplementationOnce(async () => { return { data: { - id: 1, + id: '1', browser_download_url: `https://github.com/facebook/react-native/releases/download/untagged-0b602d8af97c6d3b784c/test-renamed.tar.gz`, }, }; diff --git a/scripts/releases/upload-release-assets-for-dotslash.js b/scripts/releases/upload-release-assets-for-dotslash.js index 401b325f8ee301..93865bf3a36c9f 100644 --- a/scripts/releases/upload-release-assets-for-dotslash.js +++ b/scripts/releases/upload-release-assets-for-dotslash.js @@ -20,7 +20,6 @@ const { const { FIRST_PARTY_DOTSLASH_FILES, } = require('./write-dotslash-release-asset-urls'); -// $FlowFixMe[untyped-import] TODO: add types for @octokit/rest const {Octokit} = require('@octokit/rest'); const nullthrows = require('nullthrows'); const path = require('path'); @@ -28,11 +27,9 @@ const {parseArgs} = require('util'); /*:: import type {DotSlashProvider, DotSlashHttpProvider, DotSlashArtifactInfo} from './utils/dotslash-utils'; +import type {IOctokit} from './utils/octokit-utils'; -// $FlowFixMe[unclear-type] TODO: add types for @octokit/rest -type OctokitInstance = any; - -type GitHubReleaseAsset = {id: number, ...}; +type GitHubReleaseAsset = {id: string, ...}; type ReleaseAssetMap = $ReadOnlyMap; type ReleaseInfo = $ReadOnly<{ @@ -138,7 +135,7 @@ async function getReleaseAssetMap( {releaseId} /*: { releaseId: string, } */, - octokit /*: OctokitInstance */, + octokit /*: IOctokit */, ) /*: Promise */ { const existingAssets = await octokit.repos.listReleaseAssets({ owner: 'facebook', @@ -157,7 +154,7 @@ async function uploadReleaseAssetsForDotSlashFile( filename /*: string */, releaseInfo /*: ReleaseInfo */, executionOptions /*: ExecutionOptions */, - octokit /*: OctokitInstance */, + octokit /*: IOctokit */, ) /*: Promise */ { const fullPath = path.resolve(REPO_ROOT, filename); console.log(`Uploading assets for ${filename}...`); @@ -201,7 +198,7 @@ async function fetchUpstreamAssetAndUploadToRelease( } */, releaseInfo /*: ReleaseInfo */, executionOptions /*: ExecutionOptions */, - octokit /*: OctokitInstance */, + octokit /*: IOctokit */, ) { const targetReleaseAssetInfo = providers .map(provider => parseReleaseAssetInfo(provider, releaseInfo.releaseTag)) @@ -297,7 +294,7 @@ async function maybeDeleteExistingReleaseAsset( } */, {dryRun} /*: ExecutionOptions */, - octokit /*: OctokitInstance */, + octokit /*: IOctokit */, ) /*: Promise */ { if (!existingAsset) { return; @@ -357,7 +354,7 @@ async function uploadAndVerifyReleaseAsset( dotslashFilename: string, } */, - octokit /*: OctokitInstance */, + octokit /*: IOctokit */, ) /*: Promise */ { console.log(`[${name}] Uploading to release...`); const { 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, + }>; +} From 5429325c88c28c2cd12247edcbe6dfcb3d7db7eb Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 08/12] Add types for @expo/spawn-async --- flow-typed/npm/@expo/spawn-async_v1.x.x.js | 46 ++++++++++++++++++++++ scripts/releases/utils/curl-utils.js | 1 - 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 flow-typed/npm/@expo/spawn-async_v1.x.x.js 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/scripts/releases/utils/curl-utils.js b/scripts/releases/utils/curl-utils.js index b8ee609613757d..fff8755b77fecd 100644 --- a/scripts/releases/utils/curl-utils.js +++ b/scripts/releases/utils/curl-utils.js @@ -10,7 +10,6 @@ 'use strict'; -// $FlowFixMe[untyped-import] TODO: add types for @expo/spawn-async const spawnAsync = require('@expo/spawn-async'); const {promises: fs} = require('fs'); const os = require('os'); From 1c2fbb88f364a07f64e9ad2e6f6e46961e73dd3a Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:33:22 +0100 Subject: [PATCH 09/12] Add types for jsonc-parser --- flow-typed/npm/jsonc-parser_v3.x.x.js | 436 +++++++++++++++++++++++ scripts/releases/utils/dotslash-utils.js | 1 - 2 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 flow-typed/npm/jsonc-parser_v3.x.x.js diff --git a/flow-typed/npm/jsonc-parser_v3.x.x.js b/flow-typed/npm/jsonc-parser_v3.x.x.js new file mode 100644 index 00000000000000..eb78cd4c2e31b9 --- /dev/null +++ b/flow-typed/npm/jsonc-parser_v3.x.x.js @@ -0,0 +1,436 @@ +/** + * 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. + * When `false` is returned, the object properties will not be visited. + */ + onObjectBegin?: ( + offset: number, + length: number, + startLine: number, + startCharacter: number, + pathSupplier: () => JSONPath, + ) => boolean | 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. + * When `false` is returned, the array items will not be visited. + */ + onArrayBegin?: ( + offset: number, + length: number, + startLine: number, + startCharacter: number, + pathSupplier: () => JSONPath, + ) => boolean | 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, + /** + * If set, will add a new line at the end of the document. + */ + insertFinalNewline?: boolean, + /** + * If true, will keep line positions as is in the formatting + */ + keepLines?: boolean, + }>; + /** + * 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. If undefined, the newly inserted code will be inserted unformatted. + */ + formattingOptions?: FormattingOptions, + /** + * Default false. If `JSONPath` refers to an index of an array and `isArrayInsertion` is `true`, then + * {@linkcode modify} will insert a new item at that location instead of overwriting its contents. + */ + isArrayInsertion?: boolean, + /** + * 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/scripts/releases/utils/dotslash-utils.js b/scripts/releases/utils/dotslash-utils.js index f74a73a5e9bf04..eb435fffd71fe1 100644 --- a/scripts/releases/utils/dotslash-utils.js +++ b/scripts/releases/utils/dotslash-utils.js @@ -12,7 +12,6 @@ const dotslash = require('fb-dotslash'); const {promises: fs} = require('fs'); -// $FlowFixMe[untyped-import] TODO: add types for jsonc-parser const {applyEdits, modify, parse} = require('jsonc-parser'); const os = require('os'); const path = require('path'); From 843f5613a680728b53ece5e7ece62485f768f72e Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 3 Sep 2025 15:49:58 +0100 Subject: [PATCH 10/12] Add Flow libdef for fb-dotslash --- flow-typed/npm/fb-dotslash_v0.x.x.js | 13 +++++++++++++ packages/debugger-shell/src/node/index.flow.js | 6 +----- .../debugger-shell/src/node/private/LaunchUtils.js | 10 +++++----- 3 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 flow-typed/npm/fb-dotslash_v0.x.x.js 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/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'}; } From 5b02181b5f0369c3bbc758dce8230a8491b07e0e Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Thu, 4 Sep 2025 11:02:53 +0100 Subject: [PATCH 11/12] Limit scope of validate-dotslash-artifacts push trigger, add nightly trigger --- .../workflows/validate-dotslash-artifacts.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/validate-dotslash-artifacts.yml b/.github/workflows/validate-dotslash-artifacts.yml index 02a3172166af8f..2b62d3b34c8eff 100644 --- a/.github/workflows/validate-dotslash-artifacts.yml +++ b/.github/workflows/validate-dotslash-artifacts.yml @@ -7,6 +7,22 @@ on: 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: From df892eb882bb2d38c5db41296632fadf3545d59b Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Thu, 4 Sep 2025 12:24:40 +0100 Subject: [PATCH 12/12] Make getWithCurl compatible with older curl Summary: We were using `--write-out %{header_json}` but this feature requires curl 7.83.0. Some of our CI environments have an older version of curl so we need a different approach. --- .../upload-release-assets-for-dotslash-test.js.snap | 2 +- .../releases/upload-release-assets-for-dotslash.js | 4 ++-- scripts/releases/utils/__tests__/curl-utils-test.js | 4 +--- scripts/releases/utils/curl-utils.js | 11 +++++++---- 4 files changed, 11 insertions(+), 10 deletions(-) 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 index 8aeebc22e3495d..210fcdc260dfe0 100644 --- 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 @@ -138,7 +138,7 @@ Array [ ] `; -exports[`uploadReleaseAssetsForDotSlashFile fails loudly if the upstream asset is unreachable 1`] = `"curl --silent --location --output /data /error --write-out %{header_json} --fail exited with non-zero code: 22"`; +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 [ diff --git a/scripts/releases/upload-release-assets-for-dotslash.js b/scripts/releases/upload-release-assets-for-dotslash.js index 93865bf3a36c9f..2d3006f5f061ca 100644 --- a/scripts/releases/upload-release-assets-for-dotslash.js +++ b/scripts/releases/upload-release-assets-for-dotslash.js @@ -328,12 +328,12 @@ async function fetchAndValidateUpstreamAsset( console.log(`[${name}] Downloading from ${url}...`); // NOTE: Using curl because we have seen issues with fetch() on GHA // and the Meta CDN. ¯\_(ツ)_/¯ - const {data, headers} = await getWithCurl(url); + const {data, contentType} = await getWithCurl(url); console.log(`[${name}] Validating download...`); await validateDotSlashArtifactData(data, artifactInfo); return { data, - contentType: headers['content-type']?.[0] ?? 'application/octet-stream', + contentType: contentType ?? 'application/octet-stream', }; } diff --git a/scripts/releases/utils/__tests__/curl-utils-test.js b/scripts/releases/utils/__tests__/curl-utils-test.js index 13dbdb248efef0..77592e53fdbe8b 100644 --- a/scripts/releases/utils/__tests__/curl-utils-test.js +++ b/scripts/releases/utils/__tests__/curl-utils-test.js @@ -50,9 +50,7 @@ describe('getWithCurl', () => { test('success', async () => { await expect(getWithCurl(serverUrl)).resolves.toEqual({ data: Buffer.from('Hello World\n'), - headers: expect.objectContaining({ - 'content-type': ['text/plain'], - }), + contentType: 'text/plain', }); }); diff --git a/scripts/releases/utils/curl-utils.js b/scripts/releases/utils/curl-utils.js index fff8755b77fecd..6b39eb79b36f58 100644 --- a/scripts/releases/utils/curl-utils.js +++ b/scripts/releases/utils/curl-utils.js @@ -18,7 +18,7 @@ const path = require('path'); /*:: type CurlResult = { data: Buffer, - headers: {[string]: Array}, + contentType?: string, }; */ @@ -37,14 +37,17 @@ async function getWithCurl(url /*: string */) /*: Promise */ { tempFile, url, '--write-out', - '%{header_json}', + '%{content_type}', '--fail', ], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}, ); const data = await fs.readFile(tempFile); - const headers = JSON.parse(curlStdout); - return {data, headers}; + const contentType = curlStdout.trim(); + if (contentType === '') { + return {data}; + } + return {data, contentType}; } finally { await fs.rm(tempDir, {recursive: true, force: true}); }