Skip to content

Commit 4ea0ccc

Browse files
authored
chore: update cicd for cli releases (#2489)
* chore: update cicd for cli releases * fix(release): align local CLI release flow with semantic-release behavior
1 parent 096d9f0 commit 4ea0ccc

11 files changed

Lines changed: 470 additions & 75 deletions

.github/workflows/release-cli.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ on:
99
- main
1010
- stable
1111
paths:
12+
# Keep in sync with apps/cli/.releaserc.cjs includePaths (patch-commit-filter).
13+
# Workflow paths trigger CI; includePaths control semantic-release commit analysis.
1214
- 'apps/cli/**'
1315
- 'packages/document-api/**'
1416
- 'packages/superdoc/**'

apps/cli/.releaserc.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* commits touching any of them. This shared helper patches git-log-parser to
66
* expand path coverage. It REPLACES semantic-release-commit-filter — do not
77
* use both (the filter restricts to CWD, which undoes the expansion).
8+
*
9+
* Keep in sync with .github/workflows/release-cli.yml paths: trigger.
810
*/
911
require('../../scripts/semantic-release/patch-commit-filter.cjs')([
1012
'apps/cli',

cicd.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,40 @@ Version bumps are automatic based on commit messages:
160160

161161
> ℹ️ The legacy scoped package `@harbour-enterprises/superdoc` is mirrored with the same version and dist-tag for every release channel above.
162162
163+
## CLI Release
164+
165+
The CLI (`apps/cli`) has its own semantic-release pipeline with tag format `cli-v${version}`.
166+
167+
### Automated (CI)
168+
169+
| Trigger | Channel | Tag example |
170+
|---------|---------|-------------|
171+
| Push to `main` | `@next` | `cli-v0.3.0-next.1` |
172+
| Push to `stable` | `@latest` | `cli-v0.3.0` |
173+
174+
The workflow is `.github/workflows/release-cli.yml`. It analyzes commits across multiple packages (see `apps/cli/.releaserc.cjs` for the `includePaths` list).
175+
176+
### Local Release
177+
178+
| Command | What it does |
179+
|---------|-------------|
180+
| `pnpm run release:local` | Releases **superdoc then CLI** in sequence on `stable` |
181+
| `pnpm run release:local:superdoc` | Releases superdoc only |
182+
| `pnpm run release:local:cli` | Releases CLI only |
183+
184+
All accept `-- --dry-run` to preview without publishing. The combined orchestrator (`release:local`) enforces a `stable` branch guard (override with `--branch=<name>`).
185+
186+
`@semantic-release/git` automatically pushes version commits and tags when releasing on the `stable` branch. This is existing behavior for both superdoc and CLI.
187+
188+
### Raw Platform Publish (bypass semantic-release)
189+
190+
| Command | What it does |
191+
|---------|-------------|
192+
| `pnpm run cli:publish:raw` | Builds and publishes platform binaries directly |
193+
| `pnpm run cli:publish:raw:dry` | Dry-run of the above |
194+
195+
These skip semantic-release entirely — useful for re-publishing a failed platform upload.
196+
163197
## Workflow Scenarios
164198

165199
### Scenario 1: Feature Development

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@
5252
"pack": "pnpm run build:superdoc && pnpm run type-check && pnpm --prefix ./packages/superdoc run pack",
5353
"release": "pnpm run build:superdoc && pnpm run type-check && pnpm --prefix packages/superdoc exec semantic-release",
5454
"release:dry-run": "pnpm run build:superdoc && pnpm run type-check && pnpm --prefix packages/superdoc exec semantic-release --dry-run",
55-
"release:local": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-superdoc.mjs",
55+
"release:local": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-stable.mjs",
56+
"release:local:superdoc": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-superdoc.mjs",
57+
"release:local:cli": "pnpm run build:superdoc && pnpm run type-check && node scripts/release-local-cli.mjs",
5658
"prepare": "if [ -z \"$CI\" ]; then lefthook install; fi",
5759
"test:layout": "bun scripts/test-layout.mjs",
5860
"test:visual": "bun scripts/test-visual.mjs",
@@ -76,8 +78,8 @@
7678
"docapi:sync:check": "pnpm run docapi:sync && pnpm run docapi:check",
7779
"test:cli": "pnpm --prefix apps/cli run test",
7880
"cli:prepare": "pnpm run test:cli && pnpm --prefix apps/cli run build:prepublish",
79-
"cli:release": "pnpm run cli:prepare && pnpm --prefix apps/cli run publish:platforms",
80-
"cli:release:dry": "pnpm run cli:prepare && pnpm --prefix apps/cli run publish:platforms:dry",
81+
"cli:publish:raw": "pnpm run cli:prepare && pnpm --prefix apps/cli run publish:platforms",
82+
"cli:publish:raw:dry": "pnpm run cli:prepare && pnpm --prefix apps/cli run publish:platforms:dry",
8183
"cli:export-sdk-contract": "bun apps/cli/scripts/export-sdk-contract.ts",
8284
"docs:sync-engine": "pnpm exec tsx apps/docs/scripts/generate-sdk-overview.ts",
8385
"sdk:sync-version": "node packages/sdk/scripts/sync-sdk-version.mjs",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import assert from 'node:assert/strict';
2+
import { readFile } from 'node:fs/promises';
3+
import path from 'node:path';
4+
import test from 'node:test';
5+
import { fileURLToPath } from 'node:url';
6+
import { inferDryRunWouldRelease } from '../release-local.mjs';
7+
8+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9+
const REPO_ROOT = path.resolve(__dirname, '../../');
10+
11+
async function readRepoFile(relativePath) {
12+
return readFile(path.join(REPO_ROOT, relativePath), 'utf8');
13+
}
14+
15+
function assertOrder(content, first, second, context) {
16+
const firstIndex = content.indexOf(first);
17+
const secondIndex = content.indexOf(second);
18+
assert.notEqual(firstIndex, -1, `${context}: missing "${first}"`);
19+
assert.notEqual(secondIndex, -1, `${context}: missing "${second}"`);
20+
assert.ok(firstIndex < secondIndex, `${context}: expected "${first}" before "${second}"`);
21+
}
22+
23+
test('inferDryRunWouldRelease detects pending release previews', () => {
24+
assert.equal(
25+
inferDryRunWouldRelease('[semantic-release] › ℹ The next release version is 1.2.3'),
26+
true,
27+
);
28+
assert.equal(
29+
inferDryRunWouldRelease('There are no relevant changes, so no new version is released.'),
30+
false,
31+
);
32+
});
33+
34+
test('release-local helper prunes local-only tags across all release namespaces', async () => {
35+
const content = await readRepoFile('scripts/release-local.mjs');
36+
assert.ok(
37+
content.includes('for (const prefix of ALL_TAG_PREFIXES)'),
38+
'scripts/release-local.mjs: must iterate every known release tag prefix',
39+
);
40+
assert.equal(
41+
content.includes("filter((p) => p !== ownTagPrefix)"),
42+
false,
43+
'scripts/release-local.mjs: must not skip the current package tag namespace',
44+
);
45+
});
46+
47+
test('stable orchestrator prunes before snapshot and reports would-release previews', async () => {
48+
const content = await readRepoFile('scripts/release-local-stable.mjs');
49+
assertOrder(
50+
content,
51+
' pruneLocalOnlyReleaseTags();',
52+
' const tagsBefore = new Set(listTags(`${pkg.tagPrefix}*`));',
53+
'scripts/release-local-stable.mjs',
54+
);
55+
assert.ok(
56+
content.includes("'would-release'"),
57+
'scripts/release-local-stable.mjs: dry-run previews must be reported as would-release',
58+
);
59+
});

scripts/manual-clean-tag.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ set -euo pipefail
33

44
# Usage: ./scripts/manual-clean-tag.sh <version>
55
# Example: ./scripts/manual-clean-tag.sh v1.2.0-next.2
6+
# ./scripts/manual-clean-tag.sh cli-v0.3.0
67

78
VERSION="${1:-}"
89

@@ -15,8 +16,9 @@ if [[ -z "$VERSION" ]]; then
1516
exit 1
1617
fi
1718

18-
# Ensure version starts with 'v'
19-
if [[ ! "$VERSION" =~ ^v ]]; then
19+
# Prepend 'v' only for bare numeric versions (e.g. 1.2.0 → v1.2.0).
20+
# Prefixed tags like cli-v0.3.0 are left as-is.
21+
if [[ "$VERSION" =~ ^[0-9] ]]; then
2022
VERSION="v$VERSION"
2123
fi
2224

scripts/manual-tag.sh

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ set -euo pipefail
44
# Usage: ./scripts/manual-tag.sh <version> <commit>
55
# Example: ./scripts/manual-tag.sh v1.2.0-next.1 b4903188
66
# ./scripts/manual-tag.sh v1.2.0 HEAD
7+
# ./scripts/manual-tag.sh cli-v0.3.0 HEAD
78

89
VERSION="${1:-}"
910
COMMIT="${2:-HEAD}"
@@ -17,13 +18,21 @@ if [[ -z "$VERSION" ]]; then
1718
exit 1
1819
fi
1920

20-
# Ensure version starts with 'v'
21-
if [[ ! "$VERSION" =~ ^v ]]; then
21+
# Prepend 'v' only for bare numeric versions (e.g. 1.2.0 → v1.2.0).
22+
# Prefixed tags like cli-v0.3.0 are left as-is.
23+
if [[ "$VERSION" =~ ^[0-9] ]]; then
2224
VERSION="v$VERSION"
2325
fi
2426

25-
# Extract version without 'v' prefix for semver parsing
26-
VERSION_NUM="${VERSION#v}"
27+
# Extract the numeric version after the tag-format 'v' for semver/channel parsing.
28+
# Anchors on 'v' followed by a digit to skip prefixes like 'vscode-'.
29+
# e.g. 'v1.2.0' → '1.2.0', 'cli-v0.3.0-next.1' → '0.3.0-next.1',
30+
# 'vscode-v1.0.0' → '1.0.0' (not 'scode-v1.0.0').
31+
if [[ "$VERSION" =~ v([0-9].*)$ ]]; then
32+
VERSION_NUM="${BASH_REMATCH[1]}"
33+
else
34+
VERSION_NUM="$VERSION"
35+
fi
2736

2837
# Determine channel from version
2938
if [[ "$VERSION_NUM" =~ -next\. ]]; then

scripts/release-local-cli.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Thin wrapper — releases the CLI package locally via semantic-release.
5+
* See release-local.mjs for the reusable runner logic.
6+
*/
7+
8+
import { releasePackage } from './release-local.mjs';
9+
10+
try {
11+
releasePackage({ packageCwd: 'apps/cli', extraArgs: process.argv.slice(2) });
12+
} catch (error) {
13+
const message = error && typeof error.message === 'string' ? error.message : String(error);
14+
console.error(message);
15+
process.exitCode = 1;
16+
}

scripts/release-local-stable.mjs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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

Comments
 (0)