From 930e22d4c981bf829bd5a102a60f515d5ad87b90 Mon Sep 17 00:00:00 2001 From: josstei <48696594+josstei@users.noreply.github.com> Date: Thu, 30 Apr 2026 01:52:58 -0400 Subject: [PATCH 1/3] fix(release): enforce stable latest dist-tag policy --- .github/workflows/release.yml | 98 ++++++++++++- docs/cicd.md | 37 +++-- scripts/npm-publish-idempotent.js | 153 +++++++++++++++++++ tests/unit/npm-publish-idempotent.test.js | 170 ++++++++++++++++++++-- tests/unit/workflow-security.test.js | 18 +++ 5 files changed, 446 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a4ce569..02100f6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,27 +3,58 @@ name: Release on: push: branches: [main] + workflow_dispatch: + inputs: + version: + description: 'Stable version to recover, for example 1.6.4' + required: true + type: string + target_sha: + description: 'Commit SHA to release; defaults to refs/tags/v' + required: false + type: string permissions: contents: write - id-token: write pull-requests: write jobs: release: runs-on: ubuntu-latest + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Checkout push commit + if: github.event_name == 'push' + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - - name: Resolve release PR from commit + - name: Checkout recovery target + if: github.event_name == 'workflow_dispatch' + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + ref: ${{ inputs.target_sha || format('refs/tags/v{0}', inputs.version) }} + + - name: Resolve release context id: detect env: GH_TOKEN: ${{ github.token }} REPOSITORY: ${{ github.repository }} GITHUB_SHA: ${{ github.sha }} + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "is_release=true" >> "$GITHUB_OUTPUT" + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "pr_title=release: v${INPUT_VERSION}" >> "$GITHUB_OUTPUT" + echo "pr_head_ref=release/v${INPUT_VERSION}" >> "$GITHUB_OUTPUT" + echo "recovery=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + PR_JSON=$(gh api -H "Accept: application/vnd.github+json" "repos/$REPOSITORY/commits/$GITHUB_SHA/pulls" | \ jq -c --arg sha "$GITHUB_SHA" \ '[.[] | select(.merged_at != null and .merge_commit_sha == $sha and .base.ref == "main" and (.labels | map(.name) | index("release")))] | .[0]') @@ -38,6 +69,7 @@ jobs: echo "pr_number=$(echo "$PR_JSON" | jq -r '.number')" >> "$GITHUB_OUTPUT" echo "pr_title=$(echo "$PR_JSON" | jq -r '.title')" >> "$GITHUB_OUTPUT" echo "pr_head_ref=$(echo "$PR_JSON" | jq -r '.head.ref')" >> "$GITHUB_OUTPUT" + echo "recovery=false" >> "$GITHUB_OUTPUT" - uses: actions/setup-node@v4 if: steps.detect.outputs.is_release == 'true' @@ -45,15 +77,45 @@ jobs: node-version: '24' registry-url: 'https://registry.npmjs.org' + - name: Verify npm token + if: steps.detect.outputs.is_release == 'true' + run: | + if [ -z "$NPM_TOKEN" ]; then + echo "::error::NPM_TOKEN is required for stable release publishing" + exit 1 + fi + - name: Extract and validate version if: steps.detect.outputs.is_release == 'true' id: version env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TARGET_SHA: ${{ inputs.target_sha }} + INPUT_VERSION: ${{ inputs.version }} PR_HEAD_REF: ${{ steps.detect.outputs.pr_head_ref }} PR_TITLE: ${{ steps.detect.outputs.pr_title }} run: | PKG_VERSION=$(node -p "require('./package.json').version") + TARGET_SHA=$(git rev-parse HEAD) echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT" + echo "target_sha=$TARGET_SHA" >> "$GITHUB_OUTPUT" + + if [[ ! "$PKG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Release workflow only publishes stable semver versions; found $PKG_VERSION" + exit 1 + fi + + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "$PKG_VERSION" != "$INPUT_VERSION" ]; then + echo "::error::package.json version ($PKG_VERSION) does not match manual recovery version ($INPUT_VERSION)" + exit 1 + fi + + if [ -n "$INPUT_TARGET_SHA" ] && [ "$TARGET_SHA" != "$INPUT_TARGET_SHA" ]; then + echo "::error::Checked-out commit ($TARGET_SHA) does not match manual recovery target_sha ($INPUT_TARGET_SHA)" + exit 1 + fi + fi if ! grep -Fq "## [$PKG_VERSION]" CHANGELOG.md; then echo "::error::CHANGELOG.md missing section for [$PKG_VERSION]" @@ -76,6 +138,18 @@ jobs: fi fi + TAG="v${PKG_VERSION}" + TAG_SHA=$(git rev-parse "$TAG^{commit}" 2>/dev/null || true) + if [ -n "$TAG_SHA" ]; then + if [ "$TAG_SHA" != "$TARGET_SHA" ]; then + echo "::error::Tag $TAG exists at $TAG_SHA, not target commit $TARGET_SHA" + exit 1 + fi + elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "::error::Manual release recovery requires existing tag $TAG" + exit 1 + fi + - name: Generate runtime adapters if: steps.detect.outputs.is_release == 'true' run: node scripts/generate.js @@ -108,26 +182,34 @@ jobs: - name: Create and push tag if: steps.detect.outputs.is_release == 'true' env: + TARGET_SHA: ${{ steps.version.outputs.target_sha }} VERSION: v${{ steps.version.outputs.version }} - GITHUB_SHA: ${{ github.sha }} run: | + CURRENT_SHA=$(git rev-parse HEAD) + if [ "$CURRENT_SHA" != "$TARGET_SHA" ]; then + echo "::error::Checked-out commit changed from $TARGET_SHA to $CURRENT_SHA" + exit 1 + fi + if git rev-parse "$VERSION" >/dev/null 2>&1; then EXISTING_SHA=$(git rev-parse "$VERSION^{commit}") - if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then echo "Tag $VERSION already exists at $EXISTING_SHA. Skipping." exit 0 fi - echo "::error::Tag $VERSION already exists at $EXISTING_SHA, not $GITHUB_SHA" + echo "::error::Tag $VERSION already exists at $EXISTING_SHA, not $TARGET_SHA" exit 1 fi - git tag "$VERSION" "$GITHUB_SHA" + git tag "$VERSION" "$TARGET_SHA" git push origin "$VERSION" - name: Publish to npm if: steps.detect.outputs.is_release == 'true' run: node scripts/npm-publish-idempotent.js --access public + env: + NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }} - name: Extract changelog for version if: steps.detect.outputs.is_release == 'true' @@ -152,7 +234,7 @@ jobs: uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1 with: tag_name: v${{ steps.version.outputs.version }} - target_commitish: ${{ github.sha }} + target_commitish: ${{ steps.version.outputs.target_sha }} name: v${{ steps.version.outputs.version }} body: ${{ steps.changelog.outputs.notes }} generate_release_notes: false diff --git a/docs/cicd.md b/docs/cicd.md index 8a83362e..bfe523cf 100644 --- a/docs/cicd.md +++ b/docs/cicd.md @@ -468,13 +468,14 @@ Publishes `@josstei/maestro@X.Y.Z-rc.N` to npm with the `rc` dist-tag, where `N` ### Purpose -The final step of the release pipeline. Triggers on any push to `main`, but only acts when the push is a merged pull request carrying the `release` label. Validates package and archive contents, creates a Git tag, publishes the stable package to npm, then publishes a GitHub Release with CHANGELOG notes and the self-contained extension archive attached. +The final step of the release pipeline. Triggers on any push to `main`, but only acts when the push is a merged pull request carrying the `release` label. It can also be manually dispatched to recover a failed post-tag stable publish. Validates package and archive contents, creates or reuses the matching Git tag, publishes the stable package to npm, then publishes a GitHub Release with CHANGELOG notes and the self-contained extension archive attached. ### Trigger | Event | Details | |-------|---------| | `push` | Branch: `main` | +| `workflow_dispatch` | Manual stable recovery with `version` and optional `target_sha`; when `target_sha` is omitted, the workflow checks out `refs/tags/v` | | **Condition** | The pushed commit must be the merge commit of a PR labeled `release` that targeted `main` | ### Flow @@ -482,11 +483,14 @@ The final step of the release pipeline. Triggers on any push to `main`, but only ```mermaid graph TD A["Push to main"] --> B["Checkout with full history"] - B --> C["Resolve release PR from commit"] + AA["Manual recovery
version + target_sha"] --> BB["Checkout target SHA
or refs/tags/vX.Y.Z"] + B --> C["Resolve release context"] + BB --> C C --> D{"Merged PR with
'release' label found?"} D --> |"No"| E["Skip: not a release commit"] D --> |"Yes"| F["Setup Node.js 24 with npm registry"] - F --> I["Extract and validate version"] + F --> G["Verify NPM_TOKEN"] + G --> I["Extract and validate version,
target SHA, and tag"] I --> J["Generate runtime adapters"] J --> K{"Adapter drift?"} K --> |"Yes"| L["Fail: adapters out of sync"] @@ -506,18 +510,19 @@ graph TD | Step | Description | |------|-------------| -| Checkout | Full history (`fetch-depth: 0`) for tag operations | -| Resolve release PR from commit | Queries the GitHub API for PRs associated with the current commit SHA; filters for merged PRs targeting `main` with the `release` label. If none found, sets `is_release=false` and all subsequent steps are skipped. | -| Setup Node.js | Conditional on `is_release=true`; Node.js 24 with npm registry URL for npm Trusted Publishing | -| Extract and validate version | Reads version from `package.json` and cross-validates: the CHANGELOG must have a matching section (unconditional). When the release branch name matches `release/vX.Y.Z` and the PR title matches `release: vX.Y.Z`, their embedded versions must agree with `package.json`. | +| Checkout | Full history (`fetch-depth: 0`) for tag operations. Push releases check out the pushed commit; manual recovery checks out `target_sha` or `refs/tags/v`. | +| Resolve release context | Push releases query the GitHub API for PRs associated with the current commit SHA and filter for merged PRs targeting `main` with the `release` label. Manual recovery sets the release context from the supplied version. If no push release is found, sets `is_release=false` and all subsequent steps are skipped. | +| Setup Node.js | Conditional on `is_release=true`; Node.js 24 with npm registry URL | +| Verify npm token | Fails before tag or publish work unless `NPM_TOKEN` is configured | +| Extract and validate version | Reads version from `package.json` and cross-validates: the version must be stable semver, the CHANGELOG must have a matching section (unconditional), any existing `vX.Y.Z` tag must point at the checked-out target SHA, and manual recovery must match both the requested `version` and `target_sha` when supplied. When the release branch name matches `release/vX.Y.Z` and the PR title matches `release: vX.Y.Z`, their embedded versions must agree with `package.json`. | | Generate runtime adapters | Runs `node scripts/generate.js` | | Check adapter drift | Final drift check before release; fails with error annotation | | Run full test suite | Final test gate before release | | Verify npm package contents | Runs `npm run pack:verify` before any tag or publish operation | | Package release artifact | Runs `npm run release:artifacts` to create `dist/release/maestro-vX.Y.Z-extension.tar.gz` | | Verify release artifact | Runs `npm run release:verify-artifacts` against the generated archive | -| Create and push tag | Creates Git tag `vX.Y.Z` at the merge commit SHA; handles idempotency (skips if tag exists at same SHA, fails if tag exists at different SHA) | -| Publish to npm | Publishes stable release through `node scripts/npm-publish-idempotent.js --access public` and GitHub Actions OIDC trusted publishing, skipping if the exact version already exists | +| Create and push tag | Creates Git tag `vX.Y.Z` at the checked-out target SHA; handles idempotency (skips if tag exists at same SHA, fails if tag exists at different SHA) | +| Publish to npm | Publishes stable release through `node scripts/npm-publish-idempotent.js --access public` with `NODE_AUTH_TOKEN` derived from `NPM_TOKEN`, skipping if the exact version already exists | | Extract changelog | Extracts the version-specific section from `CHANGELOG.md` using `awk` | | Create GitHub Release | Uses `softprops/action-gh-release` (pinned to SHA `c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda`, v2.2.1) with CHANGELOG excerpt as body and the generic extension archive attached | @@ -526,8 +531,10 @@ graph TD | Item | Type | Purpose | |------|------|---------| | `GH_TOKEN` | Env (derived) | Set to `${{ github.token }}` for PR creation and GitHub API calls | +| `NPM_TOKEN` | Secret | npm registry authentication for stable publish and dist-tag repair | +| `NODE_AUTH_TOKEN` | Env (derived) | Set to `$NPM_TOKEN` for npm CLI | -**Permissions**: `contents: write`, `id-token: write`, `pull-requests: write` +**Permissions**: `contents: write`, `pull-requests: write` ### Artifacts @@ -538,8 +545,10 @@ graph TD ### Key Behaviors - The release detection uses the GitHub API to find the PR associated with the merge commit, filtering for the `release` label. Non-release pushes to `main` exit early and cleanly. -- Version validation cross-checks `package.json` against the CHANGELOG (unconditional) and, when applicable, against the release branch name (`release/vX.Y.Z`) and the PR title (`release: vX.Y.Z`). A mismatch in any available source fails the workflow. -- Stable releases require npm Trusted Publishing to be configured for `@josstei/maestro` with GitHub Actions workflow `release.yml`; the workflow does not use a long-lived npm token. +- Manual recovery is only for stable versions and requires the checked-out commit, `package.json`, `CHANGELOG.md`, and existing `vX.Y.Z` tag to agree. This recovers failures after tag creation without creating a new version or publishing from a maintainer laptop. +- Version validation cross-checks `package.json` against the CHANGELOG (unconditional), the existing tag when present, and, when applicable, against the release branch name (`release/vX.Y.Z`) and the PR title (`release: vX.Y.Z`). A mismatch in any available source fails the workflow. +- Stable releases require `NPM_TOKEN`; npm Trusted Publishing should only replace it after the package owner explicitly configures and tests that path. +- npm `latest` is stable-only. The publish helper rejects prereleases published without an explicit prerelease tag or with `latest`, rejects stable publishes with prerelease dist-tags, removes a stale `latest` tag when it points at a prerelease and no stable exists, and moves `latest` to the stable version after a stable publish or idempotent skip. - Tag creation is idempotent: if the tag already exists at the same commit, the step is skipped. If it exists at a different commit, the workflow fails to prevent overwriting a release. --- @@ -633,7 +642,7 @@ graph LR | Preview Build | `contents: read`, `pull-requests: write` | `NPM_TOKEN` | | Prepare Release | `contents: write`, `pull-requests: write` | `RELEASE_TOKEN` | | Release Candidate | `contents: read`, `pull-requests: write` | `NPM_TOKEN` | -| Release | `contents: write`, `id-token: write`, `pull-requests: write` | None | +| Release | `contents: write`, `pull-requests: write` | `NPM_TOKEN` | The `RELEASE_TOKEN` used by Prepare Release is a personal access token with elevated permissions. The default `GITHUB_TOKEN` does not trigger downstream workflow runs, so `RELEASE_TOKEN` is required for branch pushes and PR creation that need to activate Source Of Truth Check and Release Candidate on the newly created PR. @@ -645,3 +654,5 @@ The `RELEASE_TOKEN` used by Prepare Release is a personal access token with elev | `rc` | Release candidate from release PR | `X.Y.Z-rc.N` | Release Candidate | | `preview` | PR preview build | `X.Y.Z-preview.SHORT_SHA` | Preview Build | | `nightly` | Daily main snapshot | `X.Y.Z-nightly.YYYYMMDD` | Nightly Build | + +`latest` must never point at `rc`, `preview`, or `nightly`. If a prerelease publish or idempotent skip sees `latest` pointing to a prerelease, the helper repairs it by moving `latest` back to the highest published stable version or removing it when no stable exists. diff --git a/scripts/npm-publish-idempotent.js b/scripts/npm-publish-idempotent.js index 12a8fef2..8a7c4397 100644 --- a/scripts/npm-publish-idempotent.js +++ b/scripts/npm-publish-idempotent.js @@ -6,6 +6,7 @@ const path = require('node:path'); const { execFileSync } = require('node:child_process'); const ROOT = path.resolve(__dirname, '..'); +const PRERELEASE_TAGS = new Set(['rc', 'preview', 'nightly']); function printHelp() { console.log(`Publish a Maestro npm package if the exact version is absent. @@ -79,6 +80,68 @@ function isNpmNotFoundError(error) { return /\bE404\b|404 Not Found|is not in this registry/i.test(text); } +function isPrereleaseVersion(version) { + return /^[0-9]+\.[0-9]+\.[0-9]+-.+/.test(version); +} + +function isStableVersion(version) { + return /^[0-9]+\.[0-9]+\.[0-9]+$/.test(version); +} + +function compareStableVersions(left, right) { + const leftParts = left.split('.').map((part) => Number.parseInt(part, 10)); + const rightParts = right.split('.').map((part) => Number.parseInt(part, 10)); + + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] !== rightParts[index]) { + return leftParts[index] - rightParts[index]; + } + } + + return 0; +} + +function highestStableVersion(versions) { + return versions + .filter(isStableVersion) + .sort(compareStableVersions) + .at(-1) || null; +} + +function parseDistTagOutput(output) { + const tags = {}; + const text = Buffer.isBuffer(output) ? output.toString('utf8') : String(output || ''); + + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^([^:\s]+):\s*(\S+)\s*$/); + if (match) { + tags[match[1]] = match[2]; + } + } + + return tags; +} + +function parseVersionsOutput(output) { + const text = Buffer.isBuffer(output) ? output.toString('utf8') : String(output || ''); + const trimmed = text.trim(); + + if (!trimmed) { + return []; + } + + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + + if (typeof parsed === 'string') { + return [parsed]; + } + + return []; +} + function packageVersionExists(packageSpec, runner) { try { const stdout = runner('npm', ['view', packageSpec, 'version'], { @@ -95,13 +158,92 @@ function packageVersionExists(packageSpec, runner) { } } +function getDistTags(packageName, runner) { + try { + const stdout = runner('npm', ['dist-tag', 'ls', packageName], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return parseDistTagOutput(stdout); + } catch (error) { + if (isNpmNotFoundError(error)) { + return {}; + } + + throw error; + } +} + +function getPublishedVersions(packageName, runner) { + try { + const stdout = runner('npm', ['view', packageName, 'versions', '--json'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return parseVersionsOutput(stdout); + } catch (error) { + if (isNpmNotFoundError(error)) { + return []; + } + + throw error; + } +} + +function validatePublishTag(pkg, tag) { + const prerelease = isPrereleaseVersion(pkg.version); + + if (prerelease && (!tag || tag === 'latest')) { + throw new Error( + `Refusing to publish prerelease ${pkg.name}@${pkg.version} with the latest tag; use rc, preview, or nightly.` + ); + } + + if (!prerelease && PRERELEASE_TAGS.has(tag)) { + throw new Error( + `Refusing to publish stable ${pkg.name}@${pkg.version} with prerelease tag "${tag}".` + ); + } +} + +function ensureLatestTagPolicy(pkg, runner) { + const tags = getDistTags(pkg.name, runner); + const latest = tags.latest; + + if (isPrereleaseVersion(pkg.version)) { + if (!latest || !isPrereleaseVersion(latest)) { + return; + } + + const stableVersion = highestStableVersion(getPublishedVersions(pkg.name, runner)); + if (stableVersion) { + runner('npm', ['dist-tag', 'add', `${pkg.name}@${stableVersion}`, 'latest'], { + stdio: 'inherit', + }); + } else { + runner('npm', ['dist-tag', 'rm', pkg.name, 'latest'], { + stdio: 'inherit', + }); + } + return; + } + + if (latest !== pkg.version) { + runner('npm', ['dist-tag', 'add', `${pkg.name}@${pkg.version}`, 'latest'], { + stdio: 'inherit', + }); + } +} + function publishIfNeeded(options = {}) { const root = options.root || ROOT; const runner = options.execFileSync || execFileSync; const pkg = readPackage(root); const packageSpec = `${pkg.name}@${pkg.version}`; + validatePublishTag(pkg, options.tag); if (packageVersionExists(packageSpec, runner)) { + ensureLatestTagPolicy(pkg, runner); return { packageSpec, published: false, @@ -119,6 +261,8 @@ function publishIfNeeded(options = {}) { stdio: 'inherit', }); + ensureLatestTagPolicy(pkg, runner); + return { packageSpec, published: true, @@ -142,9 +286,18 @@ if (require.main === module) { } module.exports = { + ensureLatestTagPolicy, + getDistTags, + getPublishedVersions, + highestStableVersion, isNpmNotFoundError, + isPrereleaseVersion, + isStableVersion, packageVersionExists, + parseDistTagOutput, parseArgs, + parseVersionsOutput, publishIfNeeded, readPackage, + validatePublishTag, }; diff --git a/tests/unit/npm-publish-idempotent.test.js b/tests/unit/npm-publish-idempotent.test.js index 64fd04d1..07530ff1 100644 --- a/tests/unit/npm-publish-idempotent.test.js +++ b/tests/unit/npm-publish-idempotent.test.js @@ -7,16 +7,18 @@ const os = require('node:os'); const path = require('node:path'); const { + highestStableVersion, isNpmNotFoundError, parseArgs, + parseDistTagOutput, publishIfNeeded, } = require('../../scripts/npm-publish-idempotent'); -function createPackageRoot() { +function createPackageRoot(version = '1.2.3') { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-publish-')); fs.writeFileSync( path.join(root, 'package.json'), - `${JSON.stringify({ name: '@josstei/maestro', version: '1.2.3' }, null, 2)}\n`, + `${JSON.stringify({ name: '@josstei/maestro', version }, null, 2)}\n`, 'utf8' ); return root; @@ -30,7 +32,7 @@ function npmError(stderr) { describe('idempotent npm publish', () => { it('skips publish when the exact version already exists', () => { - const root = createPackageRoot(); + const root = createPackageRoot('1.2.3-rc.1'); const calls = []; try { @@ -39,23 +41,28 @@ describe('idempotent npm publish', () => { tag: 'rc', execFileSync: (cmd, args) => { calls.push([cmd, args]); - return '1.2.3\n'; + if (args[0] === 'dist-tag') { + return 'rc: 1.2.3-rc.1\n'; + } + return '1.2.3-rc.1\n'; }, }); assert.deepEqual(result, { - packageSpec: '@josstei/maestro@1.2.3', + packageSpec: '@josstei/maestro@1.2.3-rc.1', published: false, }); - assert.equal(calls.length, 1); - assert.deepEqual(calls[0], ['npm', ['view', '@josstei/maestro@1.2.3', 'version']]); + assert.deepEqual(calls, [ + ['npm', ['view', '@josstei/maestro@1.2.3-rc.1', 'version']], + ['npm', ['dist-tag', 'ls', '@josstei/maestro']], + ]); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); it('publishes when npm reports the exact version missing', () => { - const root = createPackageRoot(); + const root = createPackageRoot('1.2.3-preview.abcdef0'); const calls = []; try { @@ -68,12 +75,15 @@ describe('idempotent npm publish', () => { if (args[0] === 'view') { throw npmError('npm ERR! code E404\nnpm ERR! 404 Not Found'); } + if (args[0] === 'dist-tag') { + return 'preview: 1.2.3-preview.abcdef0\n'; + } return ''; }, }); assert.deepEqual(result, { - packageSpec: '@josstei/maestro@1.2.3', + packageSpec: '@josstei/maestro@1.2.3-preview.abcdef0', published: true, }); assert.deepEqual(calls[1], ['npm', ['publish', '--tag', 'preview', '--access', 'public']]); @@ -82,6 +92,140 @@ describe('idempotent npm publish', () => { } }); + it('publishes stable versions without an explicit tag and ensures latest', () => { + const root = createPackageRoot('1.2.3'); + const calls = []; + + try { + const result = publishIfNeeded({ + root, + access: 'public', + execFileSync: (cmd, args) => { + calls.push([cmd, args]); + if (args[0] === 'view') { + throw npmError('npm ERR! code E404\nnpm ERR! 404 Not Found'); + } + if (args[0] === 'dist-tag' && args[1] === 'ls') { + return 'latest: 1.2.2\n'; + } + return ''; + }, + }); + + assert.deepEqual(result, { + packageSpec: '@josstei/maestro@1.2.3', + published: true, + }); + assert.deepEqual(calls[1], ['npm', ['publish', '--access', 'public']]); + assert.deepEqual(calls.at(-1), [ + 'npm', + ['dist-tag', 'add', '@josstei/maestro@1.2.3', 'latest'], + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects prerelease versions published without a prerelease tag', () => { + const root = createPackageRoot('1.2.3-rc.1'); + + try { + assert.throws( + () => publishIfNeeded({ root, execFileSync: () => '' }), + /Refusing to publish prerelease @josstei\/maestro@1\.2\.3-rc\.1 with the latest tag/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects prerelease versions published with latest', () => { + const root = createPackageRoot('1.2.3-rc.1'); + + try { + assert.throws( + () => publishIfNeeded({ root, tag: 'latest', execFileSync: () => '' }), + /Refusing to publish prerelease @josstei\/maestro@1\.2\.3-rc\.1 with the latest tag/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects stable versions published with prerelease tags', () => { + const root = createPackageRoot('1.2.3'); + + try { + assert.throws( + () => publishIfNeeded({ root, tag: 'rc', execFileSync: () => '' }), + /Refusing to publish stable @josstei\/maestro@1\.2\.3 with prerelease tag "rc"/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('removes latest when it points to a prerelease and no stable exists', () => { + const root = createPackageRoot('1.2.3-rc.1'); + const calls = []; + + try { + publishIfNeeded({ + root, + tag: 'rc', + execFileSync: (cmd, args) => { + calls.push([cmd, args]); + if (args[0] === 'view' && args[1] === '@josstei/maestro@1.2.3-rc.1') { + return '1.2.3-rc.1\n'; + } + if (args[0] === 'dist-tag' && args[1] === 'ls') { + return 'latest: 1.2.3-rc.1\nrc: 1.2.3-rc.1\n'; + } + if (args[0] === 'view' && args[1] === '@josstei/maestro') { + return '["1.2.3-rc.1"]\n'; + } + return ''; + }, + }); + + assert.deepEqual(calls.at(-1), ['npm', ['dist-tag', 'rm', '@josstei/maestro', 'latest']]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('moves latest back to the highest stable version when a prerelease owns it', () => { + const root = createPackageRoot('1.3.0-rc.1'); + const calls = []; + + try { + publishIfNeeded({ + root, + tag: 'rc', + execFileSync: (cmd, args) => { + calls.push([cmd, args]); + if (args[0] === 'view' && args[1] === '@josstei/maestro@1.3.0-rc.1') { + return '1.3.0-rc.1\n'; + } + if (args[0] === 'dist-tag' && args[1] === 'ls') { + return 'latest: 1.3.0-rc.1\nrc: 1.3.0-rc.1\n'; + } + if (args[0] === 'view' && args[1] === '@josstei/maestro') { + return '["1.1.9","1.2.0","1.3.0-rc.1"]\n'; + } + return ''; + }, + }); + + assert.deepEqual(calls.at(-1), [ + 'npm', + ['dist-tag', 'add', '@josstei/maestro@1.2.0', 'latest'], + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('fails on npm errors other than a missing version', () => { const root = createPackageRoot(); @@ -111,4 +255,12 @@ describe('idempotent npm publish', () => { assert.equal(isNpmNotFoundError(npmError('npm ERR! code E404')), true); assert.equal(isNpmNotFoundError(npmError('npm ERR! code E401')), false); }); + + it('parses dist-tag output and picks the highest stable version', () => { + assert.deepEqual(parseDistTagOutput('latest: 1.2.3\nrc: 1.3.0-rc.1\n'), { + latest: '1.2.3', + rc: '1.3.0-rc.1', + }); + assert.equal(highestStableVersion(['1.2.9', '1.10.0', '2.0.0-rc.1']), '1.10.0'); + }); }); diff --git a/tests/unit/workflow-security.test.js b/tests/unit/workflow-security.test.js index e5c5df88..92d4d676 100644 --- a/tests/unit/workflow-security.test.js +++ b/tests/unit/workflow-security.test.js @@ -94,6 +94,19 @@ describe('workflow shell security', () => { ); }); + it('stable release publishing uses npm token auth and manual recovery inputs', () => { + const content = readWorkflow('release.yml'); + + assert.match(content, /workflow_dispatch:/); + assert.match(content, /\n\s+version:\n\s+description: 'Stable version to recover/); + assert.match(content, /\n\s+target_sha:\n\s+description: 'Commit SHA to release/); + assert.match(content, /NPM_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}/); + assert.match(content, /NODE_AUTH_TOKEN: \$\{\{ env\.NPM_TOKEN \}\}/); + assert.match(content, /NPM_TOKEN is required for stable release publishing/); + assert.match(content, /Manual release recovery requires existing tag \$TAG/); + assert.match(content, /Tag \$TAG exists at \$TAG_SHA, not target commit \$TARGET_SHA/); + }); + it('prerelease workflows regenerate metadata and verify pack after npm versioning', () => { const expectations = [ { @@ -124,6 +137,11 @@ describe('workflow shell security', () => { assert.notEqual(generateIndex, -1, `${fileName} should regenerate after npm version`); assert.notEqual(verifyIndex, -1, `${fileName} should verify npm pack after regenerating`); assert.notEqual(publishIndex, -1, `${fileName} should publish through the helper after verification`); + assert.doesNotMatch( + content, + /npm-publish-idempotent\.js(?:[^\n]*\s)?--tag latest/, + `${fileName} must not publish prereleases with the latest dist-tag` + ); } }); }); From 44fe1dace6991654f87560379775f18b4308b67f Mon Sep 17 00:00:00 2001 From: josstei <48696594+josstei@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:03:25 -0400 Subject: [PATCH 2/3] docs: update changelog for release recovery --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec42bd9a..7e0e80c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Stable npm release recovery**: Release now uses `NPM_TOKEN` for stable publishes, supports manual recovery from an existing `vX.Y.Z` tag and target SHA, and enforces a stable-only `latest` dist-tag through the idempotent npm publish helper. + ## [1.6.4] - 2026-04-30 ### Added From dac3b23900def999d0b41814dfbd799a6c97a1aa Mon Sep 17 00:00:00 2001 From: josstei <48696594+josstei@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:04:54 -0400 Subject: [PATCH 3/3] docs: unstamp changelog for release rerun --- CHANGELOG.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e0e80c1..035ef9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- **Stable npm release recovery**: Release now uses `NPM_TOKEN` for stable publishes, supports manual recovery from an existing `vX.Y.Z` tag and target SHA, and enforces a stable-only `latest` dist-tag through the idempotent npm publish helper. - -## [1.6.4] - 2026-04-30 - ### Added - **Maestro cheatsheet**: added an English quick-reference guide covering supported runtimes, common commands, workflow concepts, and suggested reading order. ### Changed -- **npm package identity**: renamed the planned npm package to `@josstei/maestro`, added `hello@josstei.dev` to public author metadata, and moved the stable release publish path toward GitHub Actions trusted publishing. +- **npm package identity**: renamed the planned npm package to `@josstei/maestro`, added `hello@josstei.dev` to public author metadata, and moved the stable release publish path into GitHub Actions with npm token authentication. ### Fixed +- **Stable npm release recovery**: Release now uses `NPM_TOKEN` for stable publishes, supports manual recovery from an existing `vX.Y.Z` tag and target SHA, and enforces a stable-only `latest` dist-tag through the idempotent npm publish helper. - **Codex plugin MCP server fails to start**: corrected `npx` args in `plugins/maestro/.mcp.json` — added `-p`/`--package` flag so `maestro-mcp-server` is resolved as the binary name rather than an argument to the package's default binary. - **Release metadata drift**: runtime manifests, marketplace entries, detached payload versions, and Codex MCP package specs are now generated from `package.json` so stable and prerelease packages stay self-consistent.