diff --git a/.ado/jobs/npm-publish.yml b/.ado/jobs/npm-publish.yml index 01b83311b41c0f..8a3dc46727f515 100644 --- a/.ado/jobs/npm-publish.yml +++ b/.ado/jobs/npm-publish.yml @@ -8,8 +8,6 @@ jobs: variables: - name: BUILDSECMON_OPT_IN value: true - - name: USE_YARN_FOR_PUBLISH - value: false timeoutInMinutes: 90 cancelTimeoutInMinutes: 5 @@ -26,71 +24,26 @@ jobs: - template: /.ado/templates/configure-git.yml@self - - script: | - PUBLISH_TAG=$(jq -r '.release.version.versionActionsOptions.currentVersionResolverMetadata.tag' nx.json) - if [ -z "$PUBLISH_TAG" ] || [ "$PUBLISH_TAG" = "null" ]; then - echo "Error: Failed to read publish tag from nx.json" - exit 1 - fi - 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) + node .ado/scripts/release.mjs --dry-run --verbose + displayName: Release (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: | git switch $(Build.SourceBranchName) - yarn nx release --skip-publish --verbose + node .ado/scripts/release.mjs --verbose --token "$(npmAuthToken)" env: GITHUB_TOKEN: $(githubAuthToken) - displayName: Version Packages and Github Release - condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) - - - script: | - set -eox pipefail - if [[ -f .rnm-publish ]]; then - # https://github.com/microsoft/react-native-macos/issues/2580 - # `nx release publish` gets confused by the output of RNM's prepack script. - # Let's call publish directly instead on the packages we want to publish. - # yarn nx release publish --tag $(publishTag) --excludeTaskDependencies - if [ "$(USE_YARN_FOR_PUBLISH)" = "true" ]; then - echo "Configuring yarn for npm publishing" - yarn config set npmPublishRegistry "https://registry.npmjs.org" - yarn config set npmAuthToken $(npmAuthToken) - echo "Publishing with yarn npm publish" - yarn ./packages/virtualized-lists npm publish --tag $(publishTag) - yarn ./packages/react-native npm publish --tag $(publishTag) - else - echo "Publishing with npm publish" - npm publish ./packages/virtualized-lists --tag $(publishTag) --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=$(npmAuthToken) - npm publish ./packages/react-native --tag $(publishTag) --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=$(npmAuthToken) - fi - fi - displayName: Publish packages + displayName: Release condition: and(succeeded(), eq(variables['publish_react_native_macos'], '1')) - script: | - if [ "$(USE_YARN_FOR_PUBLISH)" = "true" ]; then - echo "Cleaning up yarn npm configuration" - yarn config unset npmAuthToken || true - yarn config unset npmPublishRegistry || true - else - echo "Cleaning up npm configuration" - rm -f ~/.npmrc - fi + rm -f ~/.npmrc displayName: Remove NPM auth configuration condition: always() diff --git a/.ado/scripts/__tests__/release-test.mjs b/.ado/scripts/__tests__/release-test.mjs new file mode 100644 index 00000000000000..056d77bded040e --- /dev/null +++ b/.ado/scripts/__tests__/release-test.mjs @@ -0,0 +1,365 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Run with: node --test .ado/scripts/__tests__/release-test.mjs + * + * @format + */ + +import { describe, it } from "node:test"; +import assert from "node:assert"; + +// ============================================================================ +// Replicated pure functions from release.mjs for testing +// ============================================================================ + +const NPM_TAG_NEXT = "next"; +const NPM_TAG_NIGHTLY = "nightly"; + +/** + * @typedef {"NIGHTLY" | "PATCH_LATEST" | "PATCH_OLD" | "PROMOTE_TO_LATEST" | "RELEASE_CANDIDATE" | "NOT_RELEASE_BRANCH"} ReleaseState + */ + +/** + * @typedef {{ + * state: ReleaseState; + * currentVersion: number; + * latestVersion: number; + * nextVersion: number; + * }} ReleaseStateInfo + */ + +function isMainBranch(branch) { + return branch === "main"; +} + +function isStableBranch(branch) { + return /^\d+\.\d+-stable$/.test(branch); +} + +function versionToNumber(version) { + const [major, minor] = version.split("-")[0].split("."); + return Number(major) * 1000 + Number(minor); +} + +/** + * Determines the release state based on branch, versions, and options + * @param {string} branch + * @param {{tag?: string}} options + * @param {(tag: string) => number} getPublishedVersion + * @returns {ReleaseStateInfo} + */ +function getReleaseState(branch, options, getPublishedVersion) { + if (isMainBranch(branch)) { + return { state: "NIGHTLY", currentVersion: 0, latestVersion: 0, nextVersion: 0 }; + } + + if (!isStableBranch(branch)) { + return { state: "NOT_RELEASE_BRANCH", currentVersion: 0, latestVersion: 0, nextVersion: 0 }; + } + + const latestVersion = getPublishedVersion("latest"); + const nextVersion = getPublishedVersion("next"); + const currentVersion = versionToNumber(branch); + + /** @type {ReleaseState} */ + let state; + if (currentVersion === latestVersion) { + state = "PATCH_LATEST"; + } else if (currentVersion < latestVersion) { + state = "PATCH_OLD"; + } else if (options.tag === "latest") { + state = "PROMOTE_TO_LATEST"; + } else { + state = "RELEASE_CANDIDATE"; + } + + return { state, currentVersion, latestVersion, nextVersion }; +} + +/** + * Gets npm tags based on release state + * @param {string} branch + * @param {{tag?: string}} options + * @param {(msg: string) => void} log + * @param {(tag: string) => number} getPublishedVersion + * @returns {{npmTags: string[], prerelease?: string} | null} + */ +function getTagInfo(branch, options, log, getPublishedVersion) { + const { state, currentVersion, latestVersion, nextVersion } = getReleaseState( + branch, + options, + getPublishedVersion + ); + + log(`react-native-macos@latest: ${latestVersion}`); + log(`react-native-macos@next: ${nextVersion}`); + log(`Current version: ${currentVersion}`); + log(`Release state: ${state}`); + + switch (state) { + case "NIGHTLY": + log(`Expected npm tags: ${NPM_TAG_NIGHTLY}`); + return { npmTags: [NPM_TAG_NIGHTLY], prerelease: NPM_TAG_NIGHTLY }; + + case "PATCH_LATEST": { + const versionTag = "v" + branch; + log(`Expected npm tags: latest, ${versionTag}`); + return { npmTags: ["latest", versionTag] }; + } + + case "PATCH_OLD": { + const npmTag = "v" + branch; + log(`Expected npm tags: ${npmTag}`); + return { npmTags: [npmTag] }; + } + + case "PROMOTE_TO_LATEST": { + const versionTag = "v" + branch; + const npmTags = ["latest", versionTag]; + if (currentVersion > nextVersion) { + npmTags.push(NPM_TAG_NEXT); + } + log(`Expected npm tags: ${npmTags.join(", ")}`); + return { npmTags }; + } + + case "RELEASE_CANDIDATE": + if (currentVersion < nextVersion) { + throw new Error( + `Current version cannot be a release candidate because it is too old: ${currentVersion} < ${nextVersion}` + ); + } + log(`Expected npm tags: ${NPM_TAG_NEXT}`); + return { npmTags: [NPM_TAG_NEXT], prerelease: "rc" }; + + case "NOT_RELEASE_BRANCH": + default: + return null; + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe("release.mjs", () => { + describe("isMainBranch", () => { + it('returns true for "main"', () => { + assert.strictEqual(isMainBranch("main"), true); + }); + + it('returns false for "master"', () => { + assert.strictEqual(isMainBranch("master"), false); + }); + + it("returns false for stable branches", () => { + assert.strictEqual(isMainBranch("0.77-stable"), false); + }); + + it("returns false for feature branches", () => { + assert.strictEqual(isMainBranch("feature/my-feature"), false); + }); + }); + + describe("isStableBranch", () => { + it("returns true for valid stable branches", () => { + assert.strictEqual(isStableBranch("0.77-stable"), true); + assert.strictEqual(isStableBranch("0.78-stable"), true); + assert.strictEqual(isStableBranch("1.0-stable"), true); + assert.strictEqual(isStableBranch("12.34-stable"), true); + }); + + it("returns false for main branch", () => { + assert.strictEqual(isStableBranch("main"), false); + }); + + it("returns false for invalid stable branch formats", () => { + assert.strictEqual(isStableBranch("0.77-stable-hotfix"), false); + assert.strictEqual(isStableBranch("stable"), false); + assert.strictEqual(isStableBranch("v0.77-stable"), false); + assert.strictEqual(isStableBranch("0.77.1-stable"), false); + assert.strictEqual(isStableBranch("77-stable"), false); + }); + + it("returns false for feature branches", () => { + assert.strictEqual(isStableBranch("feature/0.77-stable"), false); + }); + }); + + describe("versionToNumber", () => { + it("converts version strings to numbers", () => { + assert.strictEqual(versionToNumber("0.77"), 77); + assert.strictEqual(versionToNumber("0.78"), 78); + assert.strictEqual(versionToNumber("1.0"), 1000); + assert.strictEqual(versionToNumber("1.5"), 1005); + assert.strictEqual(versionToNumber("12.34"), 12034); + }); + + it("handles version strings with -stable suffix", () => { + assert.strictEqual(versionToNumber("0.77-stable"), 77); + assert.strictEqual(versionToNumber("0.78-stable"), 78); + assert.strictEqual(versionToNumber("1.0-stable"), 1000); + }); + + it("handles full semver versions", () => { + assert.strictEqual(versionToNumber("0.77.0"), 77); + assert.strictEqual(versionToNumber("0.77.5"), 77); + assert.strictEqual(versionToNumber("0.77.0-rc.1"), 77); + }); + }); + + describe("getReleaseState", () => { + const mockGetPublishedVersion = (latestV, nextV) => (tag) => { + if (tag === "latest") return latestV; + if (tag === "next") return nextV; + return 0; + }; + + it("returns NIGHTLY for main branch", () => { + const result = getReleaseState("main", {}, mockGetPublishedVersion(77, 78)); + assert.strictEqual(result.state, "NIGHTLY"); + }); + + it("returns NOT_RELEASE_BRANCH for feature branches", () => { + const result = getReleaseState("feature/foo", {}, mockGetPublishedVersion(77, 78)); + assert.strictEqual(result.state, "NOT_RELEASE_BRANCH"); + }); + + it("returns PATCH_LATEST when current equals latest", () => { + const result = getReleaseState("0.77-stable", {}, mockGetPublishedVersion(77, 78)); + assert.strictEqual(result.state, "PATCH_LATEST"); + assert.strictEqual(result.currentVersion, 77); + assert.strictEqual(result.latestVersion, 77); + }); + + it("returns PATCH_OLD when current is less than latest", () => { + const result = getReleaseState("0.77-stable", {}, mockGetPublishedVersion(78, 79)); + assert.strictEqual(result.state, "PATCH_OLD"); + assert.strictEqual(result.currentVersion, 77); + assert.strictEqual(result.latestVersion, 78); + }); + + it("returns PROMOTE_TO_LATEST when tag option is latest", () => { + const result = getReleaseState("0.78-stable", { tag: "latest" }, mockGetPublishedVersion(77, 77)); + assert.strictEqual(result.state, "PROMOTE_TO_LATEST"); + }); + + it("returns RELEASE_CANDIDATE when current > latest and no tag option", () => { + const result = getReleaseState("0.78-stable", {}, mockGetPublishedVersion(77, 77)); + assert.strictEqual(result.state, "RELEASE_CANDIDATE"); + }); + }); + + describe("getTagInfo", () => { + const mockLog = () => {}; + + it("returns nightly tags for NIGHTLY state (main branch)", () => { + const getPublishedVersion = () => 0; + const result = getTagInfo("main", {}, mockLog, getPublishedVersion); + assert.deepStrictEqual(result, { + npmTags: ["nightly"], + prerelease: "nightly", + }); + }); + + it("returns null for NOT_RELEASE_BRANCH state (feature branches)", () => { + const getPublishedVersion = () => 0; + const result = getTagInfo("feature/my-feature", {}, mockLog, getPublishedVersion); + assert.strictEqual(result, null); + }); + + it("returns null for NOT_RELEASE_BRANCH state (invalid branch names)", () => { + const getPublishedVersion = () => 0; + assert.strictEqual(getTagInfo("", {}, mockLog, getPublishedVersion), null); + assert.strictEqual(getTagInfo("develop", {}, mockLog, getPublishedVersion), null); + assert.strictEqual(getTagInfo("release", {}, mockLog, getPublishedVersion), null); + }); + + it("returns latest + version tag for PATCH_LATEST state", () => { + const getPublishedVersion = (tag) => { + if (tag === "latest") return 77; + if (tag === "next") return 78; + return 0; + }; + const result = getTagInfo("0.77-stable", {}, mockLog, getPublishedVersion); + assert.deepStrictEqual(result, { + npmTags: ["latest", "v0.77-stable"], + }); + }); + + it("returns only version tag for PATCH_OLD state", () => { + const getPublishedVersion = (tag) => { + if (tag === "latest") return 78; + if (tag === "next") return 79; + return 0; + }; + const result = getTagInfo("0.77-stable", {}, mockLog, getPublishedVersion); + assert.deepStrictEqual(result, { + npmTags: ["v0.77-stable"], + }); + }); + + it("returns latest + version + next for PROMOTE_TO_LATEST state (newer than next)", () => { + const getPublishedVersion = (tag) => { + if (tag === "latest") return 77; + if (tag === "next") return 77; + return 0; + }; + const result = getTagInfo("0.78-stable", { tag: "latest" }, mockLog, getPublishedVersion); + assert.deepStrictEqual(result, { + npmTags: ["latest", "v0.78-stable", "next"], + }); + }); + + it("returns latest + version without next for PROMOTE_TO_LATEST state (not newer than next)", () => { + const getPublishedVersion = (tag) => { + if (tag === "latest") return 77; + if (tag === "next") return 78; + return 0; + }; + const result = getTagInfo("0.78-stable", { tag: "latest" }, mockLog, getPublishedVersion); + assert.deepStrictEqual(result, { + npmTags: ["latest", "v0.78-stable"], + }); + }); + + it("returns next tag with rc prerelease for RELEASE_CANDIDATE state", () => { + const getPublishedVersion = (tag) => { + if (tag === "latest") return 77; + if (tag === "next") return 77; + return 0; + }; + const result = getTagInfo("0.78-stable", {}, mockLog, getPublishedVersion); + assert.deepStrictEqual(result, { + npmTags: ["next"], + prerelease: "rc", + }); + }); + + it("throws error for RELEASE_CANDIDATE state when version is too old", () => { + const getPublishedVersion = (tag) => { + if (tag === "latest") return 77; + if (tag === "next") return 79; + return 0; + }; + assert.throws( + () => getTagInfo("0.78-stable", {}, mockLog, getPublishedVersion), + { message: /Current version cannot be a release candidate/ } + ); + }); + }); + + describe("constants", () => { + it("NPM_TAG_NEXT is 'next'", () => { + assert.strictEqual(NPM_TAG_NEXT, "next"); + }); + + it("NPM_TAG_NIGHTLY is 'nightly'", () => { + assert.strictEqual(NPM_TAG_NIGHTLY, "nightly"); + }); + }); +}); diff --git a/.ado/scripts/prepublish-check.mjs b/.ado/scripts/prepublish-check.mjs deleted file mode 100644 index 4ea23f41d8960d..00000000000000 --- a/.ado/scripts/prepublish-check.mjs +++ /dev/null @@ -1,460 +0,0 @@ -// @ts-check -import { spawnSync } from "node:child_process"; -import * as fs from "node:fs"; -import * as util from "node:util"; - -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"; -const RNMACOS_NEXT = "react-native-macos@next"; - -/** - * @typedef {import("nx/src/config/nx-json").NxReleaseVersionConfiguration} NxReleaseVersionConfiguration; - * @typedef {{ - * defaultBase: string; - * release: { - * version: NxReleaseVersionConfiguration; - * }; - * }} NxConfig; - * @typedef {{ - * "mock-branch"?: string; - * "skip-auth"?: boolean; - * tag?: string; - * update?: boolean; - * verbose?: boolean; - * }} Options; - * @typedef {{ - * npmTag: string; - * prerelease?: string; - * isNewTag?: boolean; - * }} TagInfo; - */ - -/** - * Exports a variable, `publish_react_native_macos`, to signal that we want to - * enable publishing on Azure Pipelines. - * - * Note that pipelines need to read this variable separately and do the actual - * work to publish bits. - */ -function enablePublishingOnAzurePipelines() { - console.log(`##vso[task.setvariable variable=publish_react_native_macos]1`); -} - -/** - * Logs an error message to the console. - * @param {string} message - */ -function error(message) { - console.error("❌", message); -} - -/** - * Logs an informational message to the console. - * @param {string} message - */ -function info(message) { - console.log("ℹ️", message); -} - -/** - * Returns whether the given branch is considered main branch. - * @param {string} branch - */ -function isMainBranch(branch) { - // There is currently no good way to consistently get the main branch. We - // hardcode the value for now. - return branch === "main"; -} - -/** - * Returns whether the given branch is considered a stable branch. - * @param {string} branch - */ -function isStableBranch(branch) { - return /^\d+\.\d+-stable$/.test(branch); -} - -/** - * Loads Nx configuration. - * @param {string} configFile - * @returns {NxConfig} - */ -function loadNxConfig(configFile) { - const nx = fs.readFileSync(configFile, { encoding: "utf-8" }); - return JSON.parse(nx); -} - -/** - * Detects whether to use npm or yarn to publish based on .npmrc existence - * @returns {boolean} true if npm should be used, false if yarn should be used - * @throws {Error} if neither .npmrc nor .yarnrc.yml exists - */ -function shouldUseNpm() { - const hasNpmrc = fs.existsSync('.npmrc'); - const hasYarnrc = fs.existsSync('.yarnrc.yml'); - - if (!hasNpmrc && !hasYarnrc) { - error('No package manager configuration found. Expected either .npmrc or .yarnrc.yml file.'); - throw new Error('No package manager configuration found'); - } - - if (hasNpmrc && hasYarnrc) { - // If both exist, prefer npm (could be changed based on project preference) - info('Both .npmrc and .yarnrc.yml found, using npm configuration'); - return true; - } - - return hasNpmrc; -} - -function verifyNpmAuth(registry = NPM_DEFEAULT_REGISTRY) { - const useNpm = shouldUseNpm(); - const spawnOptions = { - stdio: /** @type {const} */ ("pipe"), - shell: true, - windowsVerbatimArguments: true, - }; - - if (useNpm) { - info("Using npm for authentication (found .npmrc)"); - const npmErrorRegex = /npm error code (\w+)/; - - 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); - const errorCode = m && m[1]; - switch (errorCode) { - 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); - const errorCode = m && m[1]; - - // E403 means the token doesn't have permission to list tokens, but that's - // not required for publishing. Only fail for other error codes. - if (errorCode === "E403") { - info(`Token verification skipped: token doesn't have permission to list tokens (${errorCode})`); - } else { - throw new Error(m ? `Auth token for '${registry}' returned error code ${errorCode}` : error); - } - } - } else { - info("Using yarn for authentication (no .npmrc found)"); - - const whoamiArgs = ["npm", "whoami", "--publish"]; - const whoami = spawnSync("yarn", whoamiArgs, spawnOptions); - if (whoami.status !== 0) { - const errorOutput = - whoami.stderr.toString().trim() || - whoami.stdout.toString().trim() || - 'No error message available'; - - // Provide more context about the yarn authentication failure - throw new Error(`Yarn authentication failed (exit code ${whoami.status}): ${errorOutput}`); - } - - // Skip token listing for yarn since it doesn't support npm token commands - // The whoami check above is sufficient to verify authentication - info("Skipping token list check when using yarn (not required for publishing)"); - } -} - -/** - * Returns a numerical value for a given version string. - * @param {string} version - * @returns {number} - */ -function versionToNumber(version) { - const [major, minor] = version.split("-")[0].split("."); - return Number(major) * 1000 + Number(minor); -} - -/** - * 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(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 - // method as fallback. - const { stdout } = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"]); - return stdout.toString().trim(); -} - -/** - * Returns the latest published version of `react-native-macos` from npm. - * @param {"latest" | "next"} tag - * @returns {number} - */ -function getPublishedVersion(tag) { - const { stdout } = spawnSync("npm", ["view", `react-native-macos@${tag}`, "version"]); - return versionToNumber(stdout.toString().trim()); -} - -/** - * Returns the npm tag and prerelease identifier for the specified branch. - * - * @privateRemarks - * Note that the current implementation treats minor versions as major. If - * upstream ever decides to change the versioning scheme, we will need to make - * changes accordingly. - * - * @param {string} branch - * @param {Options} options - * @param {typeof info} log - * @returns {TagInfo} - */ -function getTagForStableBranch(branch, { tag }, log) { - if (!isStableBranch(branch)) { - throw new Error("Expected a stable branch"); - } - - const latestVersion = getPublishedVersion("latest"); - const currentVersion = versionToNumber(branch); - - log(`${RNMACOS_LATEST}: ${latestVersion}`); - log(`Current version: ${currentVersion}`); - - // Patching latest version - if (currentVersion === latestVersion) { - const npmTag = "latest"; - log(`Expected npm tag: ${npmTag}`); - return { npmTag }; - } - - // Demoting or patching an older stable version - if (currentVersion < latestVersion) { - const npmTag = "v" + branch; - log(`Expected npm tag: ${npmTag}`); - // If we're demoting a branch, we will need to create a new tag. This will - // make Nx trip if we don't specify a fallback. In all other scenarios, the - // tags should exist and therefore prefer it to fail. - return { npmTag, isNewTag: true }; - } - - // Publishing a new latest version - if (tag === "latest") { - log(`Expected npm tag: ${tag}`); - return { npmTag: tag }; - } - - // Publishing a release candidate - const nextVersion = getPublishedVersion("next"); - log(`${RNMACOS_NEXT}: ${nextVersion}`); - log(`Expected npm tag: ${NPM_TAG_NEXT}`); - - if (currentVersion < nextVersion) { - throw new Error(`Current version cannot be a release candidate because it is too old: ${currentVersion} < ${nextVersion}`); - } - - return { npmTag: NPM_TAG_NEXT, prerelease: "rc" }; -} - -/** - * 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 }, options) { - /** @type {string[]} */ - const errors = []; - - const { defaultBase, release } = config; - - // `defaultBase` determines what we diff against when looking for tags or - // released version and must therefore be set to either the main branch or one - // of the stable branches. - if (currentBranch !== defaultBase) { - errors.push(`'defaultBase' must be set to '${currentBranch}'`); - config.defaultBase = currentBranch; - } - - // Determines whether we need to add "nightly" or "rc" to the version string. - const { versionActionsOptions = {} } = release.version; - if (versionActionsOptions.preid !== prerelease) { - if (prerelease) { - errors.push(`'release.version.versionActionsOptions.preid' must be set to '${prerelease}'`); - versionActionsOptions.preid = prerelease; - } else { - errors.push(`'release.version.versionActionsOptions.preid' must be removed`); - versionActionsOptions.preid = undefined; - } - } - - // What the published version should be tagged as e.g., "latest" or "nightly". - const currentVersionResolverMetadata = /** @type {{ tag?: string }} */ (versionActionsOptions.currentVersionResolverMetadata || {}); - if (currentVersionResolverMetadata.tag !== tag) { - errors.push(`'release.version.versionActionsOptions.currentVersionResolverMetadata.tag' must be set to '${tag}'`); - versionActionsOptions.currentVersionResolverMetadata ??= {}; - /** @type {any} */ (versionActionsOptions.currentVersionResolverMetadata).tag = tag; - } - - // If we're demoting a branch, we will need to create a new tag. This will - // make Nx trip if we don't specify a fallback. In all other scenarios, the - // tags should exist and therefore prefer it to fail. - if (isNewTag) { - if (versionActionsOptions.fallbackCurrentVersionResolver !== "disk") { - errors.push("'release.version.versionActionsOptions.fallbackCurrentVersionResolver' must be set to 'disk'"); - versionActionsOptions.fallbackCurrentVersionResolver = "disk"; - } - } else if (typeof versionActionsOptions.fallbackCurrentVersionResolver === "string") { - errors.push("'release.version.versionActionsOptions.fallbackCurrentVersionResolver' must be removed"); - versionActionsOptions.fallbackCurrentVersionResolver = undefined; - } - - if (errors.length > 0) { - errors.forEach(error); - throw new Error("Nx Release is not correctly configured for the current branch"); - } - - if (options["skip-auth"]) { - info("Skipped npm auth validation"); - } else { - verifyNpmAuth(); - } - - // Don't enable publishing in PRs - if (!getTargetBranch()) { - enablePublishingOnAzurePipelines(); - } -} - -/** - * @param {Options} options - * @returns {number} - */ -function main(options) { - const branch = getCurrentBranch(options); - if (!branch) { - error("Could not get current branch"); - return 1; - } - - const logger = options.verbose ? info : () => undefined; - - const config = loadNxConfig(NX_CONFIG_FILE); - try { - if (isMainBranch(branch)) { - 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, options); - } - } catch (e) { - if (options.update) { - const fd = fs.openSync(NX_CONFIG_FILE, "w"); - fs.writeSync(fd, JSON.stringify(config, undefined, 2)); - fs.writeSync(fd, "\n"); - fs.closeSync(fd) - } else { - error(`${e.message}`); - } - return 1; - } - - return 0; -} - -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, - }, - update: { - type: "boolean", - default: false, - }, - verbose: { - type: "boolean", - default: false, - }, - }, - strict: true, -}); - -process.exitCode = main(values); diff --git a/.ado/scripts/release.mjs b/.ado/scripts/release.mjs new file mode 100644 index 00000000000000..8f9069e3ae54e6 --- /dev/null +++ b/.ado/scripts/release.mjs @@ -0,0 +1,760 @@ +// @ts-check +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as util from "node:util"; +import { ReleaseClient } from "nx/release/index.js"; + +/** + * Unified release script using Nx Release programmatic API + * + * This script consolidates: + * - prepublish-check.mjs (config validation, tag determination) + * - nx release CLI (versioning, changelog, GitHub release) + * - nx-release-version custom executor (artifact updates) + * - Manual npm publish (workaround for yarn 4 compatibility) + * - apply-additional-tags.mjs (setting extra dist-tags) + * + * Usage: + * node release.mjs --dry-run --verbose + * node release.mjs --token + */ + +const REPO_ROOT = path.resolve(import.meta.dirname, "..", ".."); + +const NPM_REGISTRY = "https://registry.npmjs.org/"; +const NPM_TAG_NEXT = "next"; +const NPM_TAG_NIGHTLY = "nightly"; + +const PACKAGES = [ + { name: "react-native-macos", path: "./packages/react-native" }, + { name: "@react-native-macos/virtualized-lists", path: "./packages/virtualized-lists" }, +]; + +// Files that get updated by updateReactNativeArtifacts +const ARTIFACT_FILES = [ + "packages/react-native/ReactAndroid/gradle.properties", + "packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt", + "packages/react-native/React/Base/RCTVersion.m", + "packages/react-native/ReactCommon/cxxreact/ReactNativeVersion.h", + "packages/react-native/Libraries/Core/ReactNativeVersion.js", +]; + +/** + * @typedef {{ + * "dry-run"?: boolean; + * "mock-branch"?: string; + * "skip-auth"?: boolean; + * tag?: string; + * token?: string; + * verbose?: boolean; + * }} Options; + * + * @typedef {{ + * npmTags: string[]; + * prerelease?: string; + * }} TagInfo; + */ + +// ============================================================================ +// Logging utilities +// ============================================================================ + +/** + * @param {string} message + */ +function error(message) { + console.error("❌", message); +} + +/** + * @param {string} message + */ +function info(message) { + console.log("ℹ️", message); +} + +/** + * @param {string} message + */ +function success(message) { + console.log("✅", message); +} + +// ============================================================================ +// Branch detection +// ============================================================================ + +/** + * Returns whether the given branch is considered main branch. + * @param {string} branch + */ +function isMainBranch(branch) { + return branch === "main"; +} + +/** + * Returns whether the given branch is considered a stable branch. + * @param {string} branch + */ +function isStableBranch(branch) { + return /^\d+\.\d+-stable$/.test(branch); +} + +/** + * Returns the target branch name in a PR, or undefined if not in a PR. + * @returns {string | undefined} + */ +function getTargetBranch() { + // Azure Pipelines + if (process.env["TF_BUILD"] === "True") { + const targetBranch = process.env["SYSTEM_PULLREQUEST_TARGETBRANCH"]; + return targetBranch?.replace(/^refs\/heads\//, ""); + } + + // GitHub Actions + if (process.env["GITHUB_ACTIONS"] === "true") { + return process.env["GITHUB_BASE_REF"]; + } + + return undefined; +} + +/** + * Returns the current branch name. In a PR, returns the target branch. + * @param {Options} options + * @returns {string} + */ +function getCurrentBranch(options) { + const targetBranch = getTargetBranch(); + if (targetBranch) { + return targetBranch; + } + + // Azure DevOps Pipelines + if (process.env["TF_BUILD"] === "True") { + const sourceBranch = process.env["BUILD_SOURCEBRANCHNAME"]; + if (sourceBranch) { + return sourceBranch.replace(/^refs\/heads\//, ""); + } + } + + // GitHub Actions + if (process.env["GITHUB_ACTIONS"] === "true") { + const headRef = process.env["GITHUB_HEAD_REF"]; + if (headRef) { + return headRef; + } + const ref = process.env["GITHUB_REF"]; + if (ref) { + return ref.replace(/^refs\/heads\//, ""); + } + } + + if (options["mock-branch"]) { + return options["mock-branch"]; + } + + const { stdout } = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"]); + return stdout.toString().trim(); +} + +// ============================================================================ +// Version utilities +// ============================================================================ + +/** + * Converts version string to a number for comparison. + * @param {string} version + * @returns {number} + */ +function versionToNumber(version) { + const [major, minor] = version.split("-")[0].split("."); + return Number(major) * 1000 + Number(minor); +} + +/** + * Returns the published version of react-native-macos for a given tag. + * @param {"latest" | "next"} tag + * @returns {number} + */ +function getPublishedVersion(tag) { + const { stdout } = spawnSync("npm", ["view", `react-native-macos@${tag}`, "version"]); + return versionToNumber(stdout.toString().trim()); +} + +// ============================================================================ +// React Native artifact updates +// ============================================================================ + +/** + * Updates React Native version artifacts (native files, gradle, etc.) + * This replaces the custom nx-release-version executor. + * @param {string} version + * @param {boolean} dryRun + */ +async function updateReactNativeArtifacts(version, dryRun) { + info(`Updating React Native artifacts to version ${version}...`); + + if (dryRun) { + console.log(" [dry-run] Would update the following files:"); + for (const file of ARTIFACT_FILES) { + console.log(` - ${file}`); + } + return; + } + + // Import the artifact update function from the existing script + const { updateReactNativeArtifacts: doUpdate } = await import( + path.join(REPO_ROOT, "scripts", "releases", "set-rn-artifacts-version.js") + ); + + await doUpdate(version); + + // Create the .rnm-publish marker file + fs.writeFileSync(path.join(REPO_ROOT, ".rnm-publish"), ""); + + success("Updated React Native artifacts"); + console.table(ARTIFACT_FILES.map((file) => ({ file }))); +} + +/** + * Stages and commits artifact files to git. + * @param {string} version + * @param {boolean} dryRun + */ +function commitArtifactChanges(version, dryRun) { + const filesToCommit = [...ARTIFACT_FILES, ".rnm-publish"]; + + info("Staging artifact changes..."); + + if (dryRun) { + console.log(" [dry-run] Would stage and amend commit with:"); + for (const file of filesToCommit) { + console.log(` - ${file}`); + } + return; + } + + // Stage the artifact files + const addResult = spawnSync("git", ["add", ...filesToCommit], { + stdio: "inherit", + cwd: REPO_ROOT, + }); + + if (addResult.status !== 0) { + throw new Error("Failed to stage artifact files"); + } + + // Amend the previous commit (which was created by Nx for version bumps) + const commitResult = spawnSync( + "git", + ["commit", "--amend", "--no-edit", "--no-verify"], + { stdio: "inherit", cwd: REPO_ROOT } + ); + + if (commitResult.status !== 0) { + throw new Error("Failed to amend commit with artifact changes"); + } + + success("Committed artifact changes"); +} + +// ============================================================================ +// Tag determination +// ============================================================================ + +/** + * @typedef {"NIGHTLY" | "PATCH_LATEST" | "PATCH_OLD" | "PROMOTE_TO_LATEST" | "RELEASE_CANDIDATE" | "NOT_RELEASE_BRANCH"} ReleaseState + */ + +/** + * @typedef {{ + * state: ReleaseState; + * currentVersion: number; + * latestVersion: number; + * nextVersion: number; + * }} ReleaseStateInfo + */ + +/** + * Determines the release state based on branch, versions, and options + * @param {string} branch + * @param {Options} options + * @returns {ReleaseStateInfo} + */ +function getReleaseState(branch, options) { + if (isMainBranch(branch)) { + return { state: "NIGHTLY", currentVersion: 0, latestVersion: 0, nextVersion: 0 }; + } + + if (!isStableBranch(branch)) { + return { state: "NOT_RELEASE_BRANCH", currentVersion: 0, latestVersion: 0, nextVersion: 0 }; + } + + const latestVersion = getPublishedVersion("latest"); + const nextVersion = getPublishedVersion("next"); + const currentVersion = versionToNumber(branch); + + /** @type {ReleaseState} */ + let state; + if (currentVersion === latestVersion) { + state = "PATCH_LATEST"; + } else if (currentVersion < latestVersion) { + state = "PATCH_OLD"; + } else if (options.tag === "latest") { + state = "PROMOTE_TO_LATEST"; + } else { + state = "RELEASE_CANDIDATE"; + } + + return { state, currentVersion, latestVersion, nextVersion }; +} + +/** + * Gets npm tags based on release state + * @param {string} branch + * @param {Options} options + * @param {typeof info} log + * @returns {TagInfo | null} + */ +function getTagInfo(branch, options, log) { + const { state, currentVersion, latestVersion, nextVersion } = getReleaseState( + branch, + options + ); + + log(`react-native-macos@latest: ${latestVersion}`); + log(`react-native-macos@next: ${nextVersion}`); + log(`Current version: ${currentVersion}`); + log(`Release state: ${state}`); + + switch (state) { + case "NIGHTLY": + log(`Expected npm tags: ${NPM_TAG_NIGHTLY}`); + return { npmTags: [NPM_TAG_NIGHTLY], prerelease: NPM_TAG_NIGHTLY }; + + case "PATCH_LATEST": { + const versionTag = "v" + branch; + log(`Expected npm tags: latest, ${versionTag}`); + return { npmTags: ["latest", versionTag] }; + } + + case "PATCH_OLD": { + const npmTag = "v" + branch; + log(`Expected npm tags: ${npmTag}`); + return { npmTags: [npmTag] }; + } + + case "PROMOTE_TO_LATEST": { + const versionTag = "v" + branch; + const npmTags = ["latest", versionTag]; + if (currentVersion > nextVersion) { + npmTags.push(NPM_TAG_NEXT); + } + log(`Expected npm tags: ${npmTags.join(", ")}`); + return { npmTags }; + } + + case "RELEASE_CANDIDATE": + if (currentVersion < nextVersion) { + throw new Error( + `Current version cannot be a release candidate because it is too old: ${currentVersion} < ${nextVersion}` + ); + } + log(`Expected npm tags: ${NPM_TAG_NEXT}`); + return { npmTags: [NPM_TAG_NEXT], prerelease: "rc" }; + + case "NOT_RELEASE_BRANCH": + default: + return null; + } +} + +// ============================================================================ +// npm auth verification +// ============================================================================ + +/** + * Verifies npm authentication. + * @param {string} registry + */ +function verifyNpmAuth(registry = NPM_REGISTRY) { + const spawnOptions = /** @type {const} */ ({ + stdio: "pipe", + shell: true, + }); + + const npmErrorRegex = /npm error code (\w+)/; + + info("Verifying npm authentication..."); + const whoami = spawnSync("npm", ["whoami", "--registry", registry], spawnOptions); + if (whoami.status !== 0) { + const errorOutput = whoami.stderr.toString(); + const m = errorOutput.match(npmErrorRegex); + const errorCode = m?.[1]; + switch (errorCode) { + 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(errorOutput); + } + } + success("npm authentication verified"); +} + +// ============================================================================ +// Publishing +// ============================================================================ + +/** + * Publishes packages to npm. + * @param {string} tag + * @param {string} token + * @param {boolean} dryRun + */ +function publishPackages(tag, token, dryRun) { + for (const { name, path } of PACKAGES) { + info(`Publishing ${name} with tag '${tag}'...`); + + if (dryRun) { + console.log(` [dry-run] npm publish ${path} --tag ${tag}`); + } else { + const result = spawnSync( + "npm", + [ + "publish", + path, + "--tag", + tag, + "--registry", + NPM_REGISTRY, + `--//registry.npmjs.org/:_authToken=${token}`, + ], + { stdio: "inherit", shell: true } + ); + + if (result.status !== 0) { + throw new Error(`Failed to publish ${name}`); + } + + success(`Published ${name}@${tag}`); + } + } +} + +/** + * Applies additional dist-tags to packages. + * @param {string[]} tags + * @param {string} version + * @param {string} token + * @param {boolean} dryRun + */ +function applyAdditionalTags(tags, version, token, dryRun) { + if (tags.length === 0) { + info("No additional tags to apply"); + return; + } + + for (const tag of tags) { + for (const { name } of PACKAGES) { + info(`Adding dist-tag '${tag}' to ${name}@${version}...`); + + if (dryRun) { + console.log(` [dry-run] npm dist-tag add ${name}@${version} ${tag}`); + } else { + const result = spawnSync( + "npm", + [ + "dist-tag", + "add", + `${name}@${version}`, + tag, + "--registry", + NPM_REGISTRY, + `--//registry.npmjs.org/:_authToken=${token}`, + ], + { stdio: "inherit", shell: true } + ); + + if (result.status !== 0) { + throw new Error(`Failed to add dist-tag '${tag}' to ${name}@${version}`); + } + + success(`Added dist-tag '${tag}' to ${name}@${version}`); + } + } + } +} + +// ============================================================================ +// CI output helpers +// ============================================================================ + +/** + * Exports a variable for Azure Pipelines. + * @param {string} name + * @param {string} value + */ +function setAzurePipelinesVariable(name, value) { + console.log(`##vso[task.setvariable variable=${name}]${value}`); +} + +/** + * Exports a variable for GitHub Actions. + * @param {string} name + * @param {string} value + */ +function setGitHubActionsOutput(name, value) { + if (process.env["GITHUB_OUTPUT"]) { + fs.appendFileSync(process.env["GITHUB_OUTPUT"], `${name}=${value}\n`); + } +} + +// ============================================================================ +// Main +// ============================================================================ + +/** + * @param {Options} options + * @returns {Promise} + */ +async function main(options) { + const dryRun = options["dry-run"] ?? false; + const verbose = options.verbose ?? false; + const log = verbose ? info : () => {}; + + // Determine branch and tag info + const branch = getCurrentBranch(options); + if (!branch) { + error("Could not determine current branch"); + return 1; + } + + info(`Branch: ${branch}`); + + const tagInfo = getTagInfo(branch, options, log); + if (!tagInfo) { + info(`Branch '${branch}' is not a release branch, skipping release`); + return 0; + } + + const [primaryTag, ...additionalTags] = tagInfo.npmTags; + + info(`Primary npm tag: ${primaryTag}`); + if (additionalTags.length > 0) { + info(`Additional npm tags: ${additionalTags.join(", ")}`); + } + + // Verify npm auth (unless skipped or dry-run) + if (!dryRun && !options["skip-auth"]) { + verifyNpmAuth(); + } else if (options["skip-auth"]) { + info("Skipped npm auth validation"); + } + + // Create Nx Release client with full configuration + // All release config is here - nx.json is ignored for release + const releaseClient = new ReleaseClient( + { + changelog: { + projectChangelogs: { + file: false, + createRelease: "github", + }, + workspaceChangelog: false, + }, + projects: ["packages/react-native", "packages/virtualized-lists"], + versionPlans: true, + version: { + versionActionsOptions: { + currentVersionResolver: "registry", + currentVersionResolverMetadata: { + tag: primaryTag, + }, + ...(tagInfo.prerelease && { preid: tagInfo.prerelease }), + }, + }, + }, + ); + + // Phase 1: Version + info("Running version phase..."); + const { workspaceVersion, projectsVersionData } = + await releaseClient.releaseVersion({ + dryRun, + verbose, + }); + + // Check if there are actual version changes + const hasVersionChanges = Object.values(projectsVersionData).some( + (data) => data.newVersion && data.newVersion !== data.currentVersion + ); + + if (!hasVersionChanges) { + info("No version changes detected"); + + // Still show what would happen if we were to release + if (dryRun) { + info("=== Would publish with the following tags ==="); + console.log(` Primary tag: ${primaryTag}`); + if (additionalTags.length > 0) { + console.log(` Additional tags: ${additionalTags.join(", ")}`); + } + for (const { name } of PACKAGES) { + console.log(` ${name} -> ${primaryTag}`); + for (const tag of additionalTags) { + console.log(` ${name} -> ${tag}`); + } + } + } + + return 0; + } + + // Get the new version from the version data + const newVersion = + workspaceVersion || + Object.values(projectsVersionData)[0]?.newVersion || + ""; + + info(`New version: ${newVersion}`); + + // Phase 1.5: Update React Native artifacts + // This replaces the custom nx-release-version executor + await updateReactNativeArtifacts(newVersion, dryRun); + + // Commit the artifact changes (amend the version commit) + if (!dryRun) { + commitArtifactChanges(newVersion, dryRun); + } + + // Phase 2: Changelog (creates GitHub release) + info("Running changelog phase..."); + try { + await releaseClient.releaseChangelog({ + versionData: projectsVersionData, + version: workspaceVersion, + dryRun, + verbose, + }); + } catch (e) { + // In dry-run mode, changelog may fail if there are no actual changes to commit + const err = /** @type {Error} */ (e); + if (dryRun && err.message?.includes("No changed files to commit")) { + info("Skipping changelog in dry-run (no actual file changes)"); + } else { + throw e; + } + } + + // Phase 3: Publish (using our custom npm publish to work around yarn 4 issues) + if (!options.token && !dryRun) { + error("npm auth token is required for publishing (use --token)"); + return 1; + } + + info("Running publish phase..."); + publishPackages(primaryTag, options.token || "", dryRun); + + // Apply additional dist-tags + if (additionalTags.length > 0) { + info("Applying additional dist-tags..."); + applyAdditionalTags(additionalTags, newVersion, options.token || "", dryRun); + } + + // Export CI variables + if (!dryRun) { + setAzurePipelinesVariable("publish_react_native_macos", "1"); + if (additionalTags.length > 0) { + const tagsValue = additionalTags.join(","); + setAzurePipelinesVariable("additionalTags", tagsValue); + setGitHubActionsOutput("additionalTags", tagsValue); + } + } + + success("Release completed successfully"); + return 0; +} + +// ============================================================================ +// CLI +// ============================================================================ + +const { values } = util.parseArgs({ + args: process.argv.slice(2), + options: { + "dry-run": { + type: "boolean", + default: false, + }, + help: { + type: "boolean", + short: "h", + default: false, + }, + "mock-branch": { + type: "string", + }, + "skip-auth": { + type: "boolean", + default: false, + }, + tag: { + type: "string", + default: NPM_TAG_NEXT, + }, + token: { + type: "string", + }, + verbose: { + type: "boolean", + default: false, + }, + }, + strict: true, +}); + +if (values.help) { + console.log(` +Usage: node release.mjs [options] + +Unified release script for react-native-macos packages. + +Options: + --dry-run Run without publishing (default: false) + --mock-branch Override branch detection (for testing) + --skip-auth Skip npm auth verification + --tag Primary npm tag (default: ${NPM_TAG_NEXT}) + --token npm auth token + --verbose Enable verbose logging + -h, --help Show this help message + +Branch-based release tags: + main branch -> nightly + X.Y-stable branch -> latest, next (if state=latest) + -> next (if state=next) + -> rc, next (if state=rc) +`); + process.exit(0); +} + +main(values).then((code) => { + process.exit(code); +}).catch((e) => { + error(e.message); + process.exit(1); +}); + +// Export for testing +export { + isMainBranch, + isStableBranch, + versionToNumber, + getReleaseState, + getTagInfo, + NPM_TAG_NEXT, + NPM_TAG_NIGHTLY, +}; diff --git a/.github/workflows/microsoft-pr.yml b/.github/workflows/microsoft-pr.yml index ef918ae5c07ac1..a74e2b86173924 100644 --- a/.github/workflows/microsoft-pr.yml +++ b/.github/workflows/microsoft-pr.yml @@ -53,12 +53,6 @@ jobs: uses: ./.github/actions/microsoft-setup-toolchain 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" @@ -66,14 +60,9 @@ jobs: 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) + - name: Release (dry run) run: | - echo "Target branch: ${{ github.base_ref }}" - yarn nx release --dry-run --verbose + node .ado/scripts/release.mjs --dry-run --verbose yarn-constraints: name: "Check Yarn Constraints" diff --git a/nx.json b/nx.json index ac8c7e7b71cab3..4bcd46e4cfac66 100644 --- a/nx.json +++ b/nx.json @@ -5,27 +5,5 @@ "build": { "dependsOn": ["^build"] } - }, - "release": { - "changelog": { - "projectChangelogs": { - "file": false, - "createRelease": "github" - }, - "workspaceChangelog": false - }, - "projects": ["packages/react-native", "packages/virtualized-lists"], - "versionPlans": true, - "version": { - "versionActions": "@react-native-macos/nx-release-version", - "versionActionsOptions": { - "currentVersionResolver": "registry", - "currentVersionResolverMetadata": { - "tag": "next" - }, - "preid": "rc" - }, - "useLegacyVersioning": false - } } } diff --git a/packages/nx-release-version/README.md b/packages/nx-release-version/README.md deleted file mode 100644 index 540b1de7bc72e2..00000000000000 --- a/packages/nx-release-version/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# os/nx-release-version - -Nx (v21) Version Actions for React Native macOS releases. - -## Overview - -This package provides custom Version Actions for Nx 21's modern release system (`useLegacyVersioning: false`). It extends the built-in `JsVersionActions` to include React Native platform-specific artifact updates. - -## What it does - -When versioning the `react-native-macos` project, this package automatically: - -1. **Updates standard package.json files** (via the base `JsVersionActions`) -2. **Updates React Native platform artifacts**: - - `ReactAndroid/gradle.properties` - - `ReactNativeVersion.java` - - `RCTVersion.m` - - `ReactNativeVersion.h` - - `ReactNativeVersion.js` -3. **Creates a `.rnm-publish` marker file** to indicate successful versioning - diff --git a/packages/nx-release-version/index.js b/packages/nx-release-version/index.js deleted file mode 100644 index cf42ff13e8b5db..00000000000000 --- a/packages/nx-release-version/index.js +++ /dev/null @@ -1,112 +0,0 @@ -// @ts-check - -// @noflow -const {REPO_ROOT} = require('../../scripts/consts'); -const {afterAllProjectsVersioned: baseAfterAllProjectsVersioned, default: JsVersionActions} = require('@nx/js/src/release/version-actions'); -const fs = require('node:fs'); -const path = require('node:path'); - -/** - * @returns {Promise} - */ -async function runSetVersion() { - const rnmPkgJsonPath = path.join(REPO_ROOT, 'packages', 'react-native', 'package.json'); - const {updateReactNativeArtifacts} = require('../../scripts/releases/set-rn-artifacts-version'); - - const manifest = fs.readFileSync(rnmPkgJsonPath, {encoding: 'utf-8'}); - const {version} = JSON.parse(manifest); - - await updateReactNativeArtifacts(version); - - return [ - path.join( - REPO_ROOT, - 'packages', - 'react-native', - 'ReactAndroid', - 'gradle.properties', - ), - path.join( - REPO_ROOT, - 'packages', - 'react-native', - 'ReactAndroid', - 'src', - 'main', - 'java', - 'com', - 'facebook', - 'react', - 'modules', - 'systeminfo', - 'ReactNativeVersion.java', - ), - path.join(REPO_ROOT, - 'packages', - 'react-native', - 'React', - 'Base', - 'RCTVersion.m', - ), - path.join( - REPO_ROOT, - 'packages', - 'react-native', - 'ReactCommon', - 'cxxreact', - 'ReactNativeVersion.h', - ), - path.join( - REPO_ROOT, - 'packages', - 'react-native', - 'Libraries', - 'Core', - 'ReactNativeVersion.js', - ), - ]; -} - -/** - * Custom afterAllProjectsVersioned hook for React Native macOS - * Updates React Native artifacts after all projects have been versioned - * @param {string} cwd - Current working directory - * @param {object} opts - Options object containing versioning information - * @returns {Promise<{changedFiles: string[], deletedFiles: string[]}>} - */ -const afterAllProjectsVersioned = async (cwd, opts) => { - const baseResult = await baseAfterAllProjectsVersioned(cwd, opts); - - const changedFiles = [...baseResult.changedFiles]; - const deletedFiles = [...baseResult.deletedFiles]; - - // Only update React Native artifacts if versioning actually happened - if (changedFiles.length > 0) { - try { - // Create the .rnm-publish file to indicate versioning has occurred - fs.writeFileSync(path.join(REPO_ROOT, '.rnm-publish'), ''); - - // Update React Native artifacts - const versionedFiles = await runSetVersion(); - - // Add the versioned files to changed files - changedFiles.push(...versionedFiles); - - console.log('✅ Updated React Native artifacts'); - console.table(versionedFiles.map(file => path.relative(REPO_ROOT, file))); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`❌ Failed to update React Native artifacts: ${errorMessage}`); - throw error; - } - } - - return { - changedFiles, - deletedFiles, - }; -}; - -module.exports = JsVersionActions; -module.exports.default = JsVersionActions; -module.exports.afterAllProjectsVersioned = afterAllProjectsVersioned; diff --git a/packages/nx-release-version/package.json b/packages/nx-release-version/package.json deleted file mode 100644 index 314c8388a7d598..00000000000000 --- a/packages/nx-release-version/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@react-native-macos/nx-release-version", - "version": "0.81.0-rc0", - "description": "Nx Release Version Actions for React Native macOS", - "homepage": "https://github.com/microsoft/react-native-macos/tree/HEAD/packages/nx-release-version#readme", - "license": "MIT", - "files": [ - "index.js" - ], - "main": "index.js", - "repository": { - "type": "git", - "url": "git+https://github.com/microsoft/react-native-macos.git", - "directory": "packages/nx-release-version" - }, - "dependencies": { - "@nx/js": "^21.4.1" - }, - "devDependencies": { - "@rnx-kit/tsconfig": "^2.0.0", - "typescript": "^5.6.3" - }, - "engines": { - "node": ">=18" - } -} diff --git a/packages/nx-release-version/tsconfig.json b/packages/nx-release-version/tsconfig.json deleted file mode 100644 index 8b08be4b864bb6..00000000000000 --- a/packages/nx-release-version/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "@rnx-kit/tsconfig/tsconfig.json", - "compilerOptions": { - "noEmit": true - }, - "include": ["index.js"] -} diff --git a/yarn.lock b/yarn.lock index 8f55b5b13b56e4..e2845612d70602 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3402,16 +3402,6 @@ __metadata: languageName: unknown linkType: soft -"@react-native-macos/nx-release-version@workspace:packages/nx-release-version": - version: 0.0.0-use.local - resolution: "@react-native-macos/nx-release-version@workspace:packages/nx-release-version" - dependencies: - "@nx/js": "npm:^21.4.1" - "@rnx-kit/tsconfig": "npm:^2.0.0" - typescript: "npm:^5.6.3" - languageName: unknown - linkType: soft - "@react-native-macos/virtualized-lists@npm:0.81.0-rc0, @react-native-macos/virtualized-lists@workspace:packages/virtualized-lists": version: 0.0.0-use.local resolution: "@react-native-macos/virtualized-lists@workspace:packages/virtualized-lists"