diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f61b8d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +on: + workflow_dispatch: + inputs: + mode: + description: Release mode to run + required: true + type: choice + options: + - stable + - rc + - canary + +permissions: + contents: write + id-token: write + +concurrency: + group: release-${{ github.event.inputs.mode }}-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + release: + name: Run release + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + RELEASE_MODE: ${{ github.event.inputs.mode }} + RELEASE_REF: ${{ github.ref_name }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_CONFIG_PROVENANCE: true + GIT_AUTHOR_NAME: actions-bot + GIT_AUTHOR_EMAIL: actions-bot@users.noreply.github.com + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Configure git identity + run: | + git config user.name "$GIT_AUTHOR_NAME" + git config user.email "$GIT_AUTHOR_EMAIL" + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10.6.1 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24.10.0 + cache: pnpm + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run release + run: pnpm release:run diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4fd006..bf11efa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,8 @@ This project uses GitHub Actions to run validation checks on your pull requests. Currently, releases are published by maintainers when they determine it's time to do so. Usually, there is at least one release per week as long as there are changes waiting to be published. +The release workflow and branch conventions are documented in [RELEASING.md](/RELEASING.md). + ## License By contributing to React Native Harness, you agree that your contributions will be licensed under its MIT license. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..c34578e --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,33 @@ +# Releasing + +Releases are run with `.github/workflows/release.yml`. + +The workflow always uses the branch it is dispatched from. There is no manual ref input. + +## Stable releases + +Run the workflow from `main` with `mode=stable`. + +This applies pending version plans, publishes packages with the default npm dist-tag, pushes the release commit and tag, and creates the GitHub release. + +## RC releases + +Run the workflow from `release/v` branches, for example `release/v1.1` or `release/v1.1.0`, with `mode=rc`. + +`rc` mode is intentionally restricted to `release/v` branches and will fail on `main`. + +RC releases consume version plans from `.nx/version-plans`, publish packages with the `rc` dist-tag, and create a prerelease on GitHub. + +## Canary releases + +Run the workflow from any branch with `mode=canary`. + +Canary releases publish a unique prerelease version for the current commit with the `canary` dist-tag. They do not create a commit, tag, or GitHub release. + +Canary releases do not consume or remove version plans. + +## Publishing auth + +Publishing is expected to use npm trusted publishing via GitHub Actions OIDC. + +No npm access token is required for the release workflow itself. diff --git a/package.json b/package.json index a618d46..8d6f2d2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "build:all": "nx run-many -t build", "lint:all": "nx run-many -t lint", "test:all": "nx run-many -t test", - "typecheck:all": "nx run-many -t typecheck" + "typecheck:all": "nx run-many -t typecheck", + "release:run": "node ./scripts/release/release.mjs" }, "private": true, "devDependencies": { diff --git a/scripts/release/release.mjs b/scripts/release/release.mjs new file mode 100644 index 0000000..8f2d48c --- /dev/null +++ b/scripts/release/release.mjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { ReleaseClient, release } from 'nx/release'; +import semver from 'semver'; + +const cwd = process.cwd(); +const mode = process.env.RELEASE_MODE; +const rawRefName = + process.env.RELEASE_REF || + process.env.GITHUB_REF_NAME || + runOutput('git', ['rev-parse', '--abbrev-ref', 'HEAD']); +const branch = normalizeRef(rawRefName); +const remote = process.env.GIT_REMOTE ?? 'origin'; +const stableBranch = process.env.RELEASE_STABLE_BRANCH ?? 'main'; +const representativePackagePath = + process.env.RELEASE_VERSION_PACKAGE ?? + 'packages/react-native-harness/package.json'; +const versionPlansDir = path.join(cwd, '.nx', 'version-plans'); + +if (!['stable', 'rc', 'canary'].includes(mode)) { + fail('RELEASE_MODE must be set to stable, rc, or canary'); +} + +function normalizeRef(ref) { + return ref.replace(/^refs\/heads\//, '').trim(); +} + +function isReleaseBranch(ref) { + return /^release\/v\d+\.\d+(?:\.\d+)?$/.test(ref); +} + +function run(command, args, options = {}) { + execFileSync(command, args, { + stdio: 'inherit', + ...options, + }); +} + +function runOutput(command, args, options = {}) { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + ...options, + }).trim(); +} + +function commandSucceeds(command, args) { + try { + execFileSync(command, args, { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function fail(message) { + console.error(`Error: ${message}`); + process.exit(1); +} + +function ensureCleanWorktree() { + const status = runOutput('git', ['status', '--porcelain']); + + if (status.length > 0) { + fail('working tree must be clean before running release automation'); + } +} + +function ensureRemoteBranch() { + if ( + !commandSucceeds('git', [ + 'ls-remote', + '--exit-code', + '--heads', + remote, + branch, + ]) + ) { + fail(`branch ${branch} must exist on ${remote}`); + } +} + +function ensureGithubToken() { + if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) { + fail('GITHUB_TOKEN or GH_TOKEN must be set'); + } +} + +function readVersion() { + const filePath = path.join(cwd, representativePackagePath); + const packageJson = JSON.parse(readFileSync(filePath, 'utf8')); + + if ( + typeof packageJson.version !== 'string' || + packageJson.version.length === 0 + ) { + fail(`could not read version from ${representativePackagePath}`); + } + + return packageJson.version; +} + +function getVersionPlans() { + if (!existsSync(versionPlansDir)) { + return []; + } + + return readdirSync(versionPlansDir) + .filter((fileName) => fileName.endsWith('.md')) + .map((fileName) => path.join(versionPlansDir, fileName)); +} + +function convertVersionPlanReleaseType(releaseType) { + switch (releaseType) { + case 'major': + case 'feat!': + case 'fix!': + return 'premajor'; + case 'minor': + case 'feat': + return 'preminor'; + case 'patch': + case 'fix': + return 'prepatch'; + case 'premajor': + case 'preminor': + case 'prepatch': + case 'prerelease': + return releaseType; + default: + return releaseType; + } +} + +function assertPublishSucceeded(results) { + const failedProjects = Object.entries(results) + .filter(([, result]) => result.code !== 0) + .map(([project]) => project); + + if (failedProjects.length > 0) { + fail(`publishing failed for: ${failedProjects.join(', ')}`); + } +} + +function rewriteVersionPlanForRc(content) { + return content.replace(/^---\n([\s\S]*?)\n---/m, (match, frontMatter) => { + const rewrittenFrontMatter = frontMatter.replace( + /^(\s*[^:\n]+:\s*)(\S+)\s*$/gm, + (_, prefix, releaseType) => + `${prefix}${convertVersionPlanReleaseType(releaseType)}` + ); + + return `---\n${rewrittenFrontMatter}\n---`; + }); +} + +async function runRcReleaseWithVersionPlans() { + const versionPlans = getVersionPlans(); + + if (versionPlans.length === 0) { + fail('rc releases require at least one version plan in .nx/version-plans'); + } + + const originalContents = new Map( + versionPlans.map((filePath) => [filePath, readFileSync(filePath, 'utf8')]) + ); + let releaseSucceeded = false; + + try { + for (const [filePath, content] of originalContents) { + writeFileSync(filePath, rewriteVersionPlanForRc(content)); + } + + await release({ + yes: true, + skipPublish: false, + preid: 'rc', + }); + releaseSucceeded = true; + } finally { + if (!releaseSucceeded) { + for (const [filePath, content] of originalContents) { + writeFileSync(filePath, content); + } + } + } +} + +function getCanaryVersion(currentVersion) { + const parsedCurrent = semver.parse(currentVersion); + + if (!parsedCurrent) { + fail(`current version ${currentVersion} is not valid semver`); + } + + const stableCurrent = `${parsedCurrent.major}.${parsedCurrent.minor}.${parsedCurrent.patch}`; + const timestamp = new Date() + .toISOString() + .replace(/[-:TZ.]/g, '') + .slice(0, 14); + const shortSha = runOutput('git', ['rev-parse', '--short', 'HEAD']); + + return `${stableCurrent}-canary.${timestamp}.${shortSha}`; +} + +async function runStableRelease() { + if (branch !== stableBranch) { + fail(`stable releases must run from ${stableBranch}, received ${branch}`); + } + + ensureRemoteBranch(); + ensureGithubToken(); + + await release({ + yes: true, + skipPublish: false, + }); +} + +async function runRcRelease() { + if (branch === stableBranch) { + fail(`rc releases must not run from ${stableBranch}`); + } + + if (!isReleaseBranch(branch)) { + fail( + `rc releases must run from a release/v branch, received ${branch}` + ); + } + + ensureRemoteBranch(); + ensureGithubToken(); + + await runRcReleaseWithVersionPlans(); +} + +async function runCanaryRelease() { + const currentVersion = readVersion(); + const canaryVersion = getCanaryVersion(currentVersion); + const releaseClient = new ReleaseClient({ versionPlans: false }); + const { projectsVersionData, releaseGraph } = + await releaseClient.releaseVersion({ + specifier: canaryVersion, + gitCommit: false, + gitTag: false, + stageChanges: false, + }); + const publishResults = await releaseClient.releasePublish({ + releaseGraph, + versionData: projectsVersionData, + tag: 'canary', + access: 'public', + outputStyle: 'static', + }); + + assertPublishSucceeded(publishResults); +} + +ensureCleanWorktree(); + +if (mode === 'stable') { + await runStableRelease(); + process.exit(0); +} + +if (mode === 'rc') { + await runRcRelease(); + process.exit(0); +} + +await runCanaryRelease();