diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 64a43cc3ff..7e59bd1144 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -1,13 +1,11 @@ -# Auto-releases CLI on push to: -# - main (@next channel) -# - stable (@latest channel) +# Auto-releases CLI on push to main (@next channel). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release CLI on: push: branches: - 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. @@ -19,6 +17,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'scripts/semantic-release/**' - 'pnpm-workspace.yaml' - '!**/*.md' @@ -29,7 +28,7 @@ permissions: packages: write concurrency: - group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }} + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: diff --git a/.github/workflows/release-create.yml b/.github/workflows/release-create.yml index fabe7adf34..4bc957a1df 100644 --- a/.github/workflows/release-create.yml +++ b/.github/workflows/release-create.yml @@ -17,8 +17,8 @@ permissions: packages: write concurrency: - group: release-create-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -36,6 +36,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-esign.yml b/.github/workflows/release-esign.yml index 2347fbfd82..8431abc0ae 100644 --- a/.github/workflows/release-esign.yml +++ b/.github/workflows/release-esign.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next) or stable (@latest) +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release esign on: push: branches: - main - - stable paths: - 'packages/esign/**' - 'packages/superdoc/**' @@ -14,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -23,8 +24,8 @@ permissions: packages: write concurrency: - group: release-esign-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -42,6 +43,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index 993cb5fd7e..a8f5f281e7 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -17,8 +17,8 @@ permissions: packages: write concurrency: - group: release-mcp-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -36,6 +36,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-react.yml b/.github/workflows/release-react.yml index 61ebd7b4a8..30bedd8fdf 100644 --- a/.github/workflows/release-react.yml +++ b/.github/workflows/release-react.yml @@ -1,11 +1,11 @@ -# Auto-releases on push to main (@next) or stable (@latest) +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release react on: push: branches: - main - - stable paths: - 'packages/react/**' - 'packages/superdoc/**' @@ -14,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -23,8 +24,8 @@ permissions: packages: write concurrency: - group: release-react-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -42,6 +43,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 33488b2651..103bf688de 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -1,6 +1,5 @@ -# Auto-releases SDK on push to: -# - main (@next channel) -# - stable (@latest channel) +# Auto-releases SDK on push to main (@next channel). +# Stable releases are orchestrated centrally by release-stable.yml. # Also supports manual dispatch as a fallback for one-off releases. name: "\U0001F4E6 Release SDK" @@ -8,7 +7,6 @@ on: push: branches: - main - - stable paths: # Keep in sync with packages/sdk/.releaserc.cjs includePaths (patch-commit-filter). - 'packages/sdk/**' @@ -20,22 +18,23 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'scripts/semantic-release/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: inputs: version: - description: "Version to release (e.g. 1.0.0-next.7). Leave empty to publish the current repo version." + description: 'Version to release (e.g. 1.0.0-next.7). Leave empty to publish the current repo version.' required: false type: string dry-run: - description: "Dry run — build and validate without publishing" + description: 'Dry run — build and validate without publishing' required: false type: boolean default: false npm-tag: - description: "npm dist-tag for Node SDK publish (e.g. latest, next). Only used for manual version override." + description: 'npm dist-tag for Node SDK publish (e.g. latest, next). Only used for manual version override.' required: false type: string default: latest @@ -46,7 +45,7 @@ permissions: id-token: write # PyPI trusted publishing (OIDC) concurrency: - group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }} + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: @@ -85,7 +84,7 @@ jobs: with: node-version-file: .nvmrc cache: pnpm - registry-url: "https://registry.npmjs.org" + registry-url: 'https://registry.npmjs.org' - uses: oven-sh/setup-bun@v2 with: @@ -93,7 +92,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: '3.12' - name: Install canvas system dependencies run: | @@ -233,7 +232,7 @@ jobs: with: node-version-file: .nvmrc cache: pnpm - registry-url: "https://registry.npmjs.org" + registry-url: 'https://registry.npmjs.org' - uses: oven-sh/setup-bun@v2 with: @@ -241,7 +240,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: '3.12' - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000000..08611c6a1a --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,173 @@ +# Sequential stable release orchestrator. +# This is the only workflow that auto-releases on push to stable. +name: 📦 Release stable + +on: + push: + branches: + - stable + workflow_dispatch: + +permissions: + contents: write + packages: write + 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. + 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 + +jobs: + release: + if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]') + runs-on: ubuntu-24.04 + environment: pypi + 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 + ref: stable + token: ${{ steps.generate_token.outputs.token }} + + - name: Refresh stable branch head + run: | + git fetch origin stable --tags + git checkout -B stable origin/stable + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.12 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Cache apt packages + uses: actions/cache@v5 + with: + path: ~/apt-cache + key: apt-canvas-${{ runner.os }}-v1 + + - name: Install canvas system dependencies + run: | + mkdir -p ~/apt-cache + sudo apt-get update + sudo apt-get install -y -o Dir::Cache::Archives="$HOME/apt-cache" \ + build-essential \ + libcairo2-dev \ + libpango1.0-dev \ + libjpeg-dev \ + libgif-dev \ + librsvg2-dev \ + libpixman-1-dev + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Python build tools + run: pip install build + + - 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 + id: stable_release + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + 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 + + - 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 + with: + packages-dir: ${{ steps.stable_release.outputs.sdk_python_snapshot_companion_dir }} + skip-existing: true + + - name: Publish recovered SDK main Python package to PyPI + if: steps.stable_release.outputs.sdk_python_snapshot_main_dir != '' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ${{ steps.stable_release.outputs.sdk_python_snapshot_main_dir }} + skip-existing: true + + - name: Build and verify Python SDK + if: steps.sdk_release.outputs.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' + 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' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/sdk/langs/python/dist/ + skip-existing: true diff --git a/.github/workflows/release-superdoc.yml b/.github/workflows/release-superdoc.yml index 4e6ccbcbda..c3f781da39 100644 --- a/.github/workflows/release-superdoc.yml +++ b/.github/workflows/release-superdoc.yml @@ -1,6 +1,5 @@ -# Auto-releases on push to: -# - main (@next channel) -# - stable (@latest channel) +# Auto-releases on push to main (@next channel). +# Stable releases are orchestrated centrally by release-stable.yml. # Manual PR preview: dispatch with pr_number to publish @pr- name: 📦 Release superdoc @@ -8,7 +7,6 @@ on: push: branches: - main - - stable paths: - 'packages/superdoc/**' - 'packages/layout-engine/**' @@ -16,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -31,7 +30,7 @@ permissions: pull-requests: write concurrency: - group: ${{ github.ref_name == 'stable' && 'release-stable-writeback' || format('{0}-{1}', github.workflow, github.ref) }} + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: diff --git a/.github/workflows/release-template-builder.yml b/.github/workflows/release-template-builder.yml index 5573149cff..a4d4561ca7 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) or stable (@latest) +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release template-builder on: push: branches: - main - - stable paths: - 'packages/template-builder/**' - 'packages/superdoc/**' @@ -14,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -23,8 +24,8 @@ permissions: packages: write concurrency: - group: release-template-builder-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} jobs: release: @@ -42,6 +43,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/.github/workflows/release-vscode-ext.yml b/.github/workflows/release-vscode-ext.yml index 30b127a69c..6e8baecaaa 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) or stable (@latest) +# Auto-releases on push to main (@next). +# Stable releases are orchestrated centrally by release-stable.yml. name: 📦 Release vscode-ext on: push: branches: - main - - stable paths: - 'apps/vscode-ext/**' - 'packages/superdoc/**' @@ -14,6 +14,7 @@ on: - 'packages/ai/**' - 'packages/word-layout/**' - 'packages/preset-geometry/**' + - 'shared/**' - 'pnpm-workspace.yaml' - '!**/*.md' workflow_dispatch: @@ -22,6 +23,10 @@ permissions: contents: write packages: write +concurrency: + group: ${{ github.ref_name == 'stable' && 'release-stable' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: ${{ github.ref_name != 'stable' }} + jobs: release: runs-on: ubuntu-latest @@ -38,6 +43,12 @@ jobs: fetch-depth: 0 token: ${{ steps.generate_token.outputs.token }} + - name: Refresh stable branch head + if: github.ref_name == 'stable' + run: | + git fetch origin "${{ github.ref_name }}" --tags + git checkout -B "${{ github.ref_name }}" "origin/${{ github.ref_name }}" + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 diff --git a/apps/cli/.releaserc.cjs b/apps/cli/.releaserc.cjs index 88175df8ce..950fe38849 100644 --- a/apps/cli/.releaserc.cjs +++ b/apps/cli/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/apps/cli/scripts/publish.js b/apps/cli/scripts/publish.js index 02ab3eeafa..a2c985213c 100644 --- a/apps/cli/scripts/publish.js +++ b/apps/cli/scripts/publish.js @@ -81,6 +81,18 @@ export function isAlreadyPublished(packageName, version, authToken, baseEnv = pr throw new Error(`Failed to check published version for ${packageName}@${version}: ${details}`); } +function ensureDistTag(packageName, version, tag, authToken, baseEnv = process.env) { + const result = spawnSync('npm', ['dist-tag', 'add', `${packageName}@${version}`, tag], { + cwd: repoRoot, + stdio: 'inherit', + env: createNpmEnv(baseEnv, authToken), + }); + + if (result.status !== 0) { + throw new Error(`Failed to ensure dist-tag "${tag}" for ${packageName}@${version}`); + } +} + function runPnpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.env) { const pkgDir = PACKAGE_DIR_BY_NAME[packageName]; if (!pkgDir) { @@ -89,7 +101,8 @@ function runPnpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.e const version = getPackageVersion(packageName); if (!dryRun && isAlreadyPublished(packageName, version, authToken, baseEnv)) { - console.log(`Skipping ${packageName}@${version} (already published).`); + console.log(`Skipping ${packageName}@${version} (already published, ensuring dist-tag "${tag}").`); + ensureDistTag(packageName, version, tag, authToken, baseEnv); return; } diff --git a/apps/vscode-ext/.releaserc.cjs b/apps/vscode-ext/.releaserc.cjs index 6ef515b19e..214a88ceae 100644 --- a/apps/vscode-ext/.releaserc.cjs +++ b/apps/vscode-ext/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/apps/vscode-ext/package.json b/apps/vscode-ext/package.json index 1fc001f91c..33b303b3e9 100644 --- a/apps/vscode-ext/package.json +++ b/apps/vscode-ext/package.json @@ -77,7 +77,7 @@ "build": "pnpm run compile", "watch": "pnpm run compile:ext -- --watch", "package": "vsce package --no-dependencies", - "publish:vsce": "vsce publish --packagePath *.vsix", + "publish:vsce": "vsce publish --packagePath *.vsix --skip-duplicate", "lint": "eslint .", "lint:fix": "eslint --fix .", "format": "prettier --write .", diff --git a/packages/esign/.releaserc.cjs b/packages/esign/.releaserc.cjs index 4eb28dc71d..f4a177a602 100644 --- a/packages/esign/.releaserc.cjs +++ b/packages/esign/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/packages/react/.releaserc.cjs b/packages/react/.releaserc.cjs index d875ffa4f1..2a427aab25 100644 --- a/packages/react/.releaserc.cjs +++ b/packages/react/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/packages/sdk/.releaserc.cjs b/packages/sdk/.releaserc.cjs index 6c7f92118a..bf9c50fca5 100644 --- a/packages/sdk/.releaserc.cjs +++ b/packages/sdk/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/packages/sdk/scripts/__tests__/release-order.test.mjs b/packages/sdk/scripts/__tests__/release-order.test.mjs index 6f8c94d8cb..2622b88e70 100644 --- a/packages/sdk/scripts/__tests__/release-order.test.mjs +++ b/packages/sdk/scripts/__tests__/release-order.test.mjs @@ -94,15 +94,21 @@ test('release-sdk auto workflow resumes releases from sdk-v tags at HEAD', async ); }); -test('release-sdk auto workflow runs on stable and main', async () => { +test('release-sdk auto workflow stays on main while stable uses the central orchestrator', async () => { const content = await readRepoFile('.github/workflows/release-sdk.yml'); + const stableWorkflow = await readRepoFile('.github/workflows/release-stable.yml'); assert.ok( content.includes(' - main'), '.github/workflows/release-sdk.yml: auto-release must continue to run on main', ); - assert.ok( + assert.equal( content.includes(' - stable'), - '.github/workflows/release-sdk.yml: auto-release must run on stable', + false, + '.github/workflows/release-sdk.yml: stable releases should be handled by release-stable.yml', + ); + assert.ok( + stableWorkflow.includes(' - stable'), + '.github/workflows/release-stable.yml: the central stable orchestrator must run on stable', ); }); diff --git a/packages/sdk/scripts/publish-node-sdk.mjs b/packages/sdk/scripts/publish-node-sdk.mjs index 536f45854e..c491a6a68f 100644 --- a/packages/sdk/scripts/publish-node-sdk.mjs +++ b/packages/sdk/scripts/publish-node-sdk.mjs @@ -82,13 +82,26 @@ function isAlreadyPublished(packageName, version, authToken, baseEnv = process.e throw new Error(`Failed to check published version for ${packageName}@${version}: ${details}`); } +function ensureDistTag(packageName, version, tag, authToken, baseEnv = process.env) { + const result = spawnSync('npm', ['dist-tag', 'add', `${packageName}@${version}`, tag], { + cwd: REPO_ROOT, + stdio: 'inherit', + env: createNpmEnv(baseEnv, authToken), + }); + + if (result.status !== 0) { + throw new Error(`Failed to ensure dist-tag "${tag}" for ${packageName}@${version}`); + } +} + function runNpmPublish(packageName, tag, dryRun, authToken, baseEnv = process.env) { const pkgDir = PACKAGE_DIR_BY_NAME[packageName]; if (!pkgDir) throw new Error(`No package directory mapping for ${packageName}`); const version = getPackageVersion(packageName); if (!dryRun && isAlreadyPublished(packageName, version, authToken, baseEnv)) { - console.log(`Skipping ${packageName}@${version} (already published).`); + console.log(`Skipping ${packageName}@${version} (already published, ensuring dist-tag "${tag}").`); + ensureDistTag(packageName, version, tag, authToken, baseEnv); return; } diff --git a/packages/superdoc/.releaserc.cjs b/packages/superdoc/.releaserc.cjs index 88786ab318..3d2f9de69b 100644 --- a/packages/superdoc/.releaserc.cjs +++ b/packages/superdoc/.releaserc.cjs @@ -13,6 +13,7 @@ const SUPERDOC_PACKAGES = [ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ] diff --git a/packages/template-builder/.releaserc.cjs b/packages/template-builder/.releaserc.cjs index bd468610cd..1b624a4de0 100644 --- a/packages/template-builder/.releaserc.cjs +++ b/packages/template-builder/.releaserc.cjs @@ -16,6 +16,7 @@ require('../../scripts/semantic-release/patch-commit-filter.cjs')([ 'packages/ai', 'packages/word-layout', 'packages/preset-geometry', + 'shared', 'pnpm-workspace.yaml', ]); diff --git a/scripts/__tests__/release-local.test.mjs b/scripts/__tests__/release-local.test.mjs index 70df5517de..9ad89f8d05 100644 --- a/scripts/__tests__/release-local.test.mjs +++ b/scripts/__tests__/release-local.test.mjs @@ -34,14 +34,18 @@ test('inferDryRunWouldRelease detects pending release previews', () => { 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', + content.includes('for (const pattern of ALL_TAG_PATTERNS)'), + 'scripts/release-local.mjs: must iterate every known release tag pattern', ); assert.equal( content.includes("filter((p) => p !== ownTagPrefix)"), false, 'scripts/release-local.mjs: must not skip the current package tag namespace', ); + assert.ok( + content.includes("'v[0-9]*'"), + 'scripts/release-local.mjs: superdoc tag matching must not also match vscode release tags', + ); }); test('stable orchestrator prunes before snapshot and reports would-release previews', async () => { @@ -49,7 +53,7 @@ test('stable orchestrator prunes before snapshot and reports would-release previ assertOrder( content, ' pruneLocalOnlyReleaseTags();', - ' const tagsBefore = new Set(listTags(`${pkg.tagPrefix}*`));', + ' const tagsBefore = new Set(listTags(pkg.tagPattern));', 'scripts/release-local-stable.mjs', ); assert.ok( @@ -73,3 +77,171 @@ test('stable orchestrator releases superdoc, cli, then sdk in order', async () = 'scripts/release-local-stable.mjs (cli before sdk)', ); }); + +test('stable workflow isolates skip-ci writebacks from the shared stable queue', async () => { + const content = await readRepoFile('.github/workflows/release-stable.yml'); + assert.equal( + content.includes(' paths:'), + false, + '.github/workflows/release-stable.yml: stable releases must run on every push, not a filtered path subset', + ); + assert.ok( + content.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', + ); + assert.ok( + content.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', + ); +}); + +test('stable release workflows and commit filters include shared workspace coverage', async () => { + const workflowFiles = [ + '.github/workflows/release-superdoc.yml', + '.github/workflows/release-esign.yml', + '.github/workflows/release-react.yml', + '.github/workflows/release-template-builder.yml', + '.github/workflows/release-vscode-ext.yml', + '.github/workflows/release-cli.yml', + '.github/workflows/release-sdk.yml', + ]; + + for (const file of workflowFiles) { + const content = await readRepoFile(file); + assert.ok(content.includes("'shared/**'"), `${file}: shared workspace changes must trigger release workflows`); + } + + const releasercFiles = [ + 'packages/superdoc/.releaserc.cjs', + 'packages/esign/.releaserc.cjs', + 'packages/react/.releaserc.cjs', + 'packages/template-builder/.releaserc.cjs', + 'apps/vscode-ext/.releaserc.cjs', + 'apps/cli/.releaserc.cjs', + 'packages/sdk/.releaserc.cjs', + ]; + + for (const file of releasercFiles) { + const content = await readRepoFile(file); + assert.ok(content.includes("'shared'"), `${file}: semantic-release must analyze shared workspace changes`); + } +}); + +test('stable orchestrator recovers incomplete merged tags and defers stale checkouts', async () => { + const content = await readRepoFile('scripts/release-local-stable.mjs'); + assert.ok( + content.includes("? 'resumed'"), + 'scripts/release-local-stable.mjs: recovered tagged releases must be reported as resumed when no new release is cut', + ); + assert.ok( + content.includes("listMergedTags(pkg.tagPattern, branchRef)[0]"), + 'scripts/release-local-stable.mjs: recovery must inspect the latest merged tag for each package, not only tags at HEAD', + ); + assert.ok( + content.includes("run('git', ['worktree', 'add', '--detach', worktreeRoot, tag])"), + 'scripts/release-local-stable.mjs: recovering older partial releases must use a tagged worktree snapshot', + ); + assert.ok( + content.includes('ensureGitHubRelease'), + 'scripts/release-local-stable.mjs: reruns must repair missing GitHub releases, not only package publishes', + ); + assert.ok( + 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(": 'deferred'"), + 'scripts/release-local-stable.mjs: stale checkout races must defer instead of failing', + ); + assert.ok( + content.includes('StableBranchAdvancedError'), + 'scripts/release-local-stable.mjs: must detect when stable advances during the run', + ); + assert.ok( + content.includes('Current run stopped before publishing from a stale checkout.'), + 'scripts/release-local-stable.mjs: deferred runs must explain why they stopped', + ); +}); + +test('stable dry runs skip incomplete-release recovery side effects', async () => { + const content = await readRepoFile('scripts/release-local-stable.mjs'); + assert.ok( + content.includes('if (!isDryRun) {'), + 'scripts/release-local-stable.mjs: dry runs must gate recovery behind !isDryRun', + ); + assertOrder( + content, + ' if (!isDryRun) {', + ' recoveredRelease = await maybeRecoverIncompleteRelease(pkg, branchRef);', + 'scripts/release-local-stable.mjs', + ); +}); + +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'); + const cliPublish = await readRepoFile('apps/cli/scripts/publish.js'); + const sdkPublish = await readRepoFile('packages/sdk/scripts/publish-node-sdk.mjs'); + + for (const [file, content] of [ + ['scripts/npm-publish-package.cjs', genericHelper], + ['scripts/publish-superdoc.cjs', superdocHelper], + ]) { + assert.ok( + content.includes('E404|Not found|not found|No match found'), + `${file}: missing-version checks must distinguish true 404s from other npm lookup failures`, + ); + } + + assert.ok( + cliPublish.includes('already published, ensuring dist-tag'), + 'apps/cli/scripts/publish.js: reruns must reapply dist-tags for already-published CLI packages', + ); + assert.ok( + sdkPublish.includes('already published, ensuring dist-tag'), + 'packages/sdk/scripts/publish-node-sdk.mjs: reruns must reapply dist-tags for already-published SDK packages', + ); +}); diff --git a/scripts/npm-publish-package.cjs b/scripts/npm-publish-package.cjs new file mode 100644 index 0000000000..85fd5db7e2 --- /dev/null +++ b/scripts/npm-publish-package.cjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +const { execFileSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); + +const rootDir = path.resolve(__dirname, '..'); +const defaultRegistry = process.env.NPM_CONFIG_REGISTRY || 'https://registry.npmjs.org'; + +const run = (command, args, cwd = rootDir) => { + execFileSync(command, args, { stdio: 'inherit', cwd }); +}; + +const isVersionLookupNotFoundError = (error) => { + const details = [error?.stderr, error?.stdout, error?.message] + .filter(Boolean) + .join('\n'); + return /E404|Not found|not found|No match found/i.test(details); +}; + +const isVersionPublished = (packageName, version) => { + try { + execFileSync( + 'pnpm', + ['view', `${packageName}@${version}`, 'version', '--registry', defaultRegistry], + { stdio: 'pipe' }, + ); + return true; + } catch (error) { + if (isVersionLookupNotFoundError(error)) { + return false; + } + throw error; + } +}; + +const getPackageMetadata = (packageDir) => { + const pkgPath = path.join(rootDir, packageDir, 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (!pkg.name || !pkg.version) { + throw new Error(`Expected ${packageDir}/package.json to include name and version.`); + } + return pkg; +}; + +const publishPackage = ({ packageDir, tag = 'latest', logger = console }) => { + const cwd = path.join(rootDir, packageDir); + const pkg = getPackageMetadata(packageDir); + + if (isVersionPublished(pkg.name, pkg.version)) { + logger.log(`${pkg.name}@${pkg.version} already published, ensuring dist-tag "${tag}" and skipping.`); + run('pnpm', ['dist-tag', 'add', `${pkg.name}@${pkg.version}`, tag, '--registry', defaultRegistry]); + return; + } + + logger.log(`Publishing ${pkg.name} with dist-tag "${tag}"...`); + run('pnpm', ['publish', '--access', 'public', '--tag', tag, '--no-git-checks'], cwd); +}; + +const parseArgs = (argv) => { + let packageDir = ''; + let tag = 'latest'; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--package-dir') { + packageDir = argv[index + 1] || ''; + index += 1; + } else if (arg === '--tag') { + tag = argv[index + 1] || tag; + index += 1; + } + } + + if (!packageDir) { + throw new Error('--package-dir is required'); + } + + return { packageDir, tag }; +}; + +if (require.main === module) { + try { + publishPackage(parseArgs(process.argv.slice(2))); + } catch (error) { + console.error(error.message || error); + process.exit(1); + } +} + +module.exports = { + isVersionLookupNotFoundError, + isVersionPublished, + publishPackage, +}; diff --git a/scripts/publish-react.cjs b/scripts/publish-react.cjs index 4c8f3cb414..9244fb4a43 100644 --- a/scripts/publish-react.cjs +++ b/scripts/publish-react.cjs @@ -1,20 +1,15 @@ #!/usr/bin/env node -const { execFileSync } = require('node:child_process'); -const path = require('node:path'); - -const rootDir = path.resolve(__dirname, '..'); -const reactDir = path.join(rootDir, 'packages', 'react'); +const { publishPackage } = require('./npm-publish-package.cjs'); module.exports = { publish: async (_pluginConfig, context) => { const { nextRelease, logger = console } = context; const distTag = (nextRelease && nextRelease.channel) || 'latest'; - logger.log(`Publishing @superdoc-dev/react with dist-tag "${distTag}"...`); - execFileSync( - 'pnpm', - ['publish', '--access', 'public', '--tag', distTag, '--no-git-checks'], - { stdio: 'inherit', cwd: reactDir } - ); + publishPackage({ + packageDir: 'packages/react', + tag: distTag, + logger, + }); }, }; diff --git a/scripts/publish-superdoc.cjs b/scripts/publish-superdoc.cjs index 0ffed8bf2f..4d6a123019 100644 --- a/scripts/publish-superdoc.cjs +++ b/scripts/publish-superdoc.cjs @@ -23,6 +23,13 @@ const runCapture = (command, args, cwd) => { return execFileSync(command, args, { cwd, encoding: 'utf8' }).trim(); }; +const isVersionLookupNotFoundError = (error) => { + const details = [error?.stderr, error?.stdout, error?.message] + .filter(Boolean) + .join('\n'); + return /E404|Not found|not found|No match found/i.test(details); +}; + const isVersionPublished = (packageName, version) => { try { execFileSync( @@ -32,7 +39,7 @@ const isVersionPublished = (packageName, version) => { ); return true; } catch (error) { - if (error.status === 1) { + if (isVersionLookupNotFoundError(error)) { return false; } throw error; @@ -59,7 +66,7 @@ const publishScopedMirror = (packageJson, distTag, logger = console) => { if (isVersionPublished(scopedName, packageJson.version)) { logger.log(`${scopedName}@${packageJson.version} already published, ensuring dist-tag "${distTag}" and skipping.`); - run('pnpm', ['dist-tag', 'add', `${scopedName}@${packageJson.version}`, distTag], rootDir); + run('pnpm', ['dist-tag', 'add', `${scopedName}@${packageJson.version}`, distTag, '--registry', defaultRegistry], rootDir); return; } @@ -115,7 +122,7 @@ const publishPackages = ({ if (publishUnscoped) { if (isVersionPublished(packageJson.name, packageJson.version)) { logger.log(`superdoc@${packageJson.version} already published, ensuring dist-tag "${distTag}" and skipping.`); - run('pnpm', ['dist-tag', 'add', `${packageJson.name}@${packageJson.version}`, distTag], rootDir); + run('pnpm', ['dist-tag', 'add', `${packageJson.name}@${packageJson.version}`, distTag, '--registry', defaultRegistry], rootDir); } else { logger.log(`Publishing superdoc with dist-tag "${distTag}"...`); run('pnpm', ['publish', '--access', 'public', '--tag', distTag, '--no-git-checks'], superdocDir); diff --git a/scripts/release-local-stable.mjs b/scripts/release-local-stable.mjs index f5471f2b54..b4fe84ca04 100644 --- a/scripts/release-local-stable.mjs +++ b/scripts/release-local-stable.mjs @@ -1,7 +1,15 @@ #!/usr/bin/env node /** - * Combined stable orchestrator — releases superdoc, CLI, then SDK in sequence. + * Combined stable orchestrator for auto-released stable packages. + * + * 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. + * + * Manual-only stable packages (currently create + mcp) stay outside this flow. * * Usage: * pnpm run release:local [-- --dry-run] @@ -9,24 +17,667 @@ * * Flags: * --branch= Override the expected branch (default: stable) - * All other flags are forwarded to both semantic-release invocations. + * All other flags are forwarded to every semantic-release invocation. */ import { execFileSync } from 'node:child_process'; -import { dirname, resolve } from 'node:path'; +import { appendFileSync, cpSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { listTags, pruneLocalOnlyReleaseTags, runSemanticRelease } from './release-local.mjs'; +import { listTags, pruneLocalOnlyReleaseTags, run, runSemanticRelease } from './release-local.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '..'); +const GITHUB_API_VERSION = '2022-11-28'; + +const CLI_NPM_PACKAGES = [ + '@superdoc-dev/cli-darwin-arm64', + '@superdoc-dev/cli-darwin-x64', + '@superdoc-dev/cli-linux-x64', + '@superdoc-dev/cli-linux-arm64', + '@superdoc-dev/cli-windows-x64', + '@superdoc-dev/cli', +]; + +const SDK_NODE_NPM_PACKAGES = [ + '@superdoc-dev/sdk-darwin-arm64', + '@superdoc-dev/sdk-darwin-x64', + '@superdoc-dev/sdk-linux-x64', + '@superdoc-dev/sdk-linux-arm64', + '@superdoc-dev/sdk-windows-x64', + '@superdoc-dev/sdk', +]; + +const SDK_PYTHON_PACKAGES = [ + 'superdoc-sdk-cli-darwin-arm64', + 'superdoc-sdk-cli-darwin-x64', + 'superdoc-sdk-cli-linux-x64', + 'superdoc-sdk-cli-linux-arm64', + 'superdoc-sdk-cli-windows-x64', + 'superdoc-sdk', +]; + +function runInWorkspace(workspaceRoot, command, args, options = {}) { + const { capture = false, env = process.env } = options; + return execFileSync(command, args, { + cwd: workspaceRoot, + env, + encoding: 'utf8', + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); +} + +function captureInWorkspace(workspaceRoot, command, args, options = {}) { + const { env = process.env } = options; + try { + return { + stdout: execFileSync(command, args, { + cwd: workspaceRoot, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + stderr: '', + status: 0, + error: null, + }; + } catch (error) { + return { + stdout: typeof error.stdout === 'string' ? error.stdout : String(error.stdout ?? ''), + stderr: typeof error.stderr === 'string' ? error.stderr : String(error.stderr ?? ''), + status: Number.isInteger(error.status) ? error.status : null, + error, + }; + } +} -function getCurrentBranch() { +function getCurrentBranch(cwd = REPO_ROOT) { return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { + cwd, + encoding: 'utf8', + }).trim(); +} + +function getCurrentHead(cwd = REPO_ROOT) { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd, + encoding: 'utf8', + }).trim(); +} + +function getRemoteHead(branchName) { + return execFileSync('git', ['rev-parse', `origin/${branchName}`], { cwd: REPO_ROOT, encoding: 'utf8', }).trim(); } +function getTagCommit(tag) { + return execFileSync('git', ['rev-list', '-1', tag], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); +} + +function listMergedTags(pattern, ref = 'HEAD') { + const output = execFileSync( + 'git', + ['tag', '--merged', ref, '--list', pattern, '--sort=-version:refname'], + { + cwd: REPO_ROOT, + encoding: 'utf8', + }, + ).trim(); + + return output + ? output + .split('\n') + .map((tag) => tag.trim()) + .filter(Boolean) + : []; +} + +function getPreviousMergedReleaseTag(pattern, currentTag, ref = 'HEAD') { + const tags = listMergedTags(pattern, ref); + const currentIndex = tags.indexOf(currentTag); + return currentIndex === -1 ? '' : (tags[currentIndex + 1] ?? ''); +} + +function getDistTagForVersion(version) { + return version.includes('-next.') ? 'next' : 'latest'; +} + +function getVersionFromTag(pkg, tag) { + return tag.startsWith(pkg.tagPrefix) ? tag.slice(pkg.tagPrefix.length) : tag; +} + +function isLikelyStaleHeadFailure(error) { + const message = error && typeof error.message === 'string' ? error.message : String(error); + return ( + message.includes('failed to push some refs') || + message.includes('non-fast-forward') || + message.includes('Updates were rejected because the remote contains work that you do not have locally') || + message.includes('Updates were rejected because the tip of your current branch is behind') + ); +} + +function isVersionLookupNotFound(details) { + return /E404|Not found|not found|No match found/i.test(details); +} + +function isNpmVersionPublished(packageName, version, workspaceRoot = REPO_ROOT) { + const result = captureInWorkspace( + workspaceRoot, + 'npm', + ['view', `${packageName}@${version}`, 'version'], + ); + + if (result.status === 0) { + return true; + } + + const details = `${result.stderr}\n${result.stdout}`.trim(); + if (isVersionLookupNotFound(details)) { + return false; + } + + if (result.error) { + throw result.error; + } + + throw new Error(`Failed to check published version for ${packageName}@${version}: ${details || 'unknown error'}`); +} + +function isVsCodeExtensionNotFound(details) { + return /not found|does not exist|404/i.test(details); +} + +function isVsCodeExtensionVersionPublished(extensionId, version, workspaceRoot = REPO_ROOT) { + const extensionPrefix = join(workspaceRoot, 'apps/vscode-ext'); + const result = captureInWorkspace( + workspaceRoot, + 'pnpm', + ['--prefix', extensionPrefix, 'exec', 'vsce', 'show', extensionId, '--json'], + ); + + if (result.status !== 0) { + const details = `${result.stderr}\n${result.stdout}`.trim(); + if (isVsCodeExtensionNotFound(details)) { + return false; + } + if (result.error) { + throw result.error; + } + throw new Error(`Failed to inspect VS Code extension ${extensionId}: ${details || 'unknown error'}`); + } + + const metadata = JSON.parse(result.stdout); + const versions = Array.isArray(metadata.versions) ? metadata.versions : []; + if (versions.length === 0 && typeof metadata.version === 'string') { + return metadata.version === version; + } + + return versions.some((entry) => { + if (typeof entry === 'string') return entry === version; + return entry && typeof entry.version === 'string' ? entry.version === version : false; + }); +} + +async function isPyPiVersionPublished(packageName, version) { + const response = await fetch(`https://pypi.org/pypi/${encodeURIComponent(packageName)}/${encodeURIComponent(version)}/json`, { + headers: { Accept: 'application/json' }, + }); + + if (response.status === 404) { + return false; + } + + if (!response.ok) { + const details = await response.text(); + throw new Error(`Failed to check PyPI version for ${packageName}@${version}: ${response.status} ${details}`); + } + + return true; +} + +function getOriginRepository() { + const repository = process.env.GITHUB_REPOSITORY; + if (repository) { + return repository; + } + + try { + const remoteUrl = execFileSync('git', ['remote', 'get-url', 'origin'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }).trim(); + + const sshMatch = remoteUrl.match(/^git@github\.com:(.+?)(?:\.git)?$/); + if (sshMatch) { + return sshMatch[1]; + } + + const httpsMatch = remoteUrl.match(/^https:\/\/github\.com\/(.+?)(?:\.git)?$/); + if (httpsMatch) { + return httpsMatch[1]; + } + } catch { + return ''; + } + + return ''; +} + +function hasGitHubReleaseContext() { + return Boolean(process.env.GITHUB_TOKEN) && Boolean(getOriginRepository()); +} + +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, + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (allow404 && response.status === 404) { + return null; + } + + if (!response.ok) { + const details = await response.text(); + throw new Error(`GitHub API ${method} ${pathname} failed: ${response.status} ${details}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); +} + +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, + }, + body, + }); + + if (!response.ok) { + const details = await response.text(); + throw new Error(`GitHub upload ${method} ${url} failed: ${response.status} ${details}`); + } + + return response.json(); +} + +async function getGitHubReleaseByTag(tag) { + if (!hasGitHubReleaseContext()) { + return null; + } + + const repository = getOriginRepository(); + return githubJsonRequest(`/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, { allow404: true }); +} + +async function generateGitHubReleaseNotes({ tag, targetCommit, previousTag }) { + const repository = getOriginRepository(); + try { + return await githubJsonRequest(`/repos/${repository}/releases/generate-notes`, { + method: 'POST', + body: { + tag_name: tag, + target_commitish: targetCommit, + ...(previousTag ? { previous_tag_name: previousTag } : {}), + }, + }); + } catch (error) { + console.warn(`Failed to generate GitHub release notes for ${tag}: ${error.message}`); + return { name: tag, body: `Recovered release metadata for ${tag}.` }; + } +} + +function getExpectedReleaseAssets(pkg, workspaceRoot) { + if (pkg.name !== 'vscode-ext') { + return []; + } + + const extensionDir = join(workspaceRoot, 'apps/vscode-ext'); + let assets = readdirSync(extensionDir) + .filter((entry) => entry.endsWith('.vsix')) + .map((entry) => join(extensionDir, entry)); + + if (assets.length > 0) { + return assets; + } + + runInWorkspace(workspaceRoot, 'pnpm', ['--prefix', extensionDir, 'run', 'package']); + assets = readdirSync(extensionDir) + .filter((entry) => entry.endsWith('.vsix')) + .map((entry) => join(extensionDir, entry)); + + if (assets.length === 0) { + throw new Error('Expected VS Code packaging to produce a .vsix artifact.'); + } + + return assets; +} + +async function ensureGitHubReleaseAssets(release, pkg, workspaceRoot) { + const assets = getExpectedReleaseAssets(pkg, workspaceRoot); + const existing = new Set((release.assets ?? []).map((asset) => asset.name)); + const uploadBaseUrl = release.upload_url.replace(/\{\?name,label\}$/, ''); + + for (const assetPath of assets) { + const assetName = basename(assetPath); + if (existing.has(assetName)) { + continue; + } + + const binary = readFileSync(assetPath); + await githubBinaryRequest(`${uploadBaseUrl}?name=${encodeURIComponent(assetName)}`, { + body: binary, + headers: { + 'Content-Length': String(binary.byteLength), + 'Content-Type': 'application/octet-stream', + }, + }); + } +} + +function isGitHubReleaseComplete(pkg, release) { + if (!hasGitHubReleaseContext()) { + return true; + } + + if (!release) { + return false; + } + + if (pkg.name === 'vscode-ext') { + return Array.isArray(release.assets) && release.assets.some((asset) => asset.name.endsWith('.vsix')); + } + + return true; +} + +async function ensureGitHubRelease(pkg, { tag, targetCommit, previousTag, workspaceRoot }) { + if (!hasGitHubReleaseContext()) { + return null; + } + + const repository = getOriginRepository(); + let release = await getGitHubReleaseByTag(tag); + if (!release) { + const generated = await generateGitHubReleaseNotes({ tag, targetCommit, previousTag }); + release = await githubJsonRequest(`/repos/${repository}/releases`, { + method: 'POST', + body: { + tag_name: tag, + target_commitish: targetCommit, + name: generated.name || tag, + body: generated.body || '', + draft: false, + prerelease: false, + }, + }); + } + + if (pkg.name === 'vscode-ext') { + await ensureGitHubReleaseAssets(release, pkg, workspaceRoot); + release = await getGitHubReleaseByTag(tag); + } + + return release; +} + +function installWorkspaceDependencies(workspaceRoot) { + console.log(`Installing dependencies in tagged snapshot: ${workspaceRoot}`); + runInWorkspace(workspaceRoot, 'pnpm', ['install', '--frozen-lockfile']); +} + +async function withTemporaryWorktree(tag, callback) { + const tempRoot = mkdtempSync(join(tmpdir(), 'stable-release-')); + const worktreeRoot = join(tempRoot, 'worktree'); + + run('git', ['worktree', 'add', '--detach', worktreeRoot, tag]); + + try { + return await callback(worktreeRoot); + } finally { + run('git', ['worktree', 'remove', '--force', worktreeRoot]); + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function copySdkPythonArtifacts(workspaceRoot, tag) { + const tempRoot = mkdtempSync(join(tmpdir(), `sdk-python-${tag.replace(/[^a-zA-Z0-9.-]/g, '-')}-`)); + const companionDir = join(tempRoot, 'companion-dist'); + const mainDir = join(tempRoot, 'dist'); + + cpSync(join(workspaceRoot, 'packages/sdk/langs/python/companion-dist'), companionDir, { recursive: true }); + cpSync(join(workspaceRoot, 'packages/sdk/langs/python/dist'), mainDir, { recursive: true }); + + return { tag, companionDir, mainDir }; +} + +function setStepOutput(name, value) { + if (!process.env.GITHUB_OUTPUT) { + return; + } + + appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value ?? ''}\n`); +} + +function recordSdkPythonSnapshot(snapshot) { + if (!snapshot) { + return; + } + + setStepOutput('sdk_python_snapshot_tag', snapshot.tag); + setStepOutput('sdk_python_snapshot_companion_dir', snapshot.companionDir); + setStepOutput('sdk_python_snapshot_main_dir', snapshot.mainDir); +} + +async function inspectPackageReleaseState(pkg, { tag, version, workspaceRoot = REPO_ROOT }) { + let publishComplete = true; + if (pkg.vsCodeExtensionId) { + publishComplete = isVsCodeExtensionVersionPublished(pkg.vsCodeExtensionId, version, workspaceRoot); + } else if (pkg.npmPackages) { + publishComplete = pkg.npmPackages.every((packageName) => + isNpmVersionPublished(packageName, version, workspaceRoot), + ); + } + + const release = hasGitHubReleaseContext() ? await getGitHubReleaseByTag(tag) : null; + const githubComplete = isGitHubReleaseComplete(pkg, release); + + let sdkPythonPublished = true; + if (pkg.pythonPackages) { + const publishedFlags = await Promise.all( + pkg.pythonPackages.map((packageName) => isPyPiVersionPublished(packageName, version)), + ); + sdkPythonPublished = publishedFlags.every(Boolean); + } + + return { + publishComplete, + githubComplete, + sdkPythonPublished, + release, + }; +} + +function isTagAtHead(tag) { + return getTagCommit(tag) === getCurrentHead(); +} + +function refreshRemoteState(branchName) { + run('git', ['fetch', 'origin', branchName, '--tags']); +} + +class StableBranchAdvancedError extends Error {} + +function ensureBranchHeadCurrent(branchName) { + const localHead = getCurrentHead(); + const remoteHead = getRemoteHead(branchName); + + if (localHead !== remoteHead) { + throw new StableBranchAdvancedError( + `Branch ${branchName} advanced during the release run (local ${localHead.slice(0, 12)} vs remote ${remoteHead.slice(0, 12)}).`, + ); + } +} + +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': + 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]); + break; + case 'sdk': + runInWorkspace(workspaceRoot, 'node', [ + join(workspaceRoot, 'packages/sdk/scripts/sdk-release-publish.mjs'), + '--tag', + distTag, + '--npm-only', + ]); + break; + default: + throw new Error(`No resume command configured for ${pkg.name}`); + } +} + +async function recoverPackageRelease(pkg, { tag, version, distTag, branchRef, initialState = null }) { + const targetCommit = getTagCommit(tag); + const tagAtHead = isTagAtHead(tag); + + const recoverInWorkspace = async (workspaceRoot, { snapshot }) => { + if (snapshot) { + installWorkspaceDependencies(workspaceRoot); + } + + let state = initialState ?? (await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot })); + const needsSnapshotPython = pkg.name === 'sdk' && !state.sdkPythonPublished && snapshot; + const needsPublishResume = !state.publishComplete || needsSnapshotPython; + + if (needsPublishResume) { + console.log( + `${pkg.name} release ${tag} is incomplete; resuming publish (${distTag})${snapshot ? ' from tagged snapshot' : ''}.`, + ); + resumePackagePublish(pkg, distTag, { workspaceRoot, skipBuild: !snapshot }); + state = await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot }); + } + + let sdkPythonSnapshot = null; + if (pkg.name === 'sdk' && !state.sdkPythonPublished && snapshot) { + console.log(`Preparing Python SDK artifacts for recovered ${tag} snapshot.`); + runInWorkspace(workspaceRoot, 'node', [join(workspaceRoot, 'packages/sdk/scripts/build-python-sdk.mjs')]); + sdkPythonSnapshot = copySdkPythonArtifacts(workspaceRoot, tag); + } + + const previousTag = getPreviousMergedReleaseTag(pkg.tagPattern, tag, branchRef); + await ensureGitHubRelease(pkg, { + tag, + targetCommit, + previousTag, + workspaceRoot, + }); + + const finalState = await inspectPackageReleaseState(pkg, { tag, version, workspaceRoot }); + const readyForWorkflowPython = pkg.name !== 'sdk' || finalState.sdkPythonPublished || sdkPythonSnapshot || tagAtHead; + const missingParts = [ + finalState.publishComplete ? '' : 'package publish', + finalState.githubComplete ? '' : 'GitHub release', + readyForWorkflowPython ? '' : 'Python snapshot artifacts', + ].filter(Boolean); + + if (missingParts.length > 0) { + throw new Error(`Recovery for ${pkg.name} ${tag} is still incomplete: ${missingParts.join(', ')}`); + } + + return { tag, version, distTag, sdkPythonSnapshot }; + }; + + if (tagAtHead) { + return recoverInWorkspace(REPO_ROOT, { snapshot: false }); + } + + return withTemporaryWorktree(tag, (workspaceRoot) => recoverInWorkspace(workspaceRoot, { snapshot: true })); +} + +async function maybeRecoverIncompleteRelease(pkg, branchRef) { + const latestTag = listMergedTags(pkg.tagPattern, branchRef)[0]; + if (!latestTag) { + return null; + } + + const version = getVersionFromTag(pkg, latestTag); + const distTag = getDistTagForVersion(version); + const state = await inspectPackageReleaseState(pkg, { + tag: latestTag, + version, + }); + + const needsSnapshotPython = pkg.name === 'sdk' && !state.sdkPythonPublished && !isTagAtHead(latestTag); + const needsRecovery = !state.publishComplete || !state.githubComplete || needsSnapshotPython; + if (!needsRecovery) { + return null; + } + + console.log( + `\nRecovering incomplete ${pkg.name} release ${latestTag}${isTagAtHead(latestTag) ? ' from current HEAD' : ' from tagged snapshot'}.`, + ); + const recovery = await recoverPackageRelease(pkg, { + tag: latestTag, + version, + distTag, + branchRef, + initialState: state, + }); + recordSdkPythonSnapshot(recovery.sdkPythonSnapshot); + return recovery; +} + // --------------------------------------------------------------------------- // Parse own flags vs forwarded flags // --------------------------------------------------------------------------- @@ -42,6 +693,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', ''); + // --------------------------------------------------------------------------- // Branch guard // --------------------------------------------------------------------------- @@ -54,20 +709,68 @@ if (currentBranch !== expectedBranch) { } const isDryRun = forwardedArgs.includes('--dry-run') || forwardedArgs.includes('-d'); +const branchRef = `origin/${expectedBranch}`; // --------------------------------------------------------------------------- // Release pipeline // --------------------------------------------------------------------------- const packages = [ - { name: 'superdoc', packageCwd: 'packages/superdoc', tagPrefix: 'v' }, - { name: 'cli', packageCwd: 'apps/cli', tagPrefix: 'cli-v' }, - { name: 'sdk', packageCwd: 'packages/sdk', tagPrefix: 'sdk-v' }, + { + 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', + tagPrefix: 'cli-v', + tagPattern: 'cli-v*', + npmPackages: CLI_NPM_PACKAGES, + }, + { + name: 'sdk', + packageCwd: 'packages/sdk', + tagPrefix: 'sdk-v', + tagPattern: 'sdk-v*', + npmPackages: SDK_NODE_NPM_PACKAGES, + pythonPackages: SDK_PYTHON_PACKAGES, + }, ]; /** * @typedef {object} PackageResult - * @property {'released' | 'would-release' | 'no-op' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status + * @property {'released' | 'resumed' | 'would-release' | 'no-op' | 'deferred' | 'FAILED (partial)' | 'FAILED' | 'skipped'} status * @property {string[]} newTags - Tags created during this release attempt. */ @@ -75,13 +778,52 @@ const packages = [ const results = new Map(); let hasFailed = false; +let deferredReason = ''; + +function markRemainingSkipped(startIndex) { + for (let index = startIndex; index < packages.length; index += 1) { + results.set(packages[index].name, { status: 'skipped', newTags: [] }); + } +} + +for (let index = 0; index < packages.length; index += 1) { + const pkg = packages[index]; -for (const pkg of packages) { if (hasFailed) { results.set(pkg.name, { status: 'skipped', newTags: [] }); continue; } + refreshRemoteState(expectedBranch); + + let recoveredRelease = null; + if (!isDryRun) { + try { + recoveredRelease = await maybeRecoverIncompleteRelease(pkg, branchRef); + } catch (error) { + console.error(`\n${pkg.name} recovery failed:\n${error.message || error}`); + results.set(pkg.name, { status: 'FAILED', newTags: [] }); + hasFailed = true; + continue; + } + } + + try { + ensureBranchHeadCurrent(expectedBranch); + } catch (error) { + if (error instanceof StableBranchAdvancedError) { + deferredReason = error.message; + console.log(`\n${deferredReason}`); + results.set(pkg.name, { + status: recoveredRelease ? 'resumed' : 'deferred', + newTags: recoveredRelease ? [recoveredRelease.tag] : [], + }); + markRemainingSkipped(index + 1); + break; + } + throw error; + } + // 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. @@ -90,24 +832,65 @@ for (const pkg of packages) { // 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}*`)); + const tagsBefore = new Set(listTags(pkg.tagPattern)); try { const runResult = runSemanticRelease(pkg.packageCwd, forwardedArgs); - const tagsAfter = new Set(listTags(`${pkg.tagPrefix}*`)); + const tagsAfter = new Set(listTags(pkg.tagPattern)); 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 }); + ? runResult.wouldRelease + ? 'would-release' + : recoveredRelease + ? 'resumed' + : 'no-op' + : newTags.length > 0 + ? 'released' + : recoveredRelease + ? 'resumed' + : 'no-op'; + const reportedTags = newTags.length > 0 ? newTags : recoveredRelease ? [recoveredRelease.tag] : []; + results.set(pkg.name, { status, newTags: reportedTags }); } catch (error) { const message = error && typeof error.message === 'string' ? error.message : String(error); + const tagsAfter = new Set(listTags(pkg.tagPattern)); + const newTags = [...tagsAfter].filter((t) => !tagsBefore.has(t)); + + if (isLikelyStaleHeadFailure(error) && newTags.length === 0) { + deferredReason = `Stable advanced while releasing ${pkg.name}; deferring the remaining work to the queued run on the latest stable head.`; + console.log(`\n${deferredReason}`); + results.set(pkg.name, { + status: recoveredRelease ? 'resumed' : 'deferred', + newTags: recoveredRelease ? [recoveredRelease.tag] : [], + }); + markRemainingSkipped(index + 1); + break; + } + 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)); + if (newTags.length > 0) { + const recoveryTag = newTags[0]; + const recoveryVersion = getVersionFromTag(pkg, recoveryTag); + const recoveryDistTag = getDistTagForVersion(recoveryVersion); + + try { + console.log(`Attempting recovery for tagged ${pkg.name} release ${recoveryTag}.`); + const recovery = await recoverPackageRelease(pkg, { + tag: recoveryTag, + version: recoveryVersion, + distTag: recoveryDistTag, + branchRef, + }); + recordSdkPythonSnapshot(recovery.sdkPythonSnapshot); + results.set(pkg.name, { status: 'released', newTags }); + continue; + } catch (recoveryError) { + console.error(`Recovery for ${pkg.name} ${recoveryTag} failed:\n${recoveryError.message || recoveryError}`); + } + } + const status = newTags.length > 0 ? 'FAILED (partial)' : 'FAILED'; results.set(pkg.name, { status, newTags }); hasFailed = true; @@ -136,10 +919,12 @@ if (hasFailed) { process.exitCode = 1; } +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.'); +} + // 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.', - ); + console.log('\n@semantic-release/git automatically pushes version commits and tags on the stable branch.'); } diff --git a/scripts/release-local.mjs b/scripts/release-local.mjs index 374108284f..db16ca9e37 100644 --- a/scripts/release-local.mjs +++ b/scripts/release-local.mjs @@ -30,25 +30,26 @@ function getCurrentBranch() { } /** - * Allowlist of every tag prefix used across the monorepo. + * Allowlist of every release tag pattern used across the monorepo. * Used by pruneLocalOnlyReleaseTags to avoid leaking local-only * tags from any package namespace, including the current one, into * semantic-release's version detection. * * MAINTENANCE: when adding a new releasable package with its own - * tagFormat in .releaserc.*, add its prefix here too. You can find + * tagFormat in .releaserc.*, add its pattern here too. You can find * all current tagFormat values with: * grep -r 'tagFormat' --include='*.cjs' --include='*.js' --include='*.mjs' . */ -const ALL_TAG_PREFIXES = [ - 'v', // superdoc (packages/superdoc/.releaserc.cjs) - 'cli-v', // CLI (apps/cli/.releaserc.cjs) - 'sdk-v', // SDK - 'react-v', // React - 'vscode-v', // VS Code - 'mcp-v', // MCP - 'esign-v', // esign - 'template-builder-v', // template-builder +const ALL_TAG_PATTERNS = [ + 'v[0-9]*', // superdoc (packages/superdoc/.releaserc.cjs) + 'cli-v*', // CLI (apps/cli/.releaserc.cjs) + 'create-v*', // Create + 'sdk-v*', // SDK + 'react-v*', // React + 'vscode-v*', // VS Code + 'mcp-v*', // MCP + 'esign-v*', // esign + 'template-builder-v*', // template-builder ]; export function run(command, args, options = {}) { @@ -63,7 +64,12 @@ export function run(command, args, options = {}) { export function listTags(pattern) { const output = run('git', ['tag', '--list', pattern], { capture: true }).trim(); - return output ? output.split('\n').map((tag) => tag.trim()).filter(Boolean) : []; + return output + ? output + .split('\n') + .map((tag) => tag.trim()) + .filter(Boolean) + : []; } export function getRemoteTags() { @@ -91,8 +97,8 @@ export function pruneLocalOnlyReleaseTags() { const pruned = []; const remoteTags = getRemoteTags(); - for (const prefix of ALL_TAG_PREFIXES) { - const tags = listTags(`${prefix}*`); + for (const pattern of ALL_TAG_PATTERNS) { + const tags = listTags(pattern); for (const tag of tags) { if (remoteTags.has(tag)) continue; run('git', ['tag', '-d', tag]); @@ -101,9 +107,7 @@ export function pruneLocalOnlyReleaseTags() { } if (pruned.length > 0) { - console.log( - `Pruned ${pruned.length} local-only foreign tags before release: ${pruned.join(', ')}`, - ); + console.log(`Pruned ${pruned.length} local-only foreign tags before release: ${pruned.join(', ')}`); } }