|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Combined stable orchestrator — releases superdoc then CLI in sequence. |
| 5 | + * |
| 6 | + * Usage: |
| 7 | + * pnpm run release:local [-- --dry-run] |
| 8 | + * node scripts/release-local-stable.mjs [--dry-run] [--branch=<name>] |
| 9 | + * |
| 10 | + * Flags: |
| 11 | + * --branch=<name> Override the expected branch (default: stable) |
| 12 | + * All other flags are forwarded to both semantic-release invocations. |
| 13 | + */ |
| 14 | + |
| 15 | +import { execFileSync } from 'node:child_process'; |
| 16 | +import { dirname, resolve } from 'node:path'; |
| 17 | +import { fileURLToPath } from 'node:url'; |
| 18 | +import { listTags, pruneLocalOnlyReleaseTags, runSemanticRelease } from './release-local.mjs'; |
| 19 | + |
| 20 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 21 | +const REPO_ROOT = resolve(__dirname, '..'); |
| 22 | + |
| 23 | +function getCurrentBranch() { |
| 24 | + return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { |
| 25 | + cwd: REPO_ROOT, |
| 26 | + encoding: 'utf8', |
| 27 | + }).trim(); |
| 28 | +} |
| 29 | + |
| 30 | +// --------------------------------------------------------------------------- |
| 31 | +// Parse own flags vs forwarded flags |
| 32 | +// --------------------------------------------------------------------------- |
| 33 | + |
| 34 | +let expectedBranch = 'stable'; |
| 35 | +const forwardedArgs = []; |
| 36 | + |
| 37 | +for (const arg of process.argv.slice(2)) { |
| 38 | + if (arg.startsWith('--branch=')) { |
| 39 | + expectedBranch = arg.slice('--branch='.length); |
| 40 | + } else { |
| 41 | + forwardedArgs.push(arg); |
| 42 | + } |
| 43 | +} |
| 44 | + |
| 45 | +// --------------------------------------------------------------------------- |
| 46 | +// Branch guard |
| 47 | +// --------------------------------------------------------------------------- |
| 48 | + |
| 49 | +const currentBranch = getCurrentBranch(); |
| 50 | +if (currentBranch !== expectedBranch) { |
| 51 | + console.error(`Expected branch ${expectedBranch} but on ${currentBranch}`); |
| 52 | + console.error('Use --branch=<name> to override.'); |
| 53 | + process.exit(1); |
| 54 | +} |
| 55 | + |
| 56 | +const isDryRun = forwardedArgs.includes('--dry-run') || forwardedArgs.includes('-d'); |
| 57 | + |
| 58 | +// --------------------------------------------------------------------------- |
| 59 | +// Release pipeline |
| 60 | +// --------------------------------------------------------------------------- |
| 61 | + |
| 62 | +const packages = [ |
| 63 | + { name: 'superdoc', packageCwd: 'packages/superdoc', tagPrefix: 'v' }, |
| 64 | + { name: 'cli', packageCwd: 'apps/cli', tagPrefix: 'cli-v' }, |
| 65 | +]; |
| 66 | + |
| 67 | +/** |
| 68 | + * @typedef {object} PackageResult |
| 69 | + * @property {'released' | 'would-release' | 'no-op' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status |
| 70 | + * @property {string[]} newTags - Tags created during this release attempt. |
| 71 | + */ |
| 72 | + |
| 73 | +/** @type {Map<string, PackageResult>} */ |
| 74 | +const results = new Map(); |
| 75 | + |
| 76 | +let hasFailed = false; |
| 77 | + |
| 78 | +for (const pkg of packages) { |
| 79 | + if (hasFailed) { |
| 80 | + results.set(pkg.name, { status: 'skipped', newTags: [] }); |
| 81 | + continue; |
| 82 | + } |
| 83 | + |
| 84 | + // Remove stale local-only tags first, including tags in the current package |
| 85 | + // namespace, before snapshotting. Otherwise a leftover local tag can skew |
| 86 | + // semantic-release's lastRelease lookup or mask a newly created tag. |
| 87 | + pruneLocalOnlyReleaseTags(); |
| 88 | + |
| 89 | + // Snapshot tags before release to detect new tags. On real releases |
| 90 | + // semantic-release creates+pushes the tag before publish plugins run, so a |
| 91 | + // publish-time failure can still leave behind a real release tag. |
| 92 | + const tagsBefore = new Set(listTags(`${pkg.tagPrefix}*`)); |
| 93 | + |
| 94 | + try { |
| 95 | + const runResult = runSemanticRelease(pkg.packageCwd, forwardedArgs); |
| 96 | + |
| 97 | + const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`)); |
| 98 | + const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); |
| 99 | + const status = runResult.dryRun |
| 100 | + ? (runResult.wouldRelease ? 'would-release' : 'no-op') |
| 101 | + : (newTags.length > 0 ? 'released' : 'no-op'); |
| 102 | + results.set(pkg.name, { status, newTags }); |
| 103 | + } catch (error) { |
| 104 | + const message = error && typeof error.message === 'string' ? error.message : String(error); |
| 105 | + console.error(`\n${pkg.name} release failed:\n${message}`); |
| 106 | + |
| 107 | + // Check whether a tag was created before the failure (partial release). |
| 108 | + const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`)); |
| 109 | + const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); |
| 110 | + const status = newTags.length > 0 ? 'FAILED (partial)' : 'FAILED'; |
| 111 | + results.set(pkg.name, { status, newTags }); |
| 112 | + hasFailed = true; |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +// --------------------------------------------------------------------------- |
| 117 | +// Summary |
| 118 | +// --------------------------------------------------------------------------- |
| 119 | + |
| 120 | +console.log('\n--- Release Summary ---'); |
| 121 | +for (const [name, { status, newTags }] of results) { |
| 122 | + const tagInfo = newTags.length > 0 ? ` [${newTags.join(', ')}]` : ''; |
| 123 | + console.log(` ${name.padEnd(12)} ${status}${tagInfo}`); |
| 124 | +} |
| 125 | + |
| 126 | +if (hasFailed) { |
| 127 | + const partials = [...results.entries()].filter(([, r]) => r.status === 'FAILED (partial)'); |
| 128 | + const released = [...results.entries()].filter(([, r]) => r.status === 'released'); |
| 129 | + const tagsToReview = [...partials, ...released].flatMap(([, r]) => r.newTags); |
| 130 | + |
| 131 | + if (tagsToReview.length > 0) { |
| 132 | + console.log(`\nTags created before the failure: ${tagsToReview.join(', ')}`); |
| 133 | + console.log('Review these tags and decide whether manual rollback is needed.'); |
| 134 | + } |
| 135 | + process.exitCode = 1; |
| 136 | +} |
| 137 | + |
| 138 | +// Remind operator about @semantic-release/git behavior on stable |
| 139 | +const anyReleased = [...results.values()].some((r) => r.status === 'released'); |
| 140 | +if (anyReleased && !isDryRun) { |
| 141 | + console.log( |
| 142 | + '\n@semantic-release/git automatically pushes version commits and tags on the stable branch.', |
| 143 | + ); |
| 144 | +} |
0 commit comments