From 6140c5994a5d758a36b63b46d6f07765425ce7e2 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Mar 2026 21:41:55 -0700 Subject: [PATCH 1/2] chore: update cicd for cli releases --- .github/workflows/release-cli.yml | 2 + apps/cli/.releaserc.cjs | 2 + cicd.md | 34 +++++++ package.json | 8 +- scripts/manual-clean-tag.sh | 6 +- scripts/manual-tag.sh | 17 +++- scripts/release-local-cli.mjs | 16 ++++ scripts/release-local-stable.mjs | 135 +++++++++++++++++++++++++++ scripts/release-local-superdoc.mjs | 72 ++------------ scripts/release-local.mjs | 145 +++++++++++++++++++++++++++++ 10 files changed, 362 insertions(+), 75 deletions(-) create mode 100644 scripts/release-local-cli.mjs create mode 100644 scripts/release-local-stable.mjs create mode 100644 scripts/release-local.mjs diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 36adbec9e1..de1d7efd2d 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -9,6 +9,8 @@ on: - main - stable paths: + # Keep in sync with apps/cli/.releaserc.cjs includePaths (patch-commit-filter). + # Workflow paths trigger CI; includePaths control semantic-release commit analysis. - 'apps/cli/**' - 'packages/document-api/**' - 'packages/superdoc/**' diff --git a/apps/cli/.releaserc.cjs b/apps/cli/.releaserc.cjs index 3d78c2b445..e1943dd0af 100644 --- a/apps/cli/.releaserc.cjs +++ b/apps/cli/.releaserc.cjs @@ -5,6 +5,8 @@ * commits touching any of them. This shared helper patches git-log-parser to * expand path coverage. It REPLACES semantic-release-commit-filter — do not * use both (the filter restricts to CWD, which undoes the expansion). + * + * Keep in sync with .github/workflows/release-cli.yml paths: trigger. */ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'apps/cli', diff --git a/cicd.md b/cicd.md index a32ee7a36f..696920fed7 100644 --- a/cicd.md +++ b/cicd.md @@ -160,6 +160,40 @@ Version bumps are automatic based on commit messages: > ℹ️ The legacy scoped package `@harbour-enterprises/superdoc` is mirrored with the same version and dist-tag for every release channel above. +## CLI Release + +The CLI (`apps/cli`) has its own semantic-release pipeline with tag format `cli-v${version}`. + +### Automated (CI) + +| Trigger | Channel | Tag example | +|---------|---------|-------------| +| Push to `main` | `@next` | `cli-v0.3.0-next.1` | +| Push to `stable` | `@latest` | `cli-v0.3.0` | + +The workflow is `.github/workflows/release-cli.yml`. It analyzes commits across multiple packages (see `apps/cli/.releaserc.cjs` for the `includePaths` list). + +### Local Release + +| Command | What it does | +|---------|-------------| +| `pnpm run release:local` | Releases **superdoc then CLI** in sequence on `stable` | +| `pnpm run release:local:superdoc` | Releases superdoc only | +| `pnpm run release:local:cli` | Releases CLI only | + +All accept `-- --dry-run` to preview without publishing. The combined orchestrator (`release:local`) enforces a `stable` branch guard (override with `--branch=`). + +`@semantic-release/git` automatically pushes version commits and tags when releasing on the `stable` branch. This is existing behavior for both superdoc and CLI. + +### Raw Platform Publish (bypass semantic-release) + +| Command | What it does | +|---------|-------------| +| `pnpm run cli:publish:raw` | Builds and publishes platform binaries directly | +| `pnpm run cli:publish:raw:dry` | Dry-run of the above | + +These skip semantic-release entirely — useful for re-publishing a failed platform upload. + ## Workflow Scenarios ### Scenario 1: Feature Development diff --git a/package.json b/package.json index a4053abba5..c91988ee93 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ "pack": "pnpm run build:superdoc && pnpm run type-check && pnpm --prefix ./packages/superdoc run pack", "release": "pnpm run build:superdoc && pnpm run type-check && pnpm --prefix packages/superdoc exec semantic-release", "release:dry-run": "pnpm run build:superdoc && pnpm run type-check && pnpm --prefix packages/superdoc exec semantic-release --dry-run", - "release:local": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-superdoc.mjs", + "release:local": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-stable.mjs", + "release:local:superdoc": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-superdoc.mjs", + "release:local:cli": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-cli.mjs", "prepare": "if [ -z \"$CI\" ]; then lefthook install; fi", "test:layout": "bun scripts/test-layout.mjs", "test:visual": "bun scripts/test-visual.mjs", @@ -76,8 +78,8 @@ "docapi:sync:check": "pnpm run docapi:sync && pnpm run docapi:check", "test:cli": "pnpm --prefix apps/cli run test", "cli:prepare": "pnpm run test:cli && pnpm --prefix apps/cli run build:prepublish", - "cli:release": "pnpm run cli:prepare && pnpm --prefix apps/cli run publish:platforms", - "cli:release:dry": "pnpm run cli:prepare && pnpm --prefix apps/cli run publish:platforms:dry", + "cli:publish:raw": "pnpm run cli:prepare && pnpm --prefix apps/cli run publish:platforms", + "cli:publish:raw:dry": "pnpm run cli:prepare && pnpm --prefix apps/cli run publish:platforms:dry", "cli:export-sdk-contract": "bun apps/cli/scripts/export-sdk-contract.ts", "docs:sync-engine": "pnpm exec tsx apps/docs/scripts/generate-sdk-overview.ts", "sdk:sync-version": "node packages/sdk/scripts/sync-sdk-version.mjs", diff --git a/scripts/manual-clean-tag.sh b/scripts/manual-clean-tag.sh index 205dfaac38..81cffc3648 100755 --- a/scripts/manual-clean-tag.sh +++ b/scripts/manual-clean-tag.sh @@ -3,6 +3,7 @@ set -euo pipefail # Usage: ./scripts/manual-clean-tag.sh # Example: ./scripts/manual-clean-tag.sh v1.2.0-next.2 +# ./scripts/manual-clean-tag.sh cli-v0.3.0 VERSION="${1:-}" @@ -15,8 +16,9 @@ if [[ -z "$VERSION" ]]; then exit 1 fi -# Ensure version starts with 'v' -if [[ ! "$VERSION" =~ ^v ]]; then +# Prepend 'v' only for bare numeric versions (e.g. 1.2.0 → v1.2.0). +# Prefixed tags like cli-v0.3.0 are left as-is. +if [[ "$VERSION" =~ ^[0-9] ]]; then VERSION="v$VERSION" fi diff --git a/scripts/manual-tag.sh b/scripts/manual-tag.sh index 26b84ffb62..caef04db2c 100755 --- a/scripts/manual-tag.sh +++ b/scripts/manual-tag.sh @@ -4,6 +4,7 @@ set -euo pipefail # Usage: ./scripts/manual-tag.sh # Example: ./scripts/manual-tag.sh v1.2.0-next.1 b4903188 # ./scripts/manual-tag.sh v1.2.0 HEAD +# ./scripts/manual-tag.sh cli-v0.3.0 HEAD VERSION="${1:-}" COMMIT="${2:-HEAD}" @@ -17,13 +18,21 @@ if [[ -z "$VERSION" ]]; then exit 1 fi -# Ensure version starts with 'v' -if [[ ! "$VERSION" =~ ^v ]]; then +# Prepend 'v' only for bare numeric versions (e.g. 1.2.0 → v1.2.0). +# Prefixed tags like cli-v0.3.0 are left as-is. +if [[ "$VERSION" =~ ^[0-9] ]]; then VERSION="v$VERSION" fi -# Extract version without 'v' prefix for semver parsing -VERSION_NUM="${VERSION#v}" +# Extract the numeric version after the tag-format 'v' for semver/channel parsing. +# Anchors on 'v' followed by a digit to skip prefixes like 'vscode-'. +# e.g. 'v1.2.0' → '1.2.0', 'cli-v0.3.0-next.1' → '0.3.0-next.1', +# 'vscode-v1.0.0' → '1.0.0' (not 'scode-v1.0.0'). +if [[ "$VERSION" =~ v([0-9].*)$ ]]; then + VERSION_NUM="${BASH_REMATCH[1]}" +else + VERSION_NUM="$VERSION" +fi # Determine channel from version if [[ "$VERSION_NUM" =~ -next\. ]]; then diff --git a/scripts/release-local-cli.mjs b/scripts/release-local-cli.mjs new file mode 100644 index 0000000000..d3edf75286 --- /dev/null +++ b/scripts/release-local-cli.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +/** + * Thin wrapper — releases the CLI package locally via semantic-release. + * See release-local.mjs for the reusable runner logic. + */ + +import { releasePackage } from './release-local.mjs'; + +try { + releasePackage({ packageCwd: 'apps/cli', tagPrefix: 'cli-v', extraArgs: process.argv.slice(2) }); +} catch (error) { + const message = error && typeof error.message === 'string' ? error.message : String(error); + console.error(message); + process.exitCode = 1; +} diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs new file mode 100644 index 0000000000..73c74aa5be --- /dev/null +++ b/scripts/release-local-stable.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node + +/** + * Combined stable orchestrator — releases superdoc then CLI in sequence. + * + * Usage: + * pnpm run release:local [-- --dry-run] + * node scripts/release-local-stable.mjs [--dry-run] [--branch=] + * + * Flags: + * --branch= Override the expected branch (default: stable) + * All other flags are forwarded to both semantic-release invocations. + */ + +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { listTags, pruneLocalOnlyForeignTags, runSemanticRelease } from './release-local.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..'); + +function getCurrentBranch() { + return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); +} + +// --------------------------------------------------------------------------- +// Parse own flags vs forwarded flags +// --------------------------------------------------------------------------- + +let expectedBranch = 'stable'; +const forwardedArgs = []; + +for (const arg of process.argv.slice(2)) { + if (arg.startsWith('--branch=')) { + expectedBranch = arg.slice('--branch='.length); + } else { + forwardedArgs.push(arg); + } +} + +// --------------------------------------------------------------------------- +// Branch guard +// --------------------------------------------------------------------------- + +const currentBranch = getCurrentBranch(); +if (currentBranch !== expectedBranch) { + console.error(`Expected branch ${expectedBranch} but on ${currentBranch}`); + console.error('Use --branch= to override.'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Release pipeline +// --------------------------------------------------------------------------- + +const packages = [ + { name: 'superdoc', packageCwd: 'packages/superdoc', tagPrefix: 'v' }, + { name: 'cli', packageCwd: 'apps/cli', tagPrefix: 'cli-v' }, +]; + +/** + * @typedef {object} PackageResult + * @property {'released' | 'no-op' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status + * @property {string[]} newTags - Tags created during this release attempt. + */ + +/** @type {Map} */ +const results = new Map(); + +let hasFailed = false; + +for (const pkg of packages) { + if (hasFailed) { + results.set(pkg.name, { status: 'skipped', newTags: [] }); + continue; + } + + // Snapshot tags before release to detect new tags. semantic-release exits 0 + // whether it publishes or finds nothing to release, and it creates+pushes + // the tag *before* publish plugins run — so a failure can still leave a tag. + const tagsBefore = new Set(listTags(`${pkg.tagPrefix}*`)); + + try { + pruneLocalOnlyForeignTags(pkg.tagPrefix); + runSemanticRelease(pkg.packageCwd, forwardedArgs); + + const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`)); + const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); + results.set(pkg.name, { status: newTags.length > 0 ? 'released' : 'no-op', newTags }); + } catch (error) { + const message = error && typeof error.message === 'string' ? error.message : String(error); + console.error(`\n${pkg.name} release failed:\n${message}`); + + // Check whether a tag was created before the failure (partial release). + const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`)); + const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); + const status = newTags.length > 0 ? 'FAILED (partial)' : 'FAILED'; + results.set(pkg.name, { status, newTags }); + hasFailed = true; + } +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +console.log('\n--- Release Summary ---'); +for (const [name, { status, newTags }] of results) { + const tagInfo = newTags.length > 0 ? ` [${newTags.join(', ')}]` : ''; + console.log(` ${name.padEnd(12)} ${status}${tagInfo}`); +} + +if (hasFailed) { + const partials = [...results.entries()].filter(([, r]) => r.status === 'FAILED (partial)'); + const released = [...results.entries()].filter(([, r]) => r.status === 'released'); + const tagsToReview = [...partials, ...released].flatMap(([, r]) => r.newTags); + + if (tagsToReview.length > 0) { + console.log(`\nTags created before the failure: ${tagsToReview.join(', ')}`); + console.log('Review these tags and decide whether manual rollback is needed.'); + } + process.exitCode = 1; +} + +// Remind operator about @semantic-release/git behavior on stable +const anyReleased = [...results.values()].some((r) => r.status === 'released'); +if (anyReleased && !forwardedArgs.includes('--dry-run')) { + console.log( + '\n@semantic-release/git automatically pushes version commits and tags on the stable branch.', + ); +} diff --git a/scripts/release-local-superdoc.mjs b/scripts/release-local-superdoc.mjs index 54b24e3059..f00ad37940 100644 --- a/scripts/release-local-superdoc.mjs +++ b/scripts/release-local-superdoc.mjs @@ -1,74 +1,14 @@ #!/usr/bin/env node -import { execFileSync } from 'node:child_process'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +/** + * Thin wrapper — releases the superdoc package locally via semantic-release. + * See release-local.mjs for the reusable runner logic. + */ -const BLOCKED_TAG_PATTERNS = ['cli-v*', 'vscode-v*']; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, '..'); - -function run(command, args, options = {}) { - const { capture = false, env = process.env } = options; - return execFileSync(command, args, { - cwd: REPO_ROOT, - env, - encoding: 'utf8', - stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', - }); -} - -function listTags(pattern) { - const output = run('git', ['tag', '--list', pattern], { capture: true }).trim(); - return output ? output.split('\n').map((tag) => tag.trim()).filter(Boolean) : []; -} - -function getRemoteTags() { - const output = run('git', ['ls-remote', '--tags', 'origin'], { capture: true }).trim(); - if (!output) return new Set(); - - const tags = output - .split('\n') - .map((line) => line.split('\t')[1]) - .filter((ref) => ref && ref.startsWith('refs/tags/')) - .map((ref) => ref.replace(/^refs\/tags\//, '')) - .map((tag) => tag.replace(/\^\{\}$/, '')); - - return new Set(tags); -} - -function pruneLocalOnlyBlockedTags() { - const pruned = []; - const remoteTags = getRemoteTags(); - - for (const pattern of BLOCKED_TAG_PATTERNS) { - const tags = listTags(pattern); - for (const tag of tags) { - if (remoteTags.has(tag)) continue; - run('git', ['tag', '-d', tag]); - pruned.push(tag); - } - } - - if (pruned.length > 0) { - console.log( - `Pruned ${pruned.length} local-only non-superdoc tags before release: ${pruned.join(', ')}`, - ); - } -} - -function runSemanticRelease() { - const extraArgs = process.argv.slice(2); - run( - 'pnpm', - ['--prefix', 'packages/superdoc', 'exec', 'semantic-release', '--no-ci', ...extraArgs], - { env: { ...process.env, LEFTHOOK: '0' } }, - ); -} +import { releasePackage } from './release-local.mjs'; try { - pruneLocalOnlyBlockedTags(); - runSemanticRelease(); + releasePackage({ packageCwd: 'packages/superdoc', tagPrefix: 'v', extraArgs: process.argv.slice(2) }); } catch (error) { const message = error && typeof error.message === 'string' ? error.message : String(error); console.error(message); diff --git a/scripts/release-local.mjs b/scripts/release-local.mjs new file mode 100644 index 0000000000..42fa5dcdb2 --- /dev/null +++ b/scripts/release-local.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +/** + * Generic reusable local semantic-release runner. + * + * Exports helpers used by the thin per-package wrappers + * (release-local-superdoc.mjs, release-local-cli.mjs) and + * the combined stable orchestrator (release-local-stable.mjs). + */ + +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..'); + +/** + * Detect the current git branch. Used to set GITHUB_REF_NAME so that + * .releaserc.cjs files see the same branch locally as they do in CI. + * Without this, the `isPrerelease` check in each releaserc is always + * false locally, causing @semantic-release/git to be added on main + * where CI would not include it. + */ +function getCurrentBranch() { + return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); +} + +/** + * Allowlist of every tag prefix used across the monorepo. + * Used by pruneLocalOnlyForeignTags to avoid leaking local-only + * tags from other packages into semantic-release's version detection. + * + * MAINTENANCE: when adding a new releasable package with its own + * tagFormat in .releaserc.*, add its prefix here too. You can find + * all current tagFormat values with: + * grep -r 'tagFormat' --include='*.cjs' --include='*.js' --include='*.mjs' . + */ +const ALL_TAG_PREFIXES = [ + 'v', // superdoc (packages/superdoc/.releaserc.cjs) + 'cli-v', // CLI (apps/cli/.releaserc.cjs) + 'sdk-v', // SDK + 'react-v', // React + 'vscode-v', // VS Code + 'mcp-v', // MCP + 'esign-v', // esign + 'template-builder-v', // template-builder +]; + +export function run(command, args, options = {}) { + const { capture = false, env = process.env } = options; + return execFileSync(command, args, { + cwd: REPO_ROOT, + env, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); +} + +export function listTags(pattern) { + const output = run('git', ['tag', '--list', pattern], { capture: true }).trim(); + return output ? output.split('\n').map((tag) => tag.trim()).filter(Boolean) : []; +} + +export function getRemoteTags() { + const output = run('git', ['ls-remote', '--tags', 'origin'], { capture: true }).trim(); + if (!output) return new Set(); + + const tags = output + .split('\n') + .map((line) => line.split('\t')[1]) + .filter((ref) => ref && ref.startsWith('refs/tags/')) + .map((ref) => ref.replace(/^refs\/tags\//, '')) + .map((tag) => tag.replace(/\^\{\}$/, '')); + + return new Set(tags); +} + +/** + * Prune local-only tags that belong to *other* packages. + * + * Uses an allowlist of known tag prefixes (ALL_TAG_PREFIXES) rather + * than a static blocklist that rots as new packages are added. + * + * @param {string} ownTagPrefix - The tag prefix of the package being released (e.g. 'v', 'cli-v'). + */ +export function pruneLocalOnlyForeignTags(ownTagPrefix) { + const foreignPrefixes = ALL_TAG_PREFIXES.filter((p) => p !== ownTagPrefix); + const pruned = []; + const remoteTags = getRemoteTags(); + + for (const prefix of foreignPrefixes) { + const tags = listTags(`${prefix}*`); + for (const tag of tags) { + if (remoteTags.has(tag)) continue; + run('git', ['tag', '-d', tag]); + pruned.push(tag); + } + } + + if (pruned.length > 0) { + console.log( + `Pruned ${pruned.length} local-only foreign tags before release: ${pruned.join(', ')}`, + ); + } +} + +/** + * Run semantic-release for a given package directory. + * + * @param {string} packageCwd - Relative path from repo root (e.g. 'packages/superdoc'). + * @param {string[]} extraArgs - Additional CLI flags forwarded to semantic-release. + */ +export function runSemanticRelease(packageCwd, extraArgs = []) { + const branch = getCurrentBranch(); + run( + 'pnpm', + ['--prefix', packageCwd, 'exec', 'semantic-release', '--no-ci', ...extraArgs], + { + env: { + ...process.env, + LEFTHOOK: '0', + // Mirror CI: .releaserc.cjs files read GITHUB_REF_NAME to decide + // whether to include @semantic-release/git (stable-only plugin). + GITHUB_REF_NAME: process.env.GITHUB_REF_NAME || branch, + }, + }, + ); +} + +/** + * Main entry point for releasing a single package locally. + * + * @param {object} options + * @param {string} options.packageCwd - Relative path from repo root. + * @param {string} options.tagPrefix - Tag prefix for this package (e.g. 'v', 'cli-v'). + * @param {string[]} [options.extraArgs] - Additional CLI flags forwarded to semantic-release. + */ +export function releasePackage({ packageCwd, tagPrefix, extraArgs = [] }) { + pruneLocalOnlyForeignTags(tagPrefix); + runSemanticRelease(packageCwd, extraArgs); +} From 13374a5b8b381c2f8165aa0820fed27c2fc2ea31 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Mar 2026 21:59:32 -0700 Subject: [PATCH 2/2] fix(release): align local CLI release flow with semantic-release behavior --- scripts/__tests__/release-local.test.mjs | 59 +++++++++++++++ scripts/release-local-cli.mjs | 2 +- scripts/release-local-stable.mjs | 27 ++++--- scripts/release-local-superdoc.mjs | 2 +- scripts/release-local.mjs | 94 +++++++++++++++++------- 5 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 scripts/__tests__/release-local.test.mjs diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs new file mode 100644 index 0000000000..c847cc01cc --- /dev/null +++ b/scripts/__tests__/release-local.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { inferDryRunWouldRelease } from '../release-local.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '../../'); + +async function readRepoFile(relativePath) { + return readFile(path.join(REPO_ROOT, relativePath), 'utf8'); +} + +function assertOrder(content, first, second, context) { + const firstIndex = content.indexOf(first); + const secondIndex = content.indexOf(second); + assert.notEqual(firstIndex, -1, `${context}: missing "${first}"`); + assert.notEqual(secondIndex, -1, `${context}: missing "${second}"`); + assert.ok(firstIndex < secondIndex, `${context}: expected "${first}" before "${second}"`); +} + +test('inferDryRunWouldRelease detects pending release previews', () => { + assert.equal( + inferDryRunWouldRelease('[semantic-release] › ℹ The next release version is 1.2.3'), + true, + ); + assert.equal( + inferDryRunWouldRelease('There are no relevant changes, so no new version is released.'), + false, + ); +}); + +test('release-local helper prunes local-only tags across all release namespaces', async () => { + const content = await readRepoFile('scripts/release-local.mjs'); + assert.ok( + content.includes('for (const prefix of ALL_TAG_PREFIXES)'), + 'scripts/release-local.mjs: must iterate every known release tag prefix', + ); + assert.equal( + content.includes("filter((p) => p !== ownTagPrefix)"), + false, + 'scripts/release-local.mjs: must not skip the current package tag namespace', + ); +}); + +test('stable orchestrator prunes before snapshot and reports would-release previews', async () => { + const content = await readRepoFile('scripts/release-local-stable.mjs'); + assertOrder( + content, + ' pruneLocalOnlyReleaseTags();', + ' const tagsBefore = new Set(listTags(`${pkg.tagPrefix}*`));', + 'scripts/release-local-stable.mjs', + ); + assert.ok( + content.includes("'would-release'"), + 'scripts/release-local-stable.mjs: dry-run previews must be reported as would-release', + ); +}); diff --git a/scripts/release-local-cli.mjs b/scripts/release-local-cli.mjs index d3edf75286..92befdefbb 100644 --- a/scripts/release-local-cli.mjs +++ b/scripts/release-local-cli.mjs @@ -8,7 +8,7 @@ import { releasePackage } from './release-local.mjs'; try { - releasePackage({ packageCwd: 'apps/cli', tagPrefix: 'cli-v', extraArgs: process.argv.slice(2) }); + releasePackage({ packageCwd: 'apps/cli', extraArgs: process.argv.slice(2) }); } catch (error) { const message = error && typeof error.message === 'string' ? error.message : String(error); console.error(message); diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index 73c74aa5be..291393851c 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -15,7 +15,7 @@ import { execFileSync } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { listTags, pruneLocalOnlyForeignTags, runSemanticRelease } from './release-local.mjs'; +import { listTags, pruneLocalOnlyReleaseTags, runSemanticRelease } from './release-local.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); @@ -53,6 +53,8 @@ if (currentBranch !== expectedBranch) { process.exit(1); } +const isDryRun = forwardedArgs.includes('--dry-run') || forwardedArgs.includes('-d'); + // --------------------------------------------------------------------------- // Release pipeline // --------------------------------------------------------------------------- @@ -64,7 +66,7 @@ const packages = [ /** * @typedef {object} PackageResult - * @property {'released' | 'no-op' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status + * @property {'released' | 'would-release' | 'no-op' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status * @property {string[]} newTags - Tags created during this release attempt. */ @@ -79,18 +81,25 @@ for (const pkg of packages) { continue; } - // Snapshot tags before release to detect new tags. semantic-release exits 0 - // whether it publishes or finds nothing to release, and it creates+pushes - // the tag *before* publish plugins run — so a failure can still leave a tag. + // Remove stale local-only tags first, including tags in the current package + // namespace, before snapshotting. Otherwise a leftover local tag can skew + // semantic-release's lastRelease lookup or mask a newly created tag. + pruneLocalOnlyReleaseTags(); + + // Snapshot tags before release to detect new tags. On real releases + // semantic-release creates+pushes the tag before publish plugins run, so a + // publish-time failure can still leave behind a real release tag. const tagsBefore = new Set(listTags(`${pkg.tagPrefix}*`)); try { - pruneLocalOnlyForeignTags(pkg.tagPrefix); - runSemanticRelease(pkg.packageCwd, forwardedArgs); + const runResult = runSemanticRelease(pkg.packageCwd, forwardedArgs); const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`)); const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); - results.set(pkg.name, { status: newTags.length > 0 ? 'released' : 'no-op', newTags }); + const status = runResult.dryRun + ? (runResult.wouldRelease ? 'would-release' : 'no-op') + : (newTags.length > 0 ? 'released' : 'no-op'); + results.set(pkg.name, { status, newTags }); } catch (error) { const message = error && typeof error.message === 'string' ? error.message : String(error); console.error(`\n${pkg.name} release failed:\n${message}`); @@ -128,7 +137,7 @@ if (hasFailed) { // Remind operator about @semantic-release/git behavior on stable const anyReleased = [...results.values()].some((r) => r.status === 'released'); -if (anyReleased && !forwardedArgs.includes('--dry-run')) { +if (anyReleased && !isDryRun) { console.log( '\n@semantic-release/git automatically pushes version commits and tags on the stable branch.', ); diff --git a/scripts/release-local-superdoc.mjs b/scripts/release-local-superdoc.mjs index f00ad37940..6c3ed224df 100644 --- a/scripts/release-local-superdoc.mjs +++ b/scripts/release-local-superdoc.mjs @@ -8,7 +8,7 @@ import { releasePackage } from './release-local.mjs'; try { - releasePackage({ packageCwd: 'packages/superdoc', tagPrefix: 'v', extraArgs: process.argv.slice(2) }); + releasePackage({ packageCwd: 'packages/superdoc', extraArgs: process.argv.slice(2) }); } catch (error) { const message = error && typeof error.message === 'string' ? error.message : String(error); console.error(message); diff --git a/scripts/release-local.mjs b/scripts/release-local.mjs index 42fa5dcdb2..374108284f 100644 --- a/scripts/release-local.mjs +++ b/scripts/release-local.mjs @@ -31,8 +31,9 @@ function getCurrentBranch() { /** * Allowlist of every tag prefix used across the monorepo. - * Used by pruneLocalOnlyForeignTags to avoid leaking local-only - * tags from other packages into semantic-release's version detection. + * Used by pruneLocalOnlyReleaseTags to avoid leaking local-only + * tags from any package namespace, including the current one, into + * semantic-release's version detection. * * MAINTENANCE: when adding a new releasable package with its own * tagFormat in .releaserc.*, add its prefix here too. You can find @@ -80,19 +81,17 @@ export function getRemoteTags() { } /** - * Prune local-only tags that belong to *other* packages. + * Prune local-only tags across all known release namespaces. * - * Uses an allowlist of known tag prefixes (ALL_TAG_PREFIXES) rather - * than a static blocklist that rots as new packages are added. - * - * @param {string} ownTagPrefix - The tag prefix of the package being released (e.g. 'v', 'cli-v'). + * This intentionally includes the package being released. A stale local-only + * tag in the current namespace can skew semantic-release's lastRelease lookup + * even if it was left behind by a failed or interrupted run. */ -export function pruneLocalOnlyForeignTags(ownTagPrefix) { - const foreignPrefixes = ALL_TAG_PREFIXES.filter((p) => p !== ownTagPrefix); +export function pruneLocalOnlyReleaseTags() { const pruned = []; const remoteTags = getRemoteTags(); - for (const prefix of foreignPrefixes) { + for (const prefix of ALL_TAG_PREFIXES) { const tags = listTags(`${prefix}*`); for (const tag of tags) { if (remoteTags.has(tag)) continue; @@ -108,6 +107,35 @@ export function pruneLocalOnlyForeignTags(ownTagPrefix) { } } +function isDryRunEnabled(extraArgs) { + return extraArgs.includes('--dry-run') || extraArgs.includes('-d'); +} + +function capture(command, args, env) { + try { + return { + stdout: execFileSync(command, args, { + cwd: REPO_ROOT, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + stderr: '', + error: null, + }; + } catch (error) { + return { + stdout: typeof error.stdout === 'string' ? error.stdout : String(error.stdout ?? ''), + stderr: typeof error.stderr === 'string' ? error.stderr : String(error.stderr ?? ''), + error, + }; + } +} + +export function inferDryRunWouldRelease(output) { + return output.includes('The next release version is '); +} + /** * Run semantic-release for a given package directory. * @@ -116,19 +144,32 @@ export function pruneLocalOnlyForeignTags(ownTagPrefix) { */ export function runSemanticRelease(packageCwd, extraArgs = []) { const branch = getCurrentBranch(); - run( - 'pnpm', - ['--prefix', packageCwd, 'exec', 'semantic-release', '--no-ci', ...extraArgs], - { - env: { - ...process.env, - LEFTHOOK: '0', - // Mirror CI: .releaserc.cjs files read GITHUB_REF_NAME to decide - // whether to include @semantic-release/git (stable-only plugin). - GITHUB_REF_NAME: process.env.GITHUB_REF_NAME || branch, - }, - }, - ); + const env = { + ...process.env, + LEFTHOOK: '0', + // Mirror CI: .releaserc.cjs files read GITHUB_REF_NAME to decide + // whether to include @semantic-release/git (stable-only plugin). + GITHUB_REF_NAME: process.env.GITHUB_REF_NAME || branch, + }; + const args = ['--prefix', packageCwd, 'exec', 'semantic-release', '--no-ci', ...extraArgs]; + + if (!isDryRunEnabled(extraArgs)) { + run('pnpm', args, { env }); + return { dryRun: false, wouldRelease: false }; + } + + // In dry-run mode semantic-release skips prepare/publish/tag creation, so + // infer whether a release is pending from its preview output instead of tags. + const { stdout, stderr, error } = capture('pnpm', args, env); + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + if (error) throw error; + + const combinedOutput = `${stdout}\n${stderr}`; + return { + dryRun: true, + wouldRelease: inferDryRunWouldRelease(combinedOutput), + }; } /** @@ -136,10 +177,9 @@ export function runSemanticRelease(packageCwd, extraArgs = []) { * * @param {object} options * @param {string} options.packageCwd - Relative path from repo root. - * @param {string} options.tagPrefix - Tag prefix for this package (e.g. 'v', 'cli-v'). * @param {string[]} [options.extraArgs] - Additional CLI flags forwarded to semantic-release. */ -export function releasePackage({ packageCwd, tagPrefix, extraArgs = [] }) { - pruneLocalOnlyForeignTags(tagPrefix); - runSemanticRelease(packageCwd, extraArgs); +export function releasePackage({ packageCwd, extraArgs = [] }) { + pruneLocalOnlyReleaseTags(); + return runSemanticRelease(packageCwd, extraArgs); }