diff --git a/.ado/apple-pr.yml b/.ado/apple-pr.yml index b67226025c41b4..c8671f8441ea63 100644 --- a/.ado/apple-pr.yml +++ b/.ado/apple-pr.yml @@ -26,7 +26,6 @@ stages: dependsOn: [] jobs: - template: /.ado/jobs/test-javascript.yml@self - - template: /.ado/jobs/npm-publish-dry-run.yml@self # https://github.com/microsoft/react-native-macos/issues/2344 # The Verdaccio server consistently hangs on creation, which is required for the integration tests diff --git a/.ado/jobs/npm-publish-dry-run.yml b/.ado/jobs/npm-publish-dry-run.yml deleted file mode 100644 index 2fa1df7717cead..00000000000000 --- a/.ado/jobs/npm-publish-dry-run.yml +++ /dev/null @@ -1,10 +0,0 @@ -jobs: -- job: NPMPublishDryRun - displayName: NPM Publish Dry Run - pool: - vmImage: $(VmImageApple) - timeoutInMinutes: 60 - cancelTimeoutInMinutes: 5 - steps: - - template: /.ado/templates/npm-publish-steps.yml@self - diff --git a/.ado/jobs/npm-publish.yml b/.ado/jobs/npm-publish.yml index 6e879f0feb38b3..c43af09e3f843b 100644 --- a/.ado/jobs/npm-publish.yml +++ b/.ado/jobs/npm-publish.yml @@ -16,4 +16,57 @@ jobs: targetPath: $(System.DefaultWorkingDirectory) artifactName: github-npm-js-publish steps: - - template: /.ado/templates/npm-publish-steps.yml@self \ No newline at end of file + - checkout: self + clean: true + fetchFilter: blob:none + persistCredentials: true + + - template: /.ado/templates/configure-git.yml@self + + - script: | + PUBLISH_TAG=$(jq -r '.release.version.generatorOptions.currentVersionResolverMetadata.tag' nx.json) + echo "##vso[task.setvariable variable=publishTag]$PUBLISH_TAG" + echo "Using publish tag from nx.json: $PUBLISH_TAG" + displayName: Read publish tag from nx.json + + - script: | + yarn install + displayName: Install npm dependencies + + - script: | + node .ado/scripts/prepublish-check.mjs --verbose --skip-auth --tag $(publishTag) + displayName: Verify release config + + - script: | + echo Target branch: $(System.PullRequest.TargetBranch) + yarn nx release --dry-run --verbose + displayName: Version and publish packages (dry run) + condition: and(succeeded(), ne(variables['publish_react_native_macos'], '1')) + + # Disable Nightly publishing on the main branch + - ${{ if endsWith(variables['Build.SourceBranchName'], '-stable') }}: + - script: | + echo "//registry.npmjs.org/:_authToken=$(npmAuthToken)" > ~/.npmrc + node .ado/scripts/prepublish-check.mjs --verbose --tag $(publishTag) + displayName: Set and validate npm auth + condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) + + - script: | + git switch $(Build.SourceBranchName) + yarn nx release --skip-publish --verbose + env: + GITHUB_TOKEN: $(githubAuthToken) + displayName: Version Packages and Github Release + condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) + + - script: | + if [[ -f .rnm-publish ]]; then + yarn nx release publish --tag ${{ parameters['publishTag'] }} --excludeTaskDependencies + fi + displayName: Publish packages + condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) + + - script: | + rm -f ~/.npmrc + displayName: Remove npmrc if it exists + condition: always() diff --git a/.ado/scripts/npmAddUser.mjs b/.ado/scripts/npmAddUser.mjs index f08793fc3fcabb..3a62339ab9400a 100644 --- a/.ado/scripts/npmAddUser.mjs +++ b/.ado/scripts/npmAddUser.mjs @@ -1,30 +1,19 @@ #!/usr/bin/env node // @ts-check +import * as assert from "node:assert/strict"; import { exec } from "node:child_process"; -/** - * @template T - * @param {T} arg - * @param {string} message - * @returns {asserts arg is NonNullable} - */ -function assert(arg, message) { - if (!arg) { - throw new Error(message); - } -} - const { [2]: username, [3]: password, [4]: email, [5]: registry } = process.argv; -assert(username, "Please specify username"); -assert(password, "Please specify password"); -assert(email, "Please specify email"); +assert.ok(username, "Please specify username"); +assert.ok(password, "Please specify password"); +assert.ok(email, "Please specify email"); const child = exec(`npm adduser${registry ? ` --registry ${registry}` : ""}`); -assert(child.stdout, "Missing stdout on child process"); +assert.ok(child.stdout, "Missing stdout on child process"); child.stdout.on("data", d => { - assert(child.stdin, "Missing stdin on child process"); + assert.ok(child.stdin, "Missing stdin on child process"); process.stdout.write(d); process.stdout.write("\n"); diff --git a/.ado/scripts/prepublish-check.mjs b/.ado/scripts/prepublish-check.mjs index 35222585c728de..822691b4813964 100644 --- a/.ado/scripts/prepublish-check.mjs +++ b/.ado/scripts/prepublish-check.mjs @@ -3,9 +3,9 @@ import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as util from "node:util"; -const ADO_PUBLISH_PIPELINE = ".ado/templates/npm-publish-steps.yml"; const NX_CONFIG_FILE = "nx.json"; +const NPM_DEFEAULT_REGISTRY = "https://registry.npmjs.org/" const NPM_TAG_NEXT = "next"; const NPM_TAG_NIGHTLY = "nightly"; const RNMACOS_LATEST = "react-native-macos@latest"; @@ -21,8 +21,18 @@ const RNMACOS_NEXT = "react-native-macos@next"; * }; * }; * }} NxConfig; - * @typedef {{ tag?: string; update?: boolean; verbose?: boolean; }} Options; - * @typedef {{ npmTag: string; prerelease?: string; isNewTag?: boolean; }} TagInfo; + * @typedef {{ + * "mock-branch"?: string; + * "skip-auth"?: boolean; + * tag?: string; + * update?: boolean; + * verbose?: boolean; + * }} Options; + * @typedef {{ + * npmTag: string; + * prerelease?: string; + * isNewTag?: boolean; + * }} TagInfo; */ /** @@ -80,6 +90,38 @@ function loadNxConfig(configFile) { return JSON.parse(nx); } +function verifyNpmAuth(registry = NPM_DEFEAULT_REGISTRY) { + const npmErrorRegex = /npm error code (\w+)/; + const spawnOptions = { + stdio: /** @type {const} */ ("pipe"), + shell: true, + windowsVerbatimArguments: true, + }; + + const whoamiArgs = ["whoami", "--registry", registry]; + const whoami = spawnSync("npm", whoamiArgs, spawnOptions); + if (whoami.status !== 0) { + const error = whoami.stderr.toString(); + const m = error.match(npmErrorRegex); + switch (m && m[1]) { + case "EINVALIDNPMTOKEN": + throw new Error(`Invalid auth token for npm registry: ${registry}`); + case "ENEEDAUTH": + throw new Error(`Missing auth token for npm registry: ${registry}`); + default: + throw new Error(error); + } + } + + const tokenArgs = ["token", "list", "--registry", registry]; + const token = spawnSync("npm", tokenArgs, spawnOptions); + if (token.status !== 0) { + const error = token.stderr.toString(); + const m = error.match(npmErrorRegex); + throw new Error(m ? `Auth token for '${registry}' returned error code ${m[1]}` : error); + } +} + /** * Returns a numerical value for a given version string. * @param {string} version @@ -91,15 +133,65 @@ function versionToNumber(version) { } /** - * Returns the currently checked out branch. Note that this function prefers - * predefined CI environment variables over local clone. + * Returns the target branch name. If not targetting any branches (e.g., when + * executing this script locally), `undefined` is returned. + * @returns {string | undefined} + */ +function getTargetBranch() { + // Azure Pipelines + if (process.env["TF_BUILD"] === "True") { + // https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#build-variables-devops-services + const targetBranch = process.env["SYSTEM_PULLREQUEST_TARGETBRANCH"]; + return targetBranch?.replace(/^refs\/heads\//, ""); + } + + // GitHub Actions + if (process.env["GITHUB_ACTIONS"] === "true") { + // https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + return process.env["GITHUB_BASE_REF"]; + } + + return undefined; +} + +/** + * Returns the current branch name. In a pull request, the target branch name is + * returned. + * @param {Options} options * @returns {string} */ -function getCurrentBranch() { - // https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#build-variables-devops-services - const adoSourceBranchName = process.env["BUILD_SOURCEBRANCHNAME"]; - if (adoSourceBranchName) { - return adoSourceBranchName.replace(/^refs\/heads\//, ""); +function getCurrentBranch(options) { + const targetBranch = getTargetBranch(); + if (targetBranch) { + return targetBranch; + } + + // Azure DevOps Pipelines + if (process.env["TF_BUILD"] === "True") { + // https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#build-variables-devops-services + const sourceBranch = process.env["BUILD_SOURCEBRANCHNAME"]; + if (sourceBranch) { + return sourceBranch.replace(/^refs\/heads\//, ""); + } + } + + // GitHub Actions + if (process.env["GITHUB_ACTIONS"] === "true") { + // https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables + const headRef = process.env["GITHUB_HEAD_REF"]; + if (headRef) { + return headRef; // For pull requests + } + + const ref = process.env["GITHUB_REF"]; + if (ref) { + return ref.replace(/^refs\/heads\//, ""); // For push events + } + } + + const { "mock-branch": mockBranch } = options; + if (mockBranch) { + return mockBranch; } // Depending on how the repo was cloned, HEAD may not exist. We only use this @@ -177,31 +269,15 @@ function getTagForStableBranch(branch, { tag }, log) { return { npmTag: NPM_TAG_NEXT, prerelease: "rc" }; } -/** - * @param {string} file - * @param {string} tag - * @returns {void} - */ -function verifyPublishPipeline(file, tag) { - const data = fs.readFileSync(file, { encoding: "utf-8" }); - const m = data.match(/publishTag: '(latest|next|nightly|v\d+\.\d+-stable)'/); - if (!m) { - throw new Error(`${file}: Could not find npm publish tag`); - } - - if (m[1] !== tag) { - throw new Error(`${file}: 'publishTag' must be set to '${tag}'`); - } -} - /** * Verifies the configuration and enables publishing on CI. * @param {NxConfig} config * @param {string} currentBranch * @param {TagInfo} tag + * @param {Options} options * @returns {asserts config is NxConfig["release"]} */ -function enablePublishing(config, currentBranch, { npmTag: tag, prerelease, isNewTag }) { +function enablePublishing(config, currentBranch, { npmTag: tag, prerelease, isNewTag }, options) { /** @type {string[]} */ const errors = []; @@ -244,7 +320,7 @@ function enablePublishing(config, currentBranch, { npmTag: tag, prerelease, isNe generatorOptions.fallbackCurrentVersionResolver = "disk"; } } else if (typeof generatorOptions.fallbackCurrentVersionResolver === "string") { - errors.push("'release.version.generatorOptions.fallbackCurrentVersionResolver' must be unset"); + errors.push("'release.version.generatorOptions.fallbackCurrentVersionResolver' must be removed"); generatorOptions.fallbackCurrentVersionResolver = undefined; } @@ -253,8 +329,16 @@ function enablePublishing(config, currentBranch, { npmTag: tag, prerelease, isNe throw new Error("Nx Release is not correctly configured for the current branch"); } - verifyPublishPipeline(ADO_PUBLISH_PIPELINE, tag); - enablePublishingOnAzurePipelines(); + if (options["skip-auth"]) { + info("Skipped npm auth validation"); + } else { + verifyNpmAuth(); + } + + // Don't enable publishing in PRs + if (!getTargetBranch()) { + enablePublishingOnAzurePipelines(); + } } /** @@ -262,7 +346,7 @@ function enablePublishing(config, currentBranch, { npmTag: tag, prerelease, isNe * @returns {number} */ function main(options) { - const branch = getCurrentBranch(); + const branch = getCurrentBranch(options); if (!branch) { error("Could not get current branch"); return 1; @@ -273,10 +357,11 @@ function main(options) { const config = loadNxConfig(NX_CONFIG_FILE); try { if (isMainBranch(branch)) { - enablePublishing(config, branch, { npmTag: NPM_TAG_NIGHTLY, prerelease: NPM_TAG_NIGHTLY }); + const info = { npmTag: NPM_TAG_NIGHTLY, prerelease: NPM_TAG_NIGHTLY }; + enablePublishing(config, branch, info, options); } else if (isStableBranch(branch)) { const tag = getTagForStableBranch(branch, options, logger); - enablePublishing(config, branch, tag); + enablePublishing(config, branch, tag, options); } } catch (e) { if (options.update) { @@ -296,6 +381,13 @@ function main(options) { const { values } = util.parseArgs({ args: process.argv.slice(2), options: { + "mock-branch": { + type: "string", + }, + "skip-auth": { + type: "boolean", + default: false, + }, tag: { type: "string", default: NPM_TAG_NEXT, diff --git a/.ado/templates/apple-tools-setup.yml b/.ado/templates/apple-tools-setup.yml index f001e1b99bdcd8..5ceec73192883f 100644 --- a/.ado/templates/apple-tools-setup.yml +++ b/.ado/templates/apple-tools-setup.yml @@ -1,7 +1,7 @@ steps: - - task: NodeTool@0 + - task: UseNode@1 inputs: - versionSpec: '23.x' + version: '23.x' - script: | brew bundle --file .ado/Brewfile diff --git a/.ado/templates/npm-publish-steps.yml b/.ado/templates/npm-publish-steps.yml deleted file mode 100644 index 50ce5051041bff..00000000000000 --- a/.ado/templates/npm-publish-steps.yml +++ /dev/null @@ -1,44 +0,0 @@ -parameters: - # If this is a new stable branch, change `publishTag` to `latest` when going stable - publishTag: 'latest' - -steps: - - checkout: self - clean: true - fetchFilter: blob:none - persistCredentials: true - - - template: /.ado/templates/configure-git.yml@self - - - script: | - yarn install - displayName: Install npm dependencies - - - script: | - node .ado/scripts/prepublish-check.mjs --verbose --tag ${{ parameters['publishTag'] }} - displayName: Verify release config - - - script: | - echo Target branch: $(System.PullRequest.TargetBranch) - yarn nx release --dry-run --verbose - displayName: Version and publish packages (dry run) - condition: and(succeeded(), ne(variables['publish_react_native_macos'], '1')) - - - script: | - git switch $(Build.SourceBranchName) - yarn nx release --skip-publish --verbose - env: - GITHUB_TOKEN: $(githubAuthToken) - displayName: Version Packages and Github Release - condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) - - - script: | - echo "//registry.npmjs.org/:_authToken=$(npmAuthToken)" > ~/.npmrc - yarn nx release publish --tag ${{ parameters['publishTag'] }} --excludeTaskDependencies - displayName: Publish packages - condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) - - - script: | - rm -f ~/.npmrc - displayName: Remove npmrc if it exists - condition: always() diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml new file mode 100644 index 00000000000000..2bebae857cb460 --- /dev/null +++ b/.github/workflows/microsoft-pr.yml @@ -0,0 +1,65 @@ +name: PR + +on: + pull_request: + types: [opened, synchronize, edited] + branches: [ "main", "*-stable", "release/*" ] + +concurrency: + # Ensure single build of a pull request. `main` should not be affected. + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint-commit: + name: "Lint PR title" + permissions: {} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + # We lint the PR title instead of the commit message to avoid script injection attacks. + # Using environment variables prevents potential security vulnerabilities as described in: + # https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#example-of-a-script-injection-attack + - name: Lint PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + echo "$PR_TITLE" | npx @rnx-kit/commitlint-lite@2.0.0 + npm-publish-dry-run: + name: "NPM Publish (Dry Run)" + permissions: {} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Read publish tag from nx.json + id: config + run: | + PUBLISH_TAG=$(jq -r '.release.version.generatorOptions.currentVersionResolverMetadata.tag' nx.json) + echo "publishTag=$PUBLISH_TAG" >> $GITHUB_OUTPUT + echo "Using publish tag from nx.json: $PUBLISH_TAG" + - name: Configure git + run: | + git config --global user.email "53619745+rnbot@users.noreply.github.com" + git config --global user.name "React-Native Bot" + git remote set-url origin https://rnbot:${{ secrets.GITHUB_TOKEN }}@github.com/microsoft/react-native-macos + - name: Install dependencies + run: yarn + - name: Verify release config + run: | + node .ado/scripts/prepublish-check.mjs --verbose --skip-auth --tag ${{ steps.config.outputs.publishTag }} + - name: Version and publish packages (dry run) + run: | + echo "Target branch: ${{ github.base_ref }}" + yarn nx release --dry-run --verbose \ No newline at end of file diff --git a/.nx/version-plans/version-plan-1751411273803.md b/.nx/version-plans/version-plan-1751411273803.md new file mode 100644 index 00000000000000..9ce72849dfe060 --- /dev/null +++ b/.nx/version-plans/version-plan-1751411273803.md @@ -0,0 +1,6 @@ +--- +react-native-macos: patch +'@react-native-mac/virtualized-lists': patch +--- + +new patch release diff --git a/docs/Releases.md b/docs/Releases.md index 80cb81b609ea4a..c70a7a795be474 100644 --- a/docs/Releases.md +++ b/docs/Releases.md @@ -72,13 +72,7 @@ git push -u origin HEAD ## Marking a release candidate stable -Prepare the publish pipeline: - -```sh -sed -i '' "s/publishTag: 'next'/publishTag: 'latest'/" .ado/templates/npm-publish-steps.yml -``` - -Update the release configuration: +Prepare the release configuration: ```sh node .ado/scripts/prepublish-check.mjs --tag latest --update