Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 33 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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<version>` branches, for example `release/v1.1` or `release/v1.1.0`, with `mode=rc`.

`rc` mode is intentionally restricted to `release/v<version>` 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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
275 changes: 275 additions & 0 deletions scripts/release/release.mjs
Original file line number Diff line number Diff line change
@@ -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<version> 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();
Loading