diff --git a/.github/workflows/promote-stable-docs.yml b/.github/workflows/promote-stable-docs.yml new file mode 100644 index 0000000000..25e7a09d22 --- /dev/null +++ b/.github/workflows/promote-stable-docs.yml @@ -0,0 +1,71 @@ +# Advances docs-stable when SuperDoc itself ships a stable release. +# +# Scope is intentionally narrow: only release-superdoc.yml. The CLI/SDK/MCP +# bundle and the React/esign/template-builder/vscode-ext wrappers do not +# advance docs-stable, even when they release successfully — docs-stable +# represents the documentation for the stable SuperDoc release, not every +# stable package release. +# +# A successful release-superdoc.yml run is not enough on its own: the run +# could have been a semantic-release no-op (no qualifying commits). Check +# that a v* tag actually appeared between the run's head_sha and +# origin/stable before pushing. +name: 🚀 Promote stable docs + +on: + workflow_run: + workflows: + - "📦 Release superdoc" + types: + - completed + +permissions: + contents: write + +concurrency: + group: promote-stable-docs + cancel-in-progress: false + +jobs: + promote: + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'stable' + runs-on: ubuntu-24.04 + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.generate_token.outputs.token }} + + - name: Detect SuperDoc release + id: detect + env: + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + git fetch origin stable --tags --force + + # v* tags reachable from origin/stable that were NOT reachable from + # the run's head_sha. If empty, the release-superdoc.yml run was a + # semantic-release no-op and docs-stable must not advance. + tags_at_stable=$(git tag --merged origin/stable --list 'v[0-9]*' | sort -u) + tags_at_head=$(git tag --merged "${HEAD_SHA}" --list 'v[0-9]*' | sort -u) + new_tags=$(comm -23 <(echo "${tags_at_stable}") <(echo "${tags_at_head}")) + + if [ -z "${new_tags}" ]; then + echo "released=false" >> "${GITHUB_OUTPUT}" + echo "No new v* tag between ${HEAD_SHA} and origin/stable — release-superdoc was a no-op." + else + echo "released=true" >> "${GITHUB_OUTPUT}" + echo "New SuperDoc tag(s) detected: $(echo "${new_tags}" | tr '\n' ' ')" + fi + + - name: Push docs-stable + if: steps.detect.outputs.released == 'true' + run: git push origin "refs/remotes/origin/stable:refs/heads/docs-stable" diff --git a/.github/workflows/release-esign.yml b/.github/workflows/release-esign.yml index 62ab1dceed..78b6f004a1 100644 --- a/.github/workflows/release-esign.yml +++ b/.github/workflows/release-esign.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next). -# Stable releases are orchestrated centrally by release-stable.yml. +# Auto-releases on push to main (@next) and stable (@latest). name: 📦 Release esign on: push: branches: - main + - stable paths: - 'packages/esign/**' - 'pnpm-workspace.yaml' @@ -17,6 +17,9 @@ permissions: packages: write concurrency: + # Stable releases share the `release-stable` group so @semantic-release/git + # pushes to `stable` serialize across workflows; per-workflow groups would + # let releases race on `git push origin stable`. group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} diff --git a/.github/workflows/release-react.yml b/.github/workflows/release-react.yml index 6f541c507e..6249177163 100644 --- a/.github/workflows/release-react.yml +++ b/.github/workflows/release-react.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next). -# Stable releases are orchestrated centrally by release-stable.yml. +# Auto-releases on push to main (@next) and stable (@latest). name: 📦 Release react on: push: branches: - main + - stable paths: # React declares `superdoc` in dependencies (not peerDependencies), so # existing consumers with lockfiles won't pick up a new core version @@ -27,6 +27,9 @@ permissions: packages: write concurrency: + # Stable releases share the `release-stable` group so @semantic-release/git + # pushes to `stable` serialize across workflows; per-workflow groups would + # let releases race on `git push origin stable`. group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index b56f944904..79f378d6ca 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -1,6 +1,11 @@ -# Sequential stable release orchestrator. -# This is the only workflow that auto-releases on push to stable. -name: 📦 Release stable +# 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). +# +# Out of scope: superdoc, react, esign, template-builder, vscode-ext. Each of +# those has its own per-package stable workflow. +name: 📦 Release stable tooling (CLI/SDK/MCP) on: push: @@ -14,8 +19,14 @@ permissions: id-token: write concurrency: - # Keep [skip ci] writeback runs out of the shared stable queue so they cannot - # replace a real pending stable push while the orchestrator is active. + # All stable release workflows (this bundle + superdoc + react/esign/ + # template-builder/vscode-ext) share the `release-stable` group so that + # @semantic-release/git pushes to `stable` serialize. Per-workflow groups + # would let multiple workflows publish in parallel and race on + # `git push origin stable`, leaving npm/PyPI tarballs published without + # a corresponding tag/commit pushed. + # [skip ci] writeback runs use a per-run group so they cannot evict a real + # pending stable push while the bundle is active. group: ${{ github.event_name == 'push' && contains(github.event.head_commit.message, '[skip ci]') && format('release-stable-skip-{0}', github.run_id) || 'release-stable' }} cancel-in-progress: false @@ -92,14 +103,7 @@ jobs: - name: Build packages run: pnpm run build - - name: Test vscode-ext - run: pnpm --prefix apps/vscode-ext run test - - - name: Snapshot SDK tags before release - id: sdk_tags_before - run: echo "tags=$(git tag --list 'sdk-v*' | sort | tr '\n' ',')" >> "$GITHUB_OUTPUT" - - - name: Release stable packages sequentially + - name: Release stable tooling (CLI -> SDK -> MCP) id: stable_release env: GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} @@ -107,44 +111,12 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - VSCE_PAT: ${{ secrets.VSCE_PAT }} GITHUB_REF_NAME: stable run: node scripts/release-local-stable.mjs - - name: Detect SDK release tag at HEAD - id: sdk_release - run: | - BEFORE="${{ steps.sdk_tags_before.outputs.tags }}" - AFTER=$(git tag --list 'sdk-v*' | sort | tr '\n' ',') - RELEASE_TAG=$(git tag --points-at HEAD --list 'sdk-v*' --sort=-version:refname | head -n 1) - if [ -z "$RELEASE_TAG" ]; then - echo "release_present=false" >> "$GITHUB_OUTPUT" - echo "released=false" >> "$GITHUB_OUTPUT" - echo "version=" >> "$GITHUB_OUTPUT" - echo "dist_tag=" >> "$GITHUB_OUTPUT" - echo "No SDK release tag at HEAD." - else - echo "release_present=true" >> "$GITHUB_OUTPUT" - if [ "$BEFORE" = "$AFTER" ]; then - echo "released=false" >> "$GITHUB_OUTPUT" - else - echo "released=true" >> "$GITHUB_OUTPUT" - fi - VERSION="${RELEASE_TAG#sdk-v}" - if [[ "$VERSION" == *-next.* ]]; then - DIST_TAG="next" - else - DIST_TAG="latest" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT" - if [ "$BEFORE" = "$AFTER" ]; then - echo "SDK release tag already present at HEAD: $RELEASE_TAG" - else - echo "Released SDK v$VERSION" - fi - fi - + # SDK release coordinates come from the orchestrator's step outputs, not + # `git tag --points-at HEAD`. After CLI -> SDK -> MCP, HEAD lands on + # MCP's version commit and the SDK tag is no longer at HEAD. - name: Publish recovered SDK companion Python packages to PyPI if: steps.stable_release.outputs.sdk_python_snapshot_companion_dir != '' uses: pypa/gh-action-pypi-publish@release/v1 @@ -160,32 +132,22 @@ jobs: skip-existing: true - name: Build and verify Python SDK - if: steps.sdk_release.outputs.release_present == 'true' + if: steps.stable_release.outputs.sdk_release_present == 'true' run: node packages/sdk/scripts/build-python-sdk.mjs - name: Publish companion Python packages to PyPI - if: steps.sdk_release.outputs.release_present == 'true' + if: steps.stable_release.outputs.sdk_release_present == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: packages/sdk/langs/python/companion-dist/ skip-existing: true - name: Publish main Python SDK to PyPI - if: steps.sdk_release.outputs.release_present == 'true' + if: steps.stable_release.outputs.sdk_release_present == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: packages/sdk/langs/python/dist/ skip-existing: true - - # Stable docs deploy from `docs-stable`. The orchestrator emits - # `promote_sha` only when the run released (or no-op'd) on the SHA it - # checked out; it stays empty on failure or deferral, so production docs - # never advance past a commit whose packages were not actually published. - - name: Promote stable docs - if: steps.stable_release.outputs.promote_sha != '' - env: - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - PROMOTE_SHA: ${{ steps.stable_release.outputs.promote_sha }} - run: | - set -euo pipefail - git push origin "${PROMOTE_SHA}:refs/heads/docs-stable" + # 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. diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml index 9ba85fbaa9..793858720b 100644 --- a/.github/workflows/release-superdoc.yml +++ b/.github/workflows/release-superdoc.yml @@ -1,5 +1,6 @@ -# Auto-releases on push to main (@next channel). -# Stable releases are orchestrated centrally by release-stable.yml. +# 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). # Manual PR preview: dispatch with pr_number to publish @pr- name: 📦 Release superdoc @@ -7,6 +8,7 @@ on: push: branches: - main + - stable paths: - 'packages/superdoc/**' - 'packages/layout-engine/**' @@ -29,6 +31,9 @@ permissions: pull-requests: write concurrency: + # Stable releases share the `release-stable` group so @semantic-release/git + # pushes to `stable` serialize across workflows; per-workflow groups would + # let releases race on `git push origin stable`. group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} diff --git a/.github/workflows/release-template-builder.yml b/.github/workflows/release-template-builder.yml index 0f95aa59ee..f5dd88024b 100644 --- a/.github/workflows/release-template-builder.yml +++ b/.github/workflows/release-template-builder.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next). -# Stable releases are orchestrated centrally by release-stable.yml. +# Auto-releases on push to main (@next) and stable (@latest). name: 📦 Release template-builder on: push: branches: - main + - stable paths: - 'packages/template-builder/**' - 'pnpm-workspace.yaml' @@ -17,6 +17,9 @@ permissions: packages: write concurrency: + # Stable releases share the `release-stable` group so @semantic-release/git + # pushes to `stable` serialize across workflows; per-workflow groups would + # let releases race on `git push origin stable`. group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} diff --git a/.github/workflows/release-vscode-ext.yml b/.github/workflows/release-vscode-ext.yml index 8a149188ab..c4e0771bb2 100644 --- a/.github/workflows/release-vscode-ext.yml +++ b/.github/workflows/release-vscode-ext.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next). -# Stable releases are orchestrated centrally by release-stable.yml. +# Auto-releases on push to main (@next) and stable (@latest). name: 📦 Release vscode-ext on: push: branches: - main + - stable paths: - 'apps/vscode-ext/**' - 'packages/superdoc/**' @@ -23,6 +23,9 @@ permissions: packages: write concurrency: + # Stable releases share the `release-stable` group so @semantic-release/git + # pushes to `stable` serialize across workflows; per-workflow groups would + # let releases race on `git push origin stable`. group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} diff --git a/apps/mcp/.releaserc.cjs b/apps/mcp/.releaserc.cjs index aea9b09c1c..244bdfc62b 100644 --- a/apps/mcp/.releaserc.cjs +++ b/apps/mcp/.releaserc.cjs @@ -51,6 +51,12 @@ const config = { [ '@semantic-release/exec', { + // MCP's published tarball declares `dist/` in `files` and a + // `dist/index.js` bin. Root `pnpm run build` only runs + // build:superdoc + type-check and does not produce apps/mcp/dist. + // Build MCP here so semantic-release ships a working tarball + // regardless of which workflow drives the release. + prepareCmd: 'pnpm run build', publishCmd: 'pnpm publish --no-git-checks --access public --tag ${nextRelease.channel || "latest"}', }, ], diff --git a/cicd.md b/cicd.md index 44813e95e4..81cf76d812 100644 --- a/cicd.md +++ b/cicd.md @@ -201,12 +201,12 @@ The workflow is `.github/workflows/release-cli.yml`. It analyzes commits across | Command | What it does | |---------|-------------| -| `pnpm run release:local` | Releases **superdoc → CLI → SDK** in sequence on `stable` | +| `pnpm run release:local` | Releases **CLI → SDK → MCP** in sequence on `stable` (matches CI's tooling bundle) | | `pnpm run release:local:superdoc` | Releases superdoc only | | `pnpm run release:local:cli` | Releases CLI only | | `pnpm run release:local:sdk` | Releases SDK only | -All accept `-- --dry-run` to preview without publishing. The combined orchestrator (`release:local`) enforces a `stable` branch guard (override with `--branch=`). +All accept `-- --dry-run` to preview without publishing. The combined orchestrator (`release:local`) enforces a `stable` branch guard (override with `--branch=`). On stable, this matches what CI's tooling bundle workflow does (`.github/workflows/release-stable.yml`); per-package workflows handle superdoc, react, esign, template-builder, and vscode-ext independently. `@semantic-release/git` automatically pushes version commits and tags when releasing on the `stable` branch. This is existing behavior for superdoc, CLI, and SDK. diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index b9d5a1cf2c..057dcd5061 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -176,33 +176,181 @@ test('stable orchestrator prunes before snapshot and reports would-release previ ); }); -test('stable orchestrator releases superdoc, cli, then sdk in order', async () => { +test('stable tooling bundle releases CLI, SDK, then MCP in order', async () => { const content = await readRepoFile('scripts/release-local-stable.mjs'); - assertOrder(content, "name: 'superdoc'", "name: 'cli'", 'scripts/release-local-stable.mjs (superdoc before cli)'); 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( + content.includes("name: 'superdoc'"), + false, + 'scripts/release-local-stable.mjs: superdoc has its own per-package stable workflow, not this bundle', + ); + 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', + ); }); -test('stable workflow isolates skip-ci writebacks from the shared stable queue', async () => { - const content = await readRepoFile('.github/workflows/release-stable.yml'); +test('stable tooling bundle emits SDK release coordinates so Python publish does not depend on HEAD', async () => { + const content = await readRepoFile('scripts/release-local-stable.mjs'); + assert.ok( + content.includes("setStepOutput('sdk_release_present'"), + 'scripts/release-local-stable.mjs: must emit sdk_release_present after the bundle runs', + ); + assert.ok( + content.includes('recordSdkReleaseOutputs'), + 'scripts/release-local-stable.mjs: must record SDK release outputs from the SDK result, not from HEAD', + ); + + const workflow = await readRepoFile('.github/workflows/release-stable.yml'); assert.equal( - content.includes(' paths:'), + workflow.includes("git tag --points-at HEAD --list 'sdk-v*'"), false, - '.github/workflows/release-stable.yml: stable releases must run on every push, not a filtered path subset', + '.github/workflows/release-stable.yml: must not detect SDK at HEAD - MCP commits land on top after the bundle runs', ); assert.ok( - content.includes("contains(github.event.head_commit.message, '[skip ci]')"), + workflow.includes("steps.stable_release.outputs.sdk_release_present == 'true'"), + '.github/workflows/release-stable.yml: Python publish must gate on the orchestrator output, not a HEAD lookup', + ); +}); + +test('stable release workflows serialize on the shared release-stable concurrency group', async () => { + // All stable release workflows must share `release-stable` so + // @semantic-release/git pushes to `stable` queue instead of racing on + // `git push origin stable`. Per-workflow groups parallelize and leave + // npm/PyPI tarballs published with no corresponding tag/commit pushed. + const stableWorkflows = [ + '.github/workflows/release-stable.yml', + '.github/workflows/release-superdoc.yml', + '.github/workflows/release-react.yml', + '.github/workflows/release-esign.yml', + '.github/workflows/release-template-builder.yml', + '.github/workflows/release-vscode-ext.yml', + ]; + + for (const file of stableWorkflows) { + const content = await readRepoFile(file); + assert.ok( + content.includes("'release-stable'"), + `${file}: stable runs must use the shared 'release-stable' concurrency group`, + ); + } + + const bundle = await readRepoFile('.github/workflows/release-stable.yml'); + assert.equal( + bundle.includes(' paths:'), + false, + '.github/workflows/release-stable.yml: tooling bundle must run on every stable push, not a filtered path subset', + ); + assert.ok( + bundle.includes("contains(github.event.head_commit.message, '[skip ci]')"), '.github/workflows/release-stable.yml: concurrency must detect [skip ci] writeback pushes', ); assert.ok( - content.includes("format('release-stable-skip-{0}', github.run_id)"), - '.github/workflows/release-stable.yml: skip-ci writebacks must use a separate concurrency group', + bundle.includes('id-token: write'), + '.github/workflows/release-stable.yml: must request id-token: write so SDK PyPI OIDC publish works', ); assert.ok( - content.includes( + bundle.includes( "if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]')", ), '.github/workflows/release-stable.yml: skip-ci writeback runs must still no-op when they start', ); + + const perPackageStableWorkflows = [ + '.github/workflows/release-superdoc.yml', + '.github/workflows/release-react.yml', + '.github/workflows/release-esign.yml', + '.github/workflows/release-template-builder.yml', + '.github/workflows/release-vscode-ext.yml', + ]; + for (const file of perPackageStableWorkflows) { + const content = await readRepoFile(file); + assert.ok( + /branches:\s*\n\s*-\s*main\s*\n\s*-\s*stable/.test(content), + `${file}: must trigger on push to both main and stable`, + ); + } +}); + +test('MCP releaserc builds the package before publish so the tarball ships dist/', async () => { + const content = await readRepoFile('apps/mcp/.releaserc.cjs'); + assert.ok( + content.includes("prepareCmd: 'pnpm run build'"), + 'apps/mcp/.releaserc.cjs: must build apps/mcp/dist before publish - the root pnpm run build does not produce it', + ); +}); + +test('stable recovery filters prerelease tags so *-next.* never resumes as @latest', async () => { + const content = await readRepoFile('scripts/release-local-stable.mjs'); + assert.ok( + content.includes('listStableMergedTags') && content.includes("isPrereleaseTag"), + 'scripts/release-local-stable.mjs: must expose a stable-only tag filter that excludes -next.* prereleases', + ); + assert.ok( + content.includes("expectedBranch === 'stable'") && content.includes('listStableMergedTags(pkg.tagPattern, branchRef)'), + 'scripts/release-local-stable.mjs: stable recovery must consult the prerelease-filtered list', + ); +}); + +test('release-state probes wrap fetch in bounded retry to absorb transient blips', async () => { + const content = await readRepoFile('scripts/release-local-stable.mjs'); + assert.ok( + content.includes('async function fetchWithRetry'), + 'scripts/release-local-stable.mjs: must define a fetchWithRetry helper', + ); + // Only the helper itself should call bare `fetch(...)`; everywhere else must + // route through fetchWithRetry. Allow exactly one bare-fetch occurrence (the + // implementation inside fetchWithRetry). + const bareFetchCount = (content.match(/[^.\w]fetch\(/g) ?? []).length; + assert.equal( + bareFetchCount, + 1, + `scripts/release-local-stable.mjs: every release-state fetch must go through fetchWithRetry; found ${bareFetchCount} bare fetch(...) calls (expected 1, the one inside fetchWithRetry itself)`, + ); + assert.ok( + /fetchWithRetry\(\s*`https:\/\/api\.github\.com/.test(content), + 'scripts/release-local-stable.mjs: GitHub release probes must retry', + ); + assert.ok( + /fetchWithRetry\(\s*`https:\/\/pypi\.org\/pypi/.test(content), + 'scripts/release-local-stable.mjs: PyPI release probes must retry', + ); +}); + +test('docs promotion is keyed to SuperDoc only', 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', + ); + assert.equal( + /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', + ); + assert.ok( + promoteWorkflow.includes("github.event.workflow_run.conclusion == 'success'"), + '.github/workflows/promote-stable-docs.yml: must only promote on successful SuperDoc runs', + ); + assert.ok( + promoteWorkflow.includes("github.event.workflow_run.head_branch == 'stable'"), + '.github/workflows/promote-stable-docs.yml: must scope promotion to stable', + ); + assert.ok( + promoteWorkflow.includes("git tag --merged origin/stable --list 'v[0-9]*'") && + 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', + ); + assert.ok( + promoteWorkflow.includes('refs/heads/docs-stable'), + '.github/workflows/promote-stable-docs.yml: must push to docs-stable', + ); }); test('stable release workflows and commit filters include shared workspace coverage', async () => { @@ -259,14 +407,18 @@ test('stable orchestrator recovers incomplete merged tags and defers stale check content.includes('sdk_python_snapshot_companion_dir'), 'scripts/release-local-stable.mjs: SDK recovery must expose snapshot Python artifacts for workflow publishing', ); - assert.ok( - content.includes('npm-publish-package.cjs'), - 'scripts/release-local-stable.mjs: reruns must have a generic npm resume path', - ); assert.ok( content.includes('sdk-release-publish.mjs'), 'scripts/release-local-stable.mjs: SDK reruns must resume npm publish explicitly', ); + assert.ok( + content.includes("case 'mcp'") && content.includes('apps/mcp'), + 'scripts/release-local-stable.mjs: MCP reruns must have an explicit resume path', + ); + assert.ok( + content.includes("case 'cli'") && content.includes('apps/cli/scripts/publish.js'), + 'scripts/release-local-stable.mjs: CLI reruns must resume via its dedicated publish script', + ); assert.ok( content.includes(": 'deferred'"), 'scripts/release-local-stable.mjs: stale checkout races must defer instead of failing', @@ -295,37 +447,6 @@ test('stable dry runs skip incomplete-release recovery side effects', async () = ); }); -test('stable workflow publishes recovered SDK Python snapshots before any head-tag SDK publish', async () => { - const content = await readRepoFile('.github/workflows/release-stable.yml'); - assertOrder( - content, - '- name: Publish recovered SDK companion Python packages to PyPI', - '- name: Build and verify Python SDK', - '.github/workflows/release-stable.yml', - ); - assert.ok( - content.includes('id: stable_release'), - '.github/workflows/release-stable.yml: stable orchestrator step must expose recovery outputs', - ); - assert.ok( - content.includes("if: steps.stable_release.outputs.sdk_python_snapshot_companion_dir != ''"), - '.github/workflows/release-stable.yml: recovered SDK snapshot companion wheels must publish even when the sdk tag is not at HEAD', - ); - assert.ok( - content.includes("if: steps.stable_release.outputs.sdk_python_snapshot_main_dir != ''"), - '.github/workflows/release-stable.yml: recovered SDK snapshot root wheel must publish even when the sdk tag is not at HEAD', - ); - assert.ok( - content.includes("if: steps.sdk_release.outputs.release_present == 'true'"), - '.github/workflows/release-stable.yml: SDK Python publish must still key off the sdk tag at HEAD', - ); - assert.equal( - content.includes('Resume Node SDK publish for existing release tag'), - false, - '.github/workflows/release-stable.yml: SDK npm resume now belongs to the stable orchestrator', - ); -}); - test('publish helpers only treat real 404s as missing versions and keep dist-tags consistent', async () => { const genericHelper = await readRepoFile('scripts/npm-publish-package.cjs'); const superdocHelper = await readRepoFile('scripts/publish-superdoc.cjs'); diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index 9ff25bc05c..fb3d416c07 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -1,15 +1,23 @@ #!/usr/bin/env node /** - * Combined stable orchestrator for auto-released stable packages. + * Stable tooling bundle: CLI -> SDK -> MCP, in order. * - * Order matters: - * - Release core/wrappers first so dependent packages see the final stable head. - * - Release CLI before SDK because the SDK publish pipeline packages CLI artifacts. - * - Release SDK last so stable CI can detect an sdk-v* tag at HEAD and resume - * the Python publish step if needed. + * 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. * - * Manual-only stable packages (currently create + mcp) stay outside this flow. + * 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. + * + * 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. * * Usage: * pnpm run release:local [-- --dry-run] @@ -138,8 +146,22 @@ function listMergedTags(pattern, ref = 'HEAD') { : []; } +function isPrereleaseTag(tag) { + return tag.includes('-next.'); +} + +// On stable, prerelease tags (`*-next.*`) created on main are still reachable +// through merge commits but must NEVER be treated as "the latest stable +// release" during recovery. Filter them out at every callsite that consumes +// a stable tag list. +function listStableMergedTags(pattern, ref = 'HEAD') { + return listMergedTags(pattern, ref).filter((tag) => !isPrereleaseTag(tag)); +} + function getPreviousMergedReleaseTag(pattern, currentTag, ref = 'HEAD') { - const tags = listMergedTags(pattern, ref); + const tags = isPrereleaseTag(currentTag) + ? listMergedTags(pattern, ref) + : listStableMergedTags(pattern, ref); const currentIndex = tags.indexOf(currentTag); return currentIndex === -1 ? '' : (tags[currentIndex + 1] ?? ''); } @@ -225,9 +247,11 @@ function isVsCodeExtensionVersionPublished(extensionId, version, workspaceRoot = } async function isPyPiVersionPublished(packageName, version) { - const response = await fetch(`https://pypi.org/pypi/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}/json`, { - headers: { Accept: 'application/json' }, - }); + const response = await fetchWithRetry( + `https://pypi.org/pypi/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}/json`, + { headers: { Accept: 'application/json' } }, + { label: `PyPI ${packageName}@${version}` }, + ); if (response.status === 404) { return false; @@ -273,19 +297,46 @@ function hasGitHubReleaseContext() { return Boolean(process.env.GITHUB_TOKEN) && Boolean(getOriginRepository()); } +// Wraps fetch with bounded retry on transient network errors. The original +// stable bundle failure that drove this refactor was a one-off `fetch failed` +// against api.github.com during a release-state probe; without retry that +// blip cancelled the entire release. +async function fetchWithRetry(url, init, { attempts = 3, label = url } = {}) { + let lastError; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await fetch(url, init); + } catch (error) { + lastError = error; + const cause = error && error.cause ? ` (cause: ${error.cause.code || error.cause.message || error.cause})` : ''; + if (attempt === attempts) { + throw new Error(`fetch ${label} failed after ${attempts} attempts: ${error.message}${cause}`); + } + const backoffMs = 500 * 2 ** (attempt - 1); + console.warn(`fetch ${label} attempt ${attempt}/${attempts} failed: ${error.message}${cause} - retrying in ${backoffMs}ms`); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + } + throw lastError; +} + async function githubJsonRequest(pathname, options = {}) { const { method = 'GET', body, allow404 = false } = options; - const response = await fetch(`https://api.github.com${pathname}`, { - method, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, - 'Content-Type': 'application/json', - 'User-Agent': 'superdoc-release-stable', - 'X-GitHub-Api-Version': GITHUB_API_VERSION, + const response = await fetchWithRetry( + `https://api.github.com${pathname}`, + { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + 'User-Agent': 'superdoc-release-stable', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + }, + body: body ? JSON.stringify(body) : undefined, }, - body: body ? JSON.stringify(body) : undefined, - }); + { label: `GitHub ${method} ${pathname}` }, + ); if (allow404 && response.status === 404) { return null; @@ -305,17 +356,21 @@ async function githubJsonRequest(pathname, options = {}) { async function githubBinaryRequest(url, options = {}) { const { method = 'POST', body, headers = {} } = options; - const response = await fetch(url, { - method, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, - 'User-Agent': 'superdoc-release-stable', - 'X-GitHub-Api-Version': GITHUB_API_VERSION, - ...headers, + const response = await fetchWithRetry( + url, + { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'User-Agent': 'superdoc-release-stable', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + ...headers, + }, + body, }, - body, - }); + { label: `GitHub upload ${method}` }, + ); if (!response.ok) { const details = await response.text(); @@ -493,6 +548,25 @@ function recordSdkPythonSnapshot(snapshot) { setStepOutput('sdk_python_snapshot_main_dir', snapshot.mainDir); } +// Bundle order is CLI -> SDK -> MCP. After MCP releases, HEAD points at MCP's +// version commit, so `git tag --points-at HEAD --list 'sdk-v*'` returns +// nothing. Emit SDK release coordinates directly so the Python publish step +// has stable inputs that don't depend on HEAD. +function recordSdkReleaseOutputs(sdkResult) { + if (!sdkResult || !sdkResult.newTags || sdkResult.newTags.length === 0) { + return; + } + + const tag = sdkResult.newTags[0]; + const version = tag.startsWith('sdk-v') ? tag.slice('sdk-v'.length) : tag; + const distTag = version.includes('-next.') ? 'next' : 'latest'; + + setStepOutput('sdk_release_present', 'true'); + setStepOutput('sdk_release_tag', tag); + setStepOutput('sdk_release_version', version); + setStepOutput('sdk_release_dist_tag', distTag); +} + async function inspectPackageReleaseState(pkg, { tag, version, workspaceRoot = REPO_ROOT }) { let publishComplete = true; if (pkg.vsCodeExtensionId) { @@ -547,37 +621,6 @@ function resumePackagePublish(pkg, distTag, options = {}) { const { workspaceRoot = REPO_ROOT, skipBuild = workspaceRoot === REPO_ROOT } = options; switch (pkg.name) { - case 'superdoc': { - const args = [join(workspaceRoot, 'scripts/publish-superdoc.cjs'), '--dist-tag', distTag]; - if (skipBuild) { - args.push('--skip-build'); - } - runInWorkspace(workspaceRoot, 'node', args); - break; - } - case 'esign': - case 'react': - case 'template-builder': - runInWorkspace(workspaceRoot, 'node', [ - join(workspaceRoot, 'scripts/npm-publish-package.cjs'), - '--package-dir', - pkg.packageCwd, - '--tag', - distTag, - ]); - break; - case 'vscode-ext': - // The vscode-ext webview imports `superdoc`, which resolves to - // packages/superdoc/dist/. The main release flow builds that via the - // root `Build packages` step, but recovery snapshots only run - // `pnpm install` and never build the workspace. Build it here so - // `vsce package` can bundle the webview. - if (!skipBuild) { - runInWorkspace(workspaceRoot, 'pnpm', ['run', 'build']); - } - runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'apps/vscode-ext'), 'run', 'package']); - runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'apps/vscode-ext'), 'run', 'publish:vsce']); - break; case 'cli': runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'apps/cli'), 'run', 'build:prepublish']); runInWorkspace(workspaceRoot, 'node', [join(workspaceRoot, 'apps/cli/scripts/publish.js'), '--tag', distTag]); @@ -590,6 +633,29 @@ function resumePackagePublish(pkg, distTag, options = {}) { '--npm-only', ]); break; + case 'mcp': { + // MCP recovery snapshots only run `pnpm install`, so MCP's build output + // isn't on disk. Rebuild before publishing so the `dist/` tarball + // declared in apps/mcp/package.json files actually ships. + if (!skipBuild) { + runInWorkspace(workspaceRoot, 'pnpm', ['run', 'generate:all']); + runInWorkspace(workspaceRoot, 'pnpm', ['run', 'build:superdoc']); + runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', join(workspaceRoot, 'packages/sdk/langs/node'), 'run', 'build']); + } + const mcpRoot = join(workspaceRoot, 'apps/mcp'); + runInWorkspace(mcpRoot, 'pnpm', ['run', 'build']); + // `pnpm publish` does not honor `--prefix` (passes through to npm and + // errors with EUSAGE); it must run with cwd at the package root. + runInWorkspace(mcpRoot, 'pnpm', [ + 'publish', + '--no-git-checks', + '--access', + 'public', + '--tag', + distTag, + ]); + break; + } default: throw new Error(`No resume command configured for ${pkg.name}`); } @@ -654,7 +720,13 @@ async function recoverPackageRelease(pkg, { tag, version, distTag, branchRef, in } async function maybeRecoverIncompleteRelease(pkg, branchRef) { - const latestTag = listMergedTags(pkg.tagPattern, branchRef)[0]; + // Stable recovery must skip `*-next.*` tags - those are prereleases cut on + // main that flow into stable through merge commits but never represent a + // pending stable publish. Picking one would resume against a npm @next + // package as if it were @latest, polluting the stable channel. + const latestTag = expectedBranch === 'stable' + ? listStableMergedTags(pkg.tagPattern, branchRef)[0] + : listMergedTags(pkg.tagPattern, branchRef)[0]; if (!latestTag) { return null; } @@ -704,7 +776,10 @@ for (const arg of process.argv.slice(2)) { setStepOutput('sdk_python_snapshot_tag', ''); setStepOutput('sdk_python_snapshot_companion_dir', ''); setStepOutput('sdk_python_snapshot_main_dir', ''); -setStepOutput('promote_sha', ''); +setStepOutput('sdk_release_present', 'false'); +setStepOutput('sdk_release_tag', ''); +setStepOutput('sdk_release_version', ''); +setStepOutput('sdk_release_dist_tag', ''); // --------------------------------------------------------------------------- // Branch guard @@ -724,42 +799,11 @@ 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. const packages = [ - { - name: 'superdoc', - packageCwd: 'packages/superdoc', - tagPrefix: 'v', - tagPattern: 'v[0-9]*', - npmPackages: ['superdoc', '@harbour-enterprises/superdoc'], - }, - { - name: 'esign', - packageCwd: 'packages/esign', - tagPrefix: 'esign-v', - tagPattern: 'esign-v*', - npmPackages: ['@superdoc-dev/esign'], - }, - { - name: 'react', - packageCwd: 'packages/react', - tagPrefix: 'react-v', - tagPattern: 'react-v*', - npmPackages: ['@superdoc-dev/react'], - }, - { - name: 'template-builder', - packageCwd: 'packages/template-builder', - tagPrefix: 'template-builder-v', - tagPattern: 'template-builder-v*', - npmPackages: ['@superdoc-dev/template-builder'], - }, - { - name: 'vscode-ext', - packageCwd: 'apps/vscode-ext', - tagPrefix: 'vscode-v', - tagPattern: 'vscode-v*', - vsCodeExtensionId: 'superdoc-dev.superdoc-vscode-ext', - }, { name: 'cli', packageCwd: 'apps/cli', @@ -775,6 +819,13 @@ const packages = [ npmPackages: SDK_NODE_NPM_PACKAGES, pythonPackages: SDK_PYTHON_PACKAGES, }, + { + name: 'mcp', + packageCwd: 'apps/mcp', + tagPrefix: 'mcp-v', + tagPattern: 'mcp-v*', + npmPackages: ['@superdoc-dev/mcp'], + }, ]; /** @@ -910,6 +961,8 @@ for (let index = 0; index < packages.length; index += 1) { // Summary // --------------------------------------------------------------------------- +recordSdkReleaseOutputs(results.get('sdk')); + console.log('\n--- Release Summary ---'); for (const [name, { status, newTags }] of results) { const tagInfo = newTags.length > 0 ? ` [${newTags.join(', ')}]` : ''; @@ -932,13 +985,6 @@ if (deferredReason && !hasFailed) { console.log('\nCurrent run stopped before publishing from a stale checkout. The next queued stable run should continue from the latest branch head.'); } -// Emit the SHA the docs-stable promotion step should publish. Stays empty when -// the run failed or was deferred, so production docs never advance past a -// commit whose packages are not actually published. -if (!hasFailed && !deferredReason) { - setStepOutput('promote_sha', getCurrentHead()); -} - // Remind operator about @semantic-release/git behavior on stable const anyReleased = [...results.values()].some((r) => r.status === 'released'); if (anyReleased && !isDryRun) {