From f4896791e22fd55781669d7cb2bd1f1c55a4bad7 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Thu, 21 May 2026 21:49:05 +0100 Subject: [PATCH] fix(publish): resolve catalog:/workspace:* protocols + use npm pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish pipeline shipped @aictrl/cli@0.3.3 with unresolved `workspace:*` deps and @aictrl/util@1.2.16 with unresolved `catalog:` deps, breaking plain `npm install` with EUNSUPPORTEDPROTOCOL. Both protocols are Bun-only (also pnpm/yarn for `workspace:`); npm has no resolver for either. Root cause was two-part: 1. `bun pm pack` ignores in-place edits to the on-disk package.json and re-injects workspace deps from the workspace lockfile. publish.yml's "Strip bundled deps" step had no effect on the tarball — type, exports, dependencies, devDependencies, peerDependencies all survived the strip and shipped to npm verbatim. 2. publish-if-new.sh had no `catalog:` → semver step. @aictrl/util ships `{ zod: 'catalog:' }` because the catalog protocol is invisible to the existing strip logic (which only ran on CLI). Fix: - Add a catalog:/workspace: resolver to publish-if-new.sh that reads the root package.json's workspaces.catalog and the AICTRL_VERSION env var, substituting concrete semver into all four dep fields before packing. Fails loud if a catalog: dep has no catalog entry, or if workspace:* is hit without AICTRL_VERSION. - Replace `bun pm pack` with `npm pack` so on-disk package.json edits actually take effect in the tarball. - Expose stripped AICTRL_VERSION (no v prefix) to subsequent workflow steps via $GITHUB_ENV so the resolver can see it. - Add a post-publish smoke test step that does `npm install @aictrl/cli@$VERSION` in a scratch dir on the runner, with CDN-aware retry. Fails the release if the published manifest is broken, catching this entire bug class going forward. Local verification: - Resolver against packages/util/package.json: catalog: → 4.1.8 ✓ - Resolver against v0.3.3 packages/cli/package.json with AICTRL_VERSION=0.3.4: all workspace:* and catalog: deps resolved to concrete semver, npm pack produced a valid tarball. - Fail-loud path verified: missing AICTRL_VERSION on workspace:* dep exits with explicit error. Closes #72 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/scripts/publish-if-new.sh | 73 ++++++++++++++++++++++++++++--- .github/workflows/publish.yml | 36 +++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/.github/scripts/publish-if-new.sh b/.github/scripts/publish-if-new.sh index 96c1d20..b34e660 100755 --- a/.github/scripts/publish-if-new.sh +++ b/.github/scripts/publish-if-new.sh @@ -6,12 +6,25 @@ # etc) fails loud. See issue #51 for the v0.3.1 / v0.3.2 silent-failure # post-mortem that motivated this wrapper. # +# Also resolves Bun-only protocols (`catalog:` and `workspace:`) to +# concrete semver before packing, so the published tarball is installable +# via plain `npm install`. See issue #72 for the v0.3.3 install-failure +# post-mortem that motivated this resolver. +# # Usage: # .github/scripts/publish-if-new.sh # -# Must be run from the package's working directory (where bun pm pack -# will produce the .tgz). The is used only for log -# annotations (e.g. "@aictrl/util"). +# Env: +# REPO_ROOT — absolute path to repo root (where workspaces.catalog +# is defined). Defaults to `git rev-parse --show-toplevel`. +# AICTRL_VERSION — release version with no `v` prefix. Used to substitute +# `workspace:*` deps. Required only if the package being +# published actually has workspace deps after any prior +# strip step has run. +# +# Must be run from the package's working directory (where `npm pack` will +# produce the .tgz). The is used only for log annotations +# (e.g. "@aictrl/util"). set -euo pipefail @@ -20,7 +33,55 @@ label="${1:?Usage: publish-if-new.sh }" # Clean stale tarballs so we always publish the freshly packed one. rm -f *.tgz -bun pm pack +# Resolve Bun-only protocols (`catalog:`, `workspace:`) to concrete semver +# before packing. Plain `npm install` rejects both with EUNSUPPORTEDPROTOCOL, +# so any tarball that ships them is broken for npm consumers (and the AI +# Review workflow in this very repo). #72. +export REPO_ROOT="${REPO_ROOT:-$(git rev-parse --show-toplevel)}" +export AICTRL_VERSION="${AICTRL_VERSION:-}" +node -e " + const fs = require('fs'); + const path = require('path'); + const root = JSON.parse(fs.readFileSync(path.join(process.env.REPO_ROOT, 'package.json'), 'utf8')); + const catalog = (root.workspaces && root.workspaces.catalog) || {}; + const release = process.env.AICTRL_VERSION || ''; + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const fields = ['dependencies', 'optionalDependencies', 'peerDependencies', 'devDependencies']; + let touched = false; + for (const field of fields) { + const deps = pkg[field]; + if (!deps) continue; + for (const [name, version] of Object.entries(deps)) { + if (version === 'catalog:') { + if (!catalog[name]) { + console.error('::error::' + field + '[' + name + '] is catalog: but root workspaces.catalog has no entry for it'); + process.exit(1); + } + deps[name] = catalog[name]; + touched = true; + } else if (typeof version === 'string' && version.startsWith('workspace:')) { + if (!release) { + console.error('::error::' + field + '[' + name + '] is ' + version + ' but AICTRL_VERSION env var is not set'); + process.exit(1); + } + // All sibling @aictrl/* packages release in lockstep with AICTRL_VERSION. + // If that ever stops being true, replace with a per-package lookup. + deps[name] = release; + touched = true; + } + } + } + if (touched) { + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + console.log('::notice::resolved Bun-only protocol deps in ' + pkg.name + '@' + pkg.version); + } +" + +# `npm pack` reads only the on-disk package.json, unlike `bun pm pack` which +# re-injects workspace deps from the lockfile and silently ignores in-place +# manifest edits (publish.yml's "Strip bundled deps" step had no effect on +# v0.3.3 for this reason). #72. +npm pack # Use nullglob array to avoid pipefail exit on no matches (ls *.tgz # returns exit 2 when nothing matches, which pipefail propagates). @@ -29,12 +90,12 @@ tarballs=(*.tgz) shopt -u nullglob if [ ${#tarballs[@]} -eq 0 ]; then - echo "::error::bun pm pack produced no .tgz files in $(pwd)" + echo "::error::npm pack produced no .tgz files in $(pwd)" exit 1 fi if [ ${#tarballs[@]} -gt 1 ]; then - echo "::warning::bun pm pack produced ${#tarballs[@]} .tgz files in $(pwd), publishing first: ${tarballs[0]}" + echo "::warning::npm pack produced ${#tarballs[@]} .tgz files in $(pwd), publishing first: ${tarballs[0]}" fi tarball="${tarballs[0]}" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cd83212..74beef0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -49,6 +49,10 @@ jobs: AICTRL_VERSION: ${{ github.event.release.tag_name }} run: | export AICTRL_VERSION="${AICTRL_VERSION#v}" + # Expose stripped version to subsequent steps so the publish-if-new.sh + # catalog:/workspace: resolver (and the smoke test below) can see it + # without each step re-doing the v-prefix strip. #72. + echo "AICTRL_VERSION=$AICTRL_VERSION" >> "$GITHUB_ENV" bun turbo build - name: Publish @aictrl/util @@ -153,3 +157,35 @@ jobs: - name: Publish @aictrl/cli working-directory: packages/cli run: ${{ github.workspace }}/.github/scripts/publish-if-new.sh "@aictrl/cli" + + - name: Smoke test - verify @aictrl/cli installs cleanly via npm + # Catches the v0.3.3-class bug where the published manifest carries + # Bun-only protocols (workspace:*, catalog:) that plain `npm install` + # rejects with EUNSUPPORTEDPROTOCOL. Without this gate, a broken + # publish goes unnoticed until consumers report install failures. + # #72. + run: | + set -euo pipefail + smoke="${RUNNER_TEMP}/aictrl-publish-smoke" + rm -rf "$smoke" + mkdir -p "$smoke" + cd "$smoke" + npm init -y > /dev/null + + # CDN propagation can lag ~30-60s after publish; retry briefly. + for attempt in 1 2 3 4 5; do + if npm install "@aictrl/cli@${AICTRL_VERSION}" 2>&1 | tee install.log; then + echo "::notice::npm install @aictrl/cli@${AICTRL_VERSION} succeeded on attempt ${attempt}" + exit 0 + fi + # EUNSUPPORTEDPROTOCOL means the resolver in publish-if-new.sh + # didn't catch a workspace:/catalog: leak. Retrying won't help. + if grep -q "EUNSUPPORTEDPROTOCOL" install.log; then + echo "::error::@aictrl/cli@${AICTRL_VERSION} ships protocol-prefixed deps that plain npm cannot resolve. The catalog:/workspace: resolver in publish-if-new.sh did not run or did not cover all fields. See aictrl-dev/cli#72." + exit 1 + fi + echo "::warning::install attempt ${attempt} failed (likely CDN propagation), retrying in 30s..." + sleep 30 + done + echo "::error::npm install @aictrl/cli@${AICTRL_VERSION} failed after 5 attempts" + exit 1