Skip to content

Commit 66b302a

Browse files
rafaelscostaclaude
andauthored
fix(release): harden npm-publish pipeline + document release SOP (#744)
* fix(release): harden npm-publish pipeline + document release SOP Three real failure modes observed during the v5.2.5/5.2.6 releases, each fixed and documented so the next release does not re-discover them. ## npm-publish.yml ### Race: publish_legacy_aiox_core ran in parallel with publish The legacy compat wrapper `aiox-core@X.Y.Z` declares `@aiox-squads/core@X.Y.Z` as a runtime dependency. The smoke test step runs `npx --yes aiox-core@X.Y.Z --version`, which transitively installs the scoped package. With both jobs in parallel, `npx` raced against npm CDN propagation of the scoped package and timed out after 90s — making the entire job appear failed even though the publish step inside it had already printed "✅ Published aiox-core@X.Y.Z". Fix: `publish_legacy_aiox_core` now `needs: [test, build, publish]`, serializing the legacy publish behind the scoped publish. ### Smoke test legacy npx: timeout too short, no diagnostics Bumped retry budget 6×15s (90s) → 16×15s (240s). Added a second visibility check for the scoped dependency before invoking `npx` so we distinguish "compat not visible" from "scoped dep not visible". Captured last `npx` stderr in the timeout message instead of failing silently. ### Notify: aggregated bool hid which step failed Replaced with a structured summary that distinguishes hard fail (scoped publish) from soft warnings (workspace, legacy, smoke). Only the scoped publish is treated as release-blocking; the rest are informational and can be republished out-of-band. ## installer-smoke-matrix.yml ### Windows path mangling in node -e via Git Bash `${{ github.workspace }}` on a Windows runner expands to `D:\a\aiox-core\aiox-core`. Interpolated into a `node -e "..."` script running on Git Bash, the backslashes were interpreted as escape sequences (`\a` → `a`), producing `D:aaiox-coreaiox-core` and `Cannot find module ...`. Fix: pass workspace path via `WORKSPACE_DIR` env var, read inside the script as `process.env.WORKSPACE_DIR`. Applied to all 3 smoke install steps. ## docs/guides/release-procedure.md New SOP capturing the entire release pipeline: - Pre-flight checks (lint, tests, tokens) - 4-site version bump coordination (root, compat, installer, lock) - Two-system branch protection bypass dance (modern ruleset + legacy branch protection both enforce CODEOWNERS independently; `gh pr merge --admin` bypasses neither in isolation) - Atomic bypass→merge→restore as one shell block - Tag-driven publish trigger - Post-publish verification (registry sanity + artifact inspection) - Known CI quirks with mitigations - Rollback procedure (deprecate, don't unpublish) - Failure modes seen in production with root causes The SOP cites real PR numbers and incidents so future operators know why each step exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit feedback on PR #744 Two real bugs caught: ## notify step printed "Successfully published" for skipped scoped publish The previous condition gated the success block on `PUBLISH_RESULT = success OR skipped` AND pre-seeded the PUBLISHED array with `@aiox-squads/core@${VERSION}` unconditionally. Result: a workflow run where `publish` was skipped (e.g. manual dispatch excluding `core`) would falsely report the scoped package as published. Fix: PUBLISHED is now built only from jobs that actually succeeded. A separate branch handles the all-skipped case explicitly. The success message only renders when something was actually published. ## SOP bypass dance: snapshot reuse could leave protections relaxed The documented procedure piped the raw `gh api .../rulesets/...` output back into `gh api -X PUT`. The raw response includes read-only fields (`id`, `node_id`, `created_at`, `updated_at`, `_links`, `url`) that GitHub rejects with HTTP 422 on PUT — meaning the restore could silently fail and leave production in a relaxed state. Two fixes layered for safety: 1. Snapshot is now separated from restore payload. Raw snapshot stays on disk only as a diff baseline. Sanitized restore payload is derived via `jq '{name, target, enforcement, conditions, bypass_actors, rules}'` — only writable fields. 2. The bypass→merge→restore sequence is wrapped in `set -e` + a `trap 'restore_protections' EXIT`. The trap fires on ANY exit (success, failure, signal) and idempotently re-applies the sanitized restore payloads. If a restore step itself fails, the trap emits `::error::` annotations so the failure surfaces in the GitHub Actions UI. The previous documentation worked in practice because `gh api --input` tolerates read-only fields in some paths, but that behavior is not guaranteed. The new procedure is defensively correct under network failures, manual interrupts, and edge-case API rejections. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9b837ed commit 66b302a

3 files changed

Lines changed: 366 additions & 46 deletions

File tree

.github/workflows/installer-smoke-matrix.yml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,13 +135,19 @@ jobs:
135135
136136
- name: Smoke install (empty target, no ancestors)
137137
shell: bash
138+
env:
139+
# Pass workspace via env to avoid backslash escape mangling on Windows
140+
# (interpolating ${{ github.workspace }} inside node -e on Git Bash for
141+
# Windows turns `D:\a\aiox-core\aiox-core` into `D:aaiox-coreaiox-core`
142+
# because \a, \aiox are interpreted as escape sequences).
143+
WORKSPACE_DIR: ${{ github.workspace }}
138144
run: |
139145
set -e
140146
WORKDIR=$(mktemp -d)
141147
cd "$WORKDIR"
142148
echo "Smoke target: $WORKDIR"
143149
node -e "
144-
const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup');
150+
const proSetup = require(process.env.WORKSPACE_DIR + '/packages/installer/src/wizard/pro-setup');
145151
(async () => {
146152
const fs = require('fs');
147153
const path = require('path');
@@ -167,6 +173,8 @@ jobs:
167173
168174
- name: Smoke install (target nested inside an ancestor workspace)
169175
shell: bash
176+
env:
177+
WORKSPACE_DIR: ${{ github.workspace }}
170178
run: |
171179
set -e
172180
WS_ROOT=$(mktemp -d)
@@ -177,7 +185,7 @@ jobs:
177185
cd "$WS_ROOT/projects/aiox-install"
178186
echo "Smoke target: $(pwd)"
179187
node -e "
180-
const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup');
188+
const proSetup = require(process.env.WORKSPACE_DIR + '/packages/installer/src/wizard/pro-setup');
181189
(async () => {
182190
const fs = require('fs');
183191
const path = require('path');
@@ -209,6 +217,8 @@ jobs:
209217
210218
- name: Smoke install (target nested under plain package.json ancestor)
211219
shell: bash
220+
env:
221+
WORKSPACE_DIR: ${{ github.workspace }}
212222
run: |
213223
set -e
214224
PARENT_ROOT=$(mktemp -d)
@@ -219,7 +229,7 @@ jobs:
219229
cd "$PARENT_ROOT/subdir"
220230
echo "Smoke target: $(pwd)"
221231
node -e "
222-
const proSetup = require('${{ github.workspace }}/packages/installer/src/wizard/pro-setup');
232+
const proSetup = require(process.env.WORKSPACE_DIR + '/packages/installer/src/wizard/pro-setup');
223233
(async () => {
224234
const fs = require('fs');
225235
const path = require('path');

.github/workflows/npm-publish.yml

Lines changed: 88 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,13 @@ jobs:
354354
exit 1
355355
356356
publish_legacy_aiox_core:
357-
needs: [test, build]
358-
if: ${{ contains(format(',{0},', needs.build.outputs.packages), ',aiox-core,') }}
357+
# MUST wait for `publish` because compat/aiox-core declares
358+
# `@aiox-squads/core@<version>` as a runtime dependency. If this job runs
359+
# in parallel, the legacy smoke test races against npm propagation of the
360+
# scoped package and times out — making the workflow appear failed even
361+
# though both packages were published successfully.
362+
needs: [test, build, publish]
363+
if: ${{ contains(format(',{0},', needs.build.outputs.packages), ',aiox-core,') && (needs.publish.result == 'success' || needs.publish.result == 'skipped') }}
359364
runs-on: ubuntu-latest
360365
outputs:
361366
published: ${{ steps.should-publish.outputs.should_publish }}
@@ -439,21 +444,35 @@ jobs:
439444
if: steps.should-publish.outputs.should_publish == 'true'
440445
env:
441446
PKG_SPEC: aiox-core@${{ steps.pkg.outputs.version }}
447+
SCOPED_SPEC: '@aiox-squads/core@${{ steps.pkg.outputs.version }}'
442448
run: |
443-
for i in 1 2 3 4 5 6; do
449+
# Two phases: (1) wait for both packages to be visible in npm
450+
# registry, (2) actually exercise npx. Total budget: 16 × 15s = 240s
451+
# to accommodate slow CDN propagation (the legacy compat wraps the
452+
# scoped package, so BOTH must be resolvable before npx works).
453+
last_err=""
454+
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do
444455
sleep 15
445-
if npm view "${PKG_SPEC}" version > /dev/null 2>&1; then
446-
SMOKE_DIR=$(mktemp -d)
447-
if (cd "${SMOKE_DIR}" && npx --yes "${PKG_SPEC}" --version >/dev/null 2>&1); then
448-
rm -rf "${SMOKE_DIR}"
449-
echo "✅ Smoke test passed: npx --yes ${PKG_SPEC} --version works"
450-
exit 0
451-
fi
456+
if ! npm view "${PKG_SPEC}" version > /dev/null 2>&1; then
457+
echo "⏳ Waiting for ${PKG_SPEC} propagation... (attempt $i/16)"
458+
continue
459+
fi
460+
if ! npm view "${SCOPED_SPEC}" version > /dev/null 2>&1; then
461+
echo "⏳ ${PKG_SPEC} visible but ${SCOPED_SPEC} not yet... (attempt $i/16)"
462+
continue
463+
fi
464+
SMOKE_DIR=$(mktemp -d)
465+
if last_err=$(cd "${SMOKE_DIR}" && npx --yes "${PKG_SPEC}" --version 2>&1); then
452466
rm -rf "${SMOKE_DIR}"
467+
echo "✅ Smoke test passed: npx --yes ${PKG_SPEC} --version works"
468+
exit 0
453469
fi
454-
echo "⏳ Waiting for npm propagation... (attempt $i/6)"
470+
rm -rf "${SMOKE_DIR}"
471+
echo "⏳ npx attempt failed, retrying... (attempt $i/16)"
472+
echo " last npx stderr: $(echo "$last_err" | tail -3)"
455473
done
456-
echo "❌ Smoke test timeout for ${PKG_SPEC}"
474+
echo "❌ Smoke test timeout for ${PKG_SPEC} after 240s"
475+
echo " Last npx stderr: $last_err"
457476
exit 1
458477
459478
smoke_test_exports:
@@ -519,48 +538,74 @@ jobs:
519538
runs-on: ubuntu-latest
520539
steps:
521540
- name: Notify completion
541+
env:
542+
VERSION: ${{ needs.build.outputs.version }}
543+
PUBLISH_RESULT: ${{ needs.publish.result }}
544+
WORKSPACE_RESULT: ${{ needs.publish_workspace_packages.result }}
545+
LEGACY_RESULT: ${{ needs.publish_legacy_aiox_core.result }}
546+
LEGACY_PUBLISHED: ${{ needs.publish_legacy_aiox_core.outputs.published }}
547+
EXPORTS_RESULT: ${{ needs.smoke_test_exports.result }}
522548
run: |
523-
if [ "${{ needs.publish.result }}" = "failure" ] || [ "${{ needs.publish_workspace_packages.result }}" = "failure" ] || [ "${{ needs.publish_legacy_aiox_core.result }}" = "failure" ] || [ "${{ needs.smoke_test_exports.result }}" = "failure" ]; then
524-
echo "❌ Publishing failed"
549+
echo "=== Release pipeline summary for v${VERSION} ==="
550+
echo ""
551+
echo "Job results:"
552+
printf " %-32s %s\n" "publish (@aiox-squads/core)" "$PUBLISH_RESULT"
553+
printf " %-32s %s\n" "publish_workspace_packages" "$WORKSPACE_RESULT"
554+
printf " %-32s %s\n" "publish_legacy_aiox_core" "$LEGACY_RESULT (published=$LEGACY_PUBLISHED)"
555+
printf " %-32s %s\n" "smoke_test_exports" "$EXPORTS_RESULT"
556+
echo ""
557+
558+
# Hard failure: the scoped @aiox-squads/core publish IS THE release.
559+
# Workspace packages and legacy compat wrapper are advisory — they
560+
# can be republished out-of-band. Smoke tests are validation; if they
561+
# fail but publish succeeded, log a regression warning but don't
562+
# break the release signal (the artifact is still in the registry).
563+
if [ "$PUBLISH_RESULT" = "failure" ] || [ "$PUBLISH_RESULT" = "cancelled" ]; then
564+
echo "❌ HARD FAIL: scoped @aiox-squads/core@${VERSION} did not publish."
565+
echo " The release did NOT reach npmjs.org."
525566
exit 1
526567
fi
527568
528-
if [ "${{ needs.publish.result }}" = "cancelled" ] || [ "${{ needs.publish_workspace_packages.result }}" = "cancelled" ] || [ "${{ needs.publish_legacy_aiox_core.result }}" = "cancelled" ] || [ "${{ needs.smoke_test_exports.result }}" = "cancelled" ]; then
529-
echo "❌ Publishing was cancelled"
530-
exit 1
569+
# Soft warnings on partial failures.
570+
WARNINGS=()
571+
if [ "$WORKSPACE_RESULT" = "failure" ] || [ "$WORKSPACE_RESULT" = "cancelled" ]; then
572+
WARNINGS+=("workspace packages did not publish — investigate publish_workspace_packages")
531573
fi
532-
533-
PUBLISHED_PACKAGES=""
534-
535-
if [ "${{ needs.publish.result }}" = "success" ]; then
536-
PUBLISHED_PACKAGES="@aiox-squads/core"
574+
if [ "$LEGACY_RESULT" = "failure" ] || [ "$LEGACY_RESULT" = "cancelled" ]; then
575+
WARNINGS+=("legacy aiox-core wrapper did not publish — check NPM_TOKEN with owner access to legacy package")
537576
fi
538-
539-
if [ "${{ needs.publish_workspace_packages.result }}" = "success" ]; then
540-
if [ -n "$PUBLISHED_PACKAGES" ]; then
541-
PUBLISHED_PACKAGES="$PUBLISHED_PACKAGES, selected @aiox-squads workspace packages"
542-
else
543-
PUBLISHED_PACKAGES="selected @aiox-squads workspace packages"
544-
fi
577+
if [ "$EXPORTS_RESULT" = "failure" ]; then
578+
WARNINGS+=("smoke_test_exports failed — possible regression of issue #734 (ERR_PACKAGE_PATH_NOT_EXPORTED)")
545579
fi
546580
547-
if [ "${{ needs.publish_legacy_aiox_core.outputs.published }}" = "true" ]; then
548-
if [ -n "$PUBLISHED_PACKAGES" ]; then
549-
PUBLISHED_PACKAGES="$PUBLISHED_PACKAGES, aiox-core legacy wrapper"
550-
else
551-
PUBLISHED_PACKAGES="aiox-core legacy wrapper"
552-
fi
581+
PUBLISHED=()
582+
if [ "$PUBLISH_RESULT" = "success" ]; then
583+
PUBLISHED+=("@aiox-squads/core@${VERSION}")
584+
fi
585+
if [ "$WORKSPACE_RESULT" = "success" ]; then
586+
PUBLISHED+=("selected @aiox-squads workspace packages")
587+
fi
588+
if [ "$LEGACY_PUBLISHED" = "true" ]; then
589+
PUBLISHED+=("aiox-core@${VERSION} (legacy wrapper)")
553590
fi
554591
555-
if [ -n "$PUBLISHED_PACKAGES" ]; then
556-
echo "🎉 Successfully published to NPM:"
557-
echo " Packages: $PUBLISHED_PACKAGES"
558-
echo " Registry: ${{ env.NPM_REGISTRY }}"
559-
elif [ "${{ needs.publish.result }}" = "skipped" ] && [ "${{ needs.publish_workspace_packages.result }}" = "skipped" ] && [ "${{ needs.publish_legacy_aiox_core.result }}" = "skipped" ]; then
560-
echo "⏭️ No packages needed publishing (already up-to-date)"
592+
if [ ${#PUBLISHED[@]} -gt 0 ]; then
593+
echo "🎉 Successfully published to npmjs.org:"
594+
for pkg in "${PUBLISHED[@]}"; do
595+
echo " - $pkg"
596+
done
597+
elif [ "$PUBLISH_RESULT" = "skipped" ] && [ "$WORKSPACE_RESULT" = "skipped" ] && [ "$LEGACY_RESULT" = "skipped" ]; then
598+
echo "⏭️ No packages needed publishing (version selector skipped them all — already up-to-date or excluded)."
561599
else
562-
echo "❌ Publishing failed"
563-
exit 1
600+
echo "⚠️ No packages were actually published. Check job results above."
601+
fi
602+
603+
if [ ${#WARNINGS[@]} -gt 0 ]; then
604+
echo ""
605+
echo "⚠️ Soft warnings (release shipped but follow-up needed):"
606+
for w in "${WARNINGS[@]}"; do
607+
echo " - $w"
608+
done
564609
fi
565610
566611
create-release-notes:

0 commit comments

Comments
 (0)