Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/.releaserc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
34 changes: 34 additions & 0 deletions cicd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name>`).

`@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
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions scripts/__tests__/release-local.test.mjs
Original file line number Diff line number Diff line change
@@ -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',
);
});
6 changes: 4 additions & 2 deletions scripts/manual-clean-tag.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ set -euo pipefail

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

VERSION="${1:-}"

Expand All @@ -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

Expand Down
17 changes: 13 additions & 4 deletions scripts/manual-tag.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ set -euo pipefail
# Usage: ./scripts/manual-tag.sh <version> <commit>
# 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}"
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions scripts/release-local-cli.mjs
Original file line number Diff line number Diff line change
@@ -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', extraArgs: process.argv.slice(2) });
} catch (error) {
const message = error && typeof error.message === 'string' ? error.message : String(error);
console.error(message);
process.exitCode = 1;
}
144 changes: 144 additions & 0 deletions scripts/release-local-stable.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
#!/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=<name>]
*
* Flags:
* --branch=<name> 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, pruneLocalOnlyReleaseTags, 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=<name> to override.');
process.exit(1);
}

const isDryRun = forwardedArgs.includes('--dry-run') || forwardedArgs.includes('-d');

// ---------------------------------------------------------------------------
// Release pipeline
// ---------------------------------------------------------------------------

const packages = [
{ name: 'superdoc', packageCwd: 'packages/superdoc', tagPrefix: 'v' },
{ name: 'cli', packageCwd: 'apps/cli', tagPrefix: 'cli-v' },
];

/**
* @typedef {object} PackageResult
* @property {'released' | 'would-release' | 'no-op' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status
* @property {string[]} newTags - Tags created during this release attempt.
*/

/** @type {Map<string, PackageResult>} */
const results = new Map();

let hasFailed = false;

for (const pkg of packages) {
if (hasFailed) {
results.set(pkg.name, { status: 'skipped', newTags: [] });
continue;
}

// 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 {
const runResult = runSemanticRelease(pkg.packageCwd, forwardedArgs);

const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`));
const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t));
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}`);

// 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 && !isDryRun) {
console.log(
'\n@semantic-release/git automatically pushes version commits and tags on the stable branch.',
);
}
Loading
Loading