diff --git a/.github/workflows/promote-stable-docs.yml b/.github/workflows/promote-stable-docs.yml index 680dcb07bb..e83e8b525f 100644 --- a/.github/workflows/promote-stable-docs.yml +++ b/.github/workflows/promote-stable-docs.yml @@ -1,22 +1,27 @@ # Advances docs-stable to the current stable HEAD (or a chosen SHA). # -# Auto path (workflow_run): only release-superdoc.yml triggers a promotion, -# and only when a real v* tag actually appeared on stable. The CLI/SDK/MCP -# tooling bundle and the React/esign/template-builder/vscode-ext wrappers do -# not advance docs-stable - docs-stable represents the documentation for -# the stable SuperDoc release. +# Auto path (workflow_run): release-stable.yml is the orchestrator that +# releases superdoc on stable, so we trigger off its completion and gate on +# whether a real v* tag appeared between the triggering run's head_sha and +# origin/stable. Tools-only runs (CLI/SDK/MCP without a superdoc release) +# leave docs-stable unchanged - no new v* tag, no push. +# +# We accept conclusion: failure as well as success because the orchestrator +# runs chains independently. A tools-chain failure that follows a successful +# superdoc release should still promote docs - the v* tag is the source of +# truth, not the workflow's overall conclusion. Cancelled and skipped runs +# are excluded since they may not have reached semantic-release at all. # # Manual path (workflow_dispatch): for cases the auto path cannot cover, -# e.g. the first stable bundle run that ships CLI/SDK/MCP without a SuperDoc -# release, or a docs-only refresh between SuperDoc versions. Defaults to -# pushing the current origin/stable head; an optional `sha` input promotes -# a specific commit instead. +# e.g. a docs-only refresh between SuperDoc versions, or recovering from +# a missed promotion. Defaults to pushing the current origin/stable head; +# an optional `sha` input promotes a specific commit instead. name: 🚀 Promote stable docs on: workflow_run: workflows: - - "📦 Release superdoc" + - "📦 Release stable tooling (CLI/SDK/MCP)" types: - completed workflow_dispatch: @@ -37,7 +42,10 @@ jobs: promote: if: | github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'stable') + ( + github.event.workflow_run.head_branch == 'stable' && + (github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure') + ) runs-on: ubuntu-24.04 steps: - name: Generate token @@ -55,6 +63,14 @@ jobs: # Auto path: gate on a real SuperDoc release between the triggering # run's head_sha and origin/stable. A no-op semantic-release run must # not advance docs-stable. + # + # The tag alone is not sufficient. @semantic-release/git pushes the v* + # tag during its prepare phase, before publish-superdoc.cjs runs. If + # publish fails and the orchestrator's recovery does not republish, the + # tag exists on origin without corresponding npm tarballs. In that case + # docs-stable would point at code that consumers cannot install. Verify + # both unscoped (`superdoc`) and scoped (`@harbour-enterprises/superdoc`) + # publishes are present at the released version before promoting. - name: Detect SuperDoc release if: github.event_name == 'workflow_run' id: detect @@ -72,8 +88,22 @@ jobs: echo "released=false" >> "${GITHUB_OUTPUT}" echo "No new v* tag between ${HEAD_SHA} and origin/stable — release-superdoc was a no-op." else + for tag in ${new_tags}; do + version="${tag#v}" + if ! npm view "superdoc@${version}" version >/dev/null 2>&1; then + echo "released=false" >> "${GITHUB_OUTPUT}" + echo "Tag ${tag} exists but superdoc@${version} is not on npm — orchestrator publish did not complete; docs-stable will not advance." + exit 0 + fi + if ! npm view "@harbour-enterprises/superdoc@${version}" version >/dev/null 2>&1; then + echo "released=false" >> "${GITHUB_OUTPUT}" + echo "Tag ${tag} exists but @harbour-enterprises/superdoc@${version} is not on npm — orchestrator publish did not complete; docs-stable will not advance." + exit 0 + fi + done + echo "released=true" >> "${GITHUB_OUTPUT}" - echo "New SuperDoc tag(s) detected: $(echo "${new_tags}" | tr '\n' ' ')" + echo "New SuperDoc tag(s) verified on npm: $(echo "${new_tags}" | tr '\n' ' ')" fi - name: Push docs-stable (auto) diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index fc598b436a..13f96abe16 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -1,10 +1,13 @@ -# Stable tooling mini-bundle: CLI -> SDK -> MCP, in order, on the same runner. -# These three share artifacts (SDK packages CLI native binaries; MCP imports -# SDK + engine code), so they release together with one queue slot and one -# permission context (including PyPI OIDC). +# Stable release orchestrator. Drives `release-local-stable.mjs`, which +# releases each package in the right chain on one runner, with one queue +# slot and one permission context (including PyPI OIDC). # -# Out of scope: superdoc, react, esign, template-builder, vscode-ext. Each of -# those has its own per-package stable workflow. +# Tools chain (CLI -> SDK -> MCP) ships together because SDK packages +# CLI native binaries and MCP imports SDK + engine code. Core chain +# (currently superdoc) ships independently of the tools chain. +# +# Out of scope: esign, template-builder. Each has its own stable workflow +# until brought into the orchestrator. name: 📦 Release stable tooling (CLI/SDK/MCP) on: @@ -103,7 +106,7 @@ jobs: - name: Build packages run: pnpm run build - - name: Release stable tooling (CLI -> SDK -> MCP) + - name: Release stable packages (orchestrator) id: stable_release env: GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} @@ -152,6 +155,7 @@ jobs: # with: # packages-dir: packages/sdk/langs/python/dist/ # skip-existing: true - # Docs promotion lives in promote-stable-docs.yml, which triggers on the - # SuperDoc workflow specifically. Tooling-bundle releases (CLI/SDK/MCP) - # do not advance docs-stable. + # Docs promotion lives in promote-stable-docs.yml. It triggers off this + # workflow's completion and gates on whether a real superdoc v* tag was + # created during the run, so tools-only releases leave docs-stable + # unchanged automatically. diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml index 793858720b..8eb0ef40e0 100644 --- a/.github/workflows/release-superdoc.yml +++ b/.github/workflows/release-superdoc.yml @@ -1,6 +1,8 @@ -# Auto-releases on push to main (@next) and stable (@latest). -# docs-stable is advanced by promote-stable-docs.yml when this workflow -# completes a real release on stable (not a no-op or PR preview). +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml so that +# every stable release shares one concurrency slot and one git push lane. +# docs-stable is advanced by promote-stable-docs.yml when release-stable.yml +# produces a real superdoc v* tag (no-op runs do not advance docs-stable). # Manual PR preview: dispatch with pr_number to publish @pr- name: 📦 Release superdoc @@ -8,7 +10,6 @@ on: push: branches: - main - - stable paths: - 'packages/superdoc/**' - 'packages/layout-engine/**' diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index b553f49718..c1e8c81b82 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -176,19 +176,18 @@ test('stable orchestrator prunes before snapshot and reports would-release previ ); }); -test('stable tooling bundle releases CLI, SDK, then MCP in order', async () => { +test('stable orchestrator releases tools chain (CLI, SDK, MCP) and core chain (superdoc) in order', async () => { const content = await readRepoFile('scripts/release-local-stable.mjs'); assertOrder(content, "name: 'cli'", "name: 'sdk'", 'scripts/release-local-stable.mjs (cli before sdk)'); assertOrder(content, "name: 'sdk'", "name: 'mcp'", 'scripts/release-local-stable.mjs (sdk before mcp)'); - assert.equal( + assert.ok( content.includes("name: 'superdoc'"), - false, - 'scripts/release-local-stable.mjs: superdoc has its own per-package stable workflow, not this bundle', + 'scripts/release-local-stable.mjs: orchestrator must release superdoc so the v* tag drives docs-stable promotion in the same workflow', ); assert.equal( content.includes("name: 'esign'") || content.includes("name: 'react'") || content.includes("name: 'template-builder'") || content.includes("name: 'vscode-ext'"), false, - 'scripts/release-local-stable.mjs: only CLI/SDK/MCP belong in the tooling bundle', + 'scripts/release-local-stable.mjs: react, vscode-ext, esign, and template-builder are added in follow-up PRs', ); }); @@ -258,8 +257,11 @@ test('stable release workflows serialize on the shared release-stable concurrenc '.github/workflows/release-stable.yml: skip-ci writeback runs must still no-op when they start', ); + // Per-package workflows that still auto-fire on stable directly. + // superdoc is excluded because release-stable.yml drives its stable + // releases now. The remaining workflows have not yet been brought into + // the orchestrator. const perPackageStableWorkflows = [ - '.github/workflows/release-superdoc.yml', '.github/workflows/release-react.yml', '.github/workflows/release-esign.yml', '.github/workflows/release-template-builder.yml', @@ -272,6 +274,15 @@ test('stable release workflows serialize on the shared release-stable concurrenc `${file}: must trigger on push to both main and stable`, ); } + + // superdoc no longer auto-fires on stable - the orchestrator is its + // single stable release path. + const superdocWorkflow = await readRepoFile('.github/workflows/release-superdoc.yml'); + assert.equal( + /branches:\s*\n\s*-\s*main\s*\n\s*-\s*stable/.test(superdocWorkflow), + false, + '.github/workflows/release-superdoc.yml: stable releases are driven by release-stable.yml; this workflow only fires on main', + ); }); test('MCP releaserc builds the package before publish so the tarball ships dist/', async () => { @@ -319,24 +330,27 @@ test('release-state probes wrap fetch in bounded retry to absorb transient blips ); }); -test('docs promotion is keyed to SuperDoc only', async () => { +test('docs promotion is keyed to a real superdoc tag from the orchestrator run', async () => { const promoteWorkflow = await readRepoFile('.github/workflows/promote-stable-docs.yml'); assert.ok( promoteWorkflow.includes('workflow_run:'), '.github/workflows/promote-stable-docs.yml: must trigger on workflow_run completion', ); assert.ok( - /workflows:\s*\n\s*-\s*"📦 Release superdoc"/.test(promoteWorkflow), - '.github/workflows/promote-stable-docs.yml: must trigger only on the SuperDoc release workflow', + /workflows:\s*\n\s*-\s*"📦 Release stable tooling \(CLI\/SDK\/MCP\)"/.test(promoteWorkflow), + '.github/workflows/promote-stable-docs.yml: must trigger off the stable orchestrator workflow', ); assert.equal( - /Release CLI|Release SDK|Release MCP|Release react|Release esign|Release template-builder|Release vscode-ext/.test(promoteWorkflow), + /"📦 Release CLI"|"📦 Release SDK"|"📦 Release MCP"|"📦 Release react"|"📦 Release esign"|"📦 Release template-builder"|"📦 Release vscode-ext"/.test(promoteWorkflow), false, - '.github/workflows/promote-stable-docs.yml: docs-stable tracks SuperDoc only, not other packages', + '.github/workflows/promote-stable-docs.yml: must trigger only off the orchestrator, not per-package workflows', ); + // Chain-independent failures (e.g. tools fail, superdoc releases) must + // still promote docs. The git-tag detection is the source of truth. assert.ok( - promoteWorkflow.includes("github.event.workflow_run.conclusion == 'success'"), - '.github/workflows/promote-stable-docs.yml: must only promote on successful SuperDoc runs', + promoteWorkflow.includes("github.event.workflow_run.conclusion == 'success'") && + promoteWorkflow.includes("github.event.workflow_run.conclusion == 'failure'"), + '.github/workflows/promote-stable-docs.yml: must accept both success and failure conclusions so a tools-chain failure does not block superdoc-driven docs promotion', ); assert.ok( promoteWorkflow.includes("github.event.workflow_run.head_branch == 'stable'"), @@ -347,6 +361,14 @@ test('docs promotion is keyed to SuperDoc only', async () => { promoteWorkflow.includes('git tag --merged "${HEAD_SHA}" --list'), '.github/workflows/promote-stable-docs.yml: must detect a real SuperDoc release (not a no-op) before pushing docs-stable', ); + // semantic-release pushes the v* tag during prepare, before publish runs. + // A failed publish leaves the tag on origin without the npm tarball, so + // tag presence alone is not sufficient evidence that the release shipped. + assert.ok( + promoteWorkflow.includes('npm view "superdoc@${version}"') && + promoteWorkflow.includes('npm view "@harbour-enterprises/superdoc@${version}"'), + '.github/workflows/promote-stable-docs.yml: must verify npm publish completed for both superdoc and @harbour-enterprises/superdoc before promoting docs-stable, otherwise a tag-without-publish failure would advance docs to an unshipped version', + ); assert.ok( promoteWorkflow.includes('refs/heads/docs-stable'), '.github/workflows/promote-stable-docs.yml: must push to docs-stable', diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index b43a70dfaa..13a4893a0a 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -1,23 +1,30 @@ #!/usr/bin/env node /** - * Stable tooling bundle: CLI -> SDK -> MCP, in order. + * Stable release orchestrator. Runs every package release that ships from + * stable in one workflow run, so the per-package workflows aren't all + * fighting for the same `release-stable` concurrency slot. * * Used both in CI (release-stable.yml) and locally (`pnpm release:local`). - * Releasing these three together solves three GitHub Actions semantics - * problems that per-workflow chains hit: shared concurrency cancellation, - * `id-token: write` permission propagation for PyPI OIDC, and double-fire - * from overlapping path filters. One workflow, one queue slot, one - * permission context. * - * Order rationale: - * - CLI ships native binaries used by SDK. - * - SDK packages those CLI binaries into Node + Python distributions. - * - MCP imports SDK + engine code and ships against pinned SDK versions. + * Packages are grouped into chains. Within a chain, fail-stop applies (a + * failure upstream skips downstream). Across chains, packages are + * independent - a tools failure does not block the core release and + * vice versa. * - * superdoc, react, esign, template-builder, and vscode-ext release - * independently via their own per-package stable workflows. SuperDoc - * promotion of docs-stable is keyed to the SuperDoc workflow specifically. + * Tools chain (CLI -> SDK -> MCP): + * These three share artifacts (SDK packages CLI native binaries; MCP + * imports SDK + engine code), so they must release in this order. + * + * Core chain: + * Currently superdoc only. react and vscode-ext still ship from their + * per-package stable workflows; pulling them into this chain is a + * separate refactor and is what makes docs-stable promotion (keyed off + * superdoc's v* tag) live in this workflow. + * + * Per-package adapters live on the descriptor (resumePublish, + * preparePythonSnapshot). The recovery engine is generic; new packages + * only add their descriptor and adapter. * * Usage: * pnpm run release:local [-- --dry-run] @@ -66,6 +73,12 @@ const SDK_PYTHON_PACKAGES = [ 'superdoc-sdk', ]; +// superdoc ships under two npm names: `superdoc` (unscoped) and a +// `@harbour-enterprises/superdoc` mirror published from the same tarball. +// Both must be present at the released version for the publish to count +// as complete - see scripts/publish-superdoc.cjs. +const SUPERDOC_NPM_PACKAGES = ['superdoc', '@harbour-enterprises/superdoc']; + function runInWorkspace(workspaceRoot, command, args, options = {}) { const { capture = false, env = process.env } = options; return execFileSync(command, args, { @@ -666,6 +679,19 @@ function prepareSdkPythonSnapshot(workspaceRoot, tag) { return copySdkPythonArtifacts(workspaceRoot, tag); } +function resumeSuperdocPublish(workspaceRoot, distTag, options = {}) { + const { skipBuild = workspaceRoot === REPO_ROOT } = options; + const args = [join(workspaceRoot, 'scripts/publish-superdoc.cjs'), '--dist-tag', distTag]; + // In a tagged worktree we just ran `pnpm install` and have no build output; + // let the script run its own build. In REPO_ROOT the build already ran + // (release:local does it before invoking the orchestrator, and CI runs + // `Build packages` ahead of this script), so skip the duplicate. + if (skipBuild) { + args.push('--skip-build'); + } + runInWorkspace(workspaceRoot, 'node', args); +} + async function recoverPackageRelease(pkg, { tag, version, distTag, branchRef, initialState = null }) { const targetCommit = getTagCommit(tag); const tagAtHead = isTagAtHead(tag); @@ -803,13 +829,14 @@ const branchRef = `origin/${expectedBranch}`; // Release pipeline // --------------------------------------------------------------------------- -// Stable bundle: CLI -> SDK -> MCP. These three share artifacts (SDK packages -// CLI native binaries; MCP imports SDK + engine code), so they must release -// together in this order. superdoc, react, esign, template-builder, and -// vscode-ext release independently via their own per-package stable workflows. +// Packages are grouped by `chain`. Within a chain, fail-stop applies: a +// failed package skips downstream packages in the same chain. Across +// chains, packages run independently - a tools failure does not block +// the core release and vice versa. const packages = [ { name: 'cli', + chain: 'tools', packageCwd: 'apps/cli', tagPrefix: 'cli-v', tagPattern: 'cli-v*', @@ -818,6 +845,7 @@ const packages = [ }, { name: 'sdk', + chain: 'tools', packageCwd: 'packages/sdk', tagPrefix: 'sdk-v', tagPattern: 'sdk-v*', @@ -828,12 +856,22 @@ const packages = [ }, { name: 'mcp', + chain: 'tools', packageCwd: 'apps/mcp', tagPrefix: 'mcp-v', tagPattern: 'mcp-v*', npmPackages: ['@superdoc-dev/mcp'], resumePublish: resumeMcpPublish, }, + { + name: 'superdoc', + chain: 'core', + packageCwd: 'packages/superdoc', + tagPrefix: 'v', + tagPattern: 'v[0-9]*', + npmPackages: SUPERDOC_NPM_PACKAGES, + resumePublish: resumeSuperdocPublish, + }, ]; /** @@ -847,6 +885,7 @@ const results = new Map(); let hasFailed = false; let deferredReason = ''; +const failedChains = new Set(); function markRemainingSkipped(startIndex) { for (let index = startIndex; index < packages.length; index += 1) { @@ -857,7 +896,7 @@ function markRemainingSkipped(startIndex) { for (let index = 0; index < packages.length; index += 1) { const pkg = packages[index]; - if (hasFailed) { + if (failedChains.has(pkg.chain)) { results.set(pkg.name, { status: 'skipped', newTags: [] }); continue; } @@ -872,6 +911,7 @@ for (let index = 0; index < packages.length; index += 1) { console.error(`\n${pkg.name} recovery failed:\n${error.message || error}`); results.set(pkg.name, { status: 'FAILED', newTags: [] }); hasFailed = true; + failedChains.add(pkg.chain); continue; } } @@ -962,6 +1002,7 @@ for (let index = 0; index < packages.length; index += 1) { const status = newTags.length > 0 ? 'FAILED (partial)' : 'FAILED'; results.set(pkg.name, { status, newTags }); hasFailed = true; + failedChains.add(pkg.chain); } }