From aea32197ece29734cbc574eb2d2663d4ef256bde Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sat, 11 Apr 2026 23:02:23 +0100 Subject: [PATCH 1/3] ci: fail loud on npm publish errors that aren't EPUBLISHCONFLICT (#51) The publish workflow used `|| true` after every `npm publish` step to tolerate "version already published" errors during reruns. The blast radius: every other failure mode was also silently masked, including ENEEDAUTH cascades. This caused the v0.3.1 silent failure (2026-04-03) and the v0.3.2 release saga (2026-04-11) - 14 publishes in a single run failed to authenticate but all reported success. This change replaces the unconditional tolerance with a wrapper that exits non-zero on any failure unless the npm output matches a known "already published" pattern (EPUBLISHCONFLICT, E409, "cannot publish over", "already published"). Also fails loud if `bun pm pack` produces zero .tgz files, which the previous glob-based `npm publish *.tgz` invocation would have silently tolerated. Closes #51 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/publish.yml | 123 ++++++++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7a73c48..58cb69b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -53,27 +53,107 @@ jobs: - name: Publish @aictrl/util working-directory: packages/util - run: bun pm pack && npm publish *.tgz --access public --provenance || true + run: | + # Pack, then publish the resulting tarball. We tolerate ONLY the + # "version already published" error (EPUBLISHCONFLICT / E409 / + # "cannot publish over" / "already published") so reruns after a + # partial failure still succeed. Every other error - ENEEDAUTH, + # network, etc - must fail loud. See issue #51 for the v0.3.1 / + # v0.3.2 silent-failure post-mortem that motivated this wrapper. + bun pm pack + tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) + if [ -z "$tarball" ]; then + echo "::error::bun pm pack produced no .tgz files in $(pwd)" + exit 1 + fi + set +e + output=$(npm publish "$tarball" --access public --provenance 2>&1) + code=$? + set -e + echo "$output" + if [ $code -ne 0 ]; then + if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then + echo "::notice::Tolerating 'already published' error for @aictrl/util" + else + exit $code + fi + fi - name: Publish @aictrl/plugin working-directory: packages/plugin - run: bun pm pack && npm publish *.tgz --access public --provenance || true + run: | + # See @aictrl/util step for wrapper rationale (issue #51). + bun pm pack + tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) + if [ -z "$tarball" ]; then + echo "::error::bun pm pack produced no .tgz files in $(pwd)" + exit 1 + fi + set +e + output=$(npm publish "$tarball" --access public --provenance 2>&1) + code=$? + set -e + echo "$output" + if [ $code -ne 0 ]; then + if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then + echo "::notice::Tolerating 'already published' error for @aictrl/plugin" + else + exit $code + fi + fi - name: Publish @aictrl/sdk working-directory: packages/sdk - run: bun pm pack && npm publish *.tgz --access public --provenance || true + run: | + # See @aictrl/util step for wrapper rationale (issue #51). + bun pm pack + tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) + if [ -z "$tarball" ]; then + echo "::error::bun pm pack produced no .tgz files in $(pwd)" + exit 1 + fi + set +e + output=$(npm publish "$tarball" --access public --provenance 2>&1) + code=$? + set -e + echo "$output" + if [ $code -ne 0 ]; then + if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then + echo "::notice::Tolerating 'already published' error for @aictrl/sdk" + else + exit $code + fi + fi - name: Publish platform binary packages working-directory: packages/cli/dist/@aictrl run: | - # Publish every @aictrl/cli--[-variant] package that the - # build step produced. Each directory contains a generated package.json - # stamped with the release version. Individual failures (e.g. version - # already published on a rerun) are tolerated to match the convention - # used by the other publish steps in this workflow. - for dir in */; do - echo "Publishing ${dir%/}" - (cd "$dir" && npm publish --access public --provenance) || true + # Publish every @aictrl/cli--[-variant] package that + # the build step produced. Each directory contains a generated + # package.json stamped with the release version. Individual + # "already published" errors are tolerated (for reruns), but every + # other failure - ENEEDAUTH, network, etc - fails loud. See #51. + shopt -s nullglob + dirs=(*/) + if [ ${#dirs[@]} -eq 0 ]; then + echo "::error::No platform binary package directories found in $(pwd)" + exit 1 + fi + for dir in "${dirs[@]}"; do + name="${dir%/}" + echo "Publishing ${name}" + set +e + output=$(cd "$dir" && npm publish --access public --provenance 2>&1) + code=$? + set -e + echo "$output" + if [ $code -ne 0 ]; then + if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then + echo "::notice::Tolerating 'already published' error for ${name}" + else + exit $code + fi + fi done - name: Strip bundled deps and refresh platform binary optionalDependencies @@ -133,4 +213,23 @@ jobs: - name: Publish @aictrl/cli working-directory: packages/cli - run: bun pm pack && npm publish *.tgz --access public --provenance + run: | + # See @aictrl/util step for wrapper rationale (issue #51). + bun pm pack + tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) + if [ -z "$tarball" ]; then + echo "::error::bun pm pack produced no .tgz files in $(pwd)" + exit 1 + fi + set +e + output=$(npm publish "$tarball" --access public --provenance 2>&1) + code=$? + set -e + echo "$output" + if [ $code -ne 0 ]; then + if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then + echo "::notice::Tolerating 'already published' error for @aictrl/cli" + else + exit $code + fi + fi From e3a3acd387ab6f9df47d58d37b5d816f5b2267f3 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 12 Apr 2026 02:16:40 +0100 Subject: [PATCH 2/3] ci: clean stale .tgz before bun pm pack per review feedback Adds `rm -f *.tgz` before `bun pm pack` in all 4 pack-then-publish steps. Ensures `ls -1 *.tgz | head -n1` always picks the freshly packed tarball, not a stale artifact from a prior invocation. Low risk today (each step runs in a fresh working directory), but cheap insurance against future workflow restructuring. Refs #51 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 58cb69b..7c5bbcd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -60,6 +60,7 @@ jobs: # partial failure still succeed. Every other error - ENEEDAUTH, # network, etc - must fail loud. See issue #51 for the v0.3.1 / # v0.3.2 silent-failure post-mortem that motivated this wrapper. + rm -f *.tgz bun pm pack tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) if [ -z "$tarball" ]; then @@ -83,6 +84,7 @@ jobs: working-directory: packages/plugin run: | # See @aictrl/util step for wrapper rationale (issue #51). + rm -f *.tgz bun pm pack tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) if [ -z "$tarball" ]; then @@ -106,6 +108,7 @@ jobs: working-directory: packages/sdk run: | # See @aictrl/util step for wrapper rationale (issue #51). + rm -f *.tgz bun pm pack tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) if [ -z "$tarball" ]; then @@ -215,6 +218,7 @@ jobs: working-directory: packages/cli run: | # See @aictrl/util step for wrapper rationale (issue #51). + rm -f *.tgz bun pm pack tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) if [ -z "$tarball" ]; then From 8a4e4d75a89915aba9812e4ee28640cfc9fd2697 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Sun, 12 Apr 2026 02:22:13 +0100 Subject: [PATCH 3/3] ci: extract publish wrapper to shared script, fix pipefail bug The pack-then-publish wrapper was duplicated across 4 steps. Extract to .github/scripts/publish-if-new.sh so changes to the error-pattern regex or wrapper logic only need to happen in one place. Also fixes a real bug: GitHub Actions runs `bash -eo pipefail` by default, so `ls -1 *.tgz 2>/dev/null | head -n1` exits immediately (exit code 2 from ls propagated by pipefail) when no .tgz files exist, making the subsequent `if [ -z "$tarball" ]` guard and its ::error:: annotation dead code. Replaced with a nullglob array, matching the pattern already used in the platform binary step. Added a warning annotation if bun pm pack produces multiple .tgz files (unexpected, but now visible instead of silently picking the first). Refs #51 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/scripts/publish-if-new.sh | 55 ++++++++++++++++++ .github/workflows/publish.yml | 93 ++----------------------------- 2 files changed, 59 insertions(+), 89 deletions(-) create mode 100755 .github/scripts/publish-if-new.sh diff --git a/.github/scripts/publish-if-new.sh b/.github/scripts/publish-if-new.sh new file mode 100755 index 0000000..96c1d20 --- /dev/null +++ b/.github/scripts/publish-if-new.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# publish-if-new.sh — pack-then-publish wrapper for the publish workflow. +# +# Tolerates ONLY "version already published" errors so reruns after a +# partial failure still succeed. Every other error (ENEEDAUTH, network, +# etc) fails loud. See issue #51 for the v0.3.1 / v0.3.2 silent-failure +# post-mortem that motivated this wrapper. +# +# 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"). + +set -euo pipefail + +label="${1:?Usage: publish-if-new.sh }" + +# Clean stale tarballs so we always publish the freshly packed one. +rm -f *.tgz + +bun pm pack + +# Use nullglob array to avoid pipefail exit on no matches (ls *.tgz +# returns exit 2 when nothing matches, which pipefail propagates). +shopt -s nullglob +tarballs=(*.tgz) +shopt -u nullglob + +if [ ${#tarballs[@]} -eq 0 ]; then + echo "::error::bun pm 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]}" +fi + +tarball="${tarballs[0]}" + +set +e +output=$(npm publish "$tarball" --access public --provenance 2>&1) +code=$? +set -e + +echo "$output" + +if [ $code -ne 0 ]; then + if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then + echo "::notice::Tolerating 'already published' error for ${label}" + else + exit $code + fi +fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7c5bbcd..cb8ddcd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -53,80 +53,15 @@ jobs: - name: Publish @aictrl/util working-directory: packages/util - run: | - # Pack, then publish the resulting tarball. We tolerate ONLY the - # "version already published" error (EPUBLISHCONFLICT / E409 / - # "cannot publish over" / "already published") so reruns after a - # partial failure still succeed. Every other error - ENEEDAUTH, - # network, etc - must fail loud. See issue #51 for the v0.3.1 / - # v0.3.2 silent-failure post-mortem that motivated this wrapper. - rm -f *.tgz - bun pm pack - tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) - if [ -z "$tarball" ]; then - echo "::error::bun pm pack produced no .tgz files in $(pwd)" - exit 1 - fi - set +e - output=$(npm publish "$tarball" --access public --provenance 2>&1) - code=$? - set -e - echo "$output" - if [ $code -ne 0 ]; then - if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then - echo "::notice::Tolerating 'already published' error for @aictrl/util" - else - exit $code - fi - fi + run: ${{ github.workspace }}/.github/scripts/publish-if-new.sh "@aictrl/util" - name: Publish @aictrl/plugin working-directory: packages/plugin - run: | - # See @aictrl/util step for wrapper rationale (issue #51). - rm -f *.tgz - bun pm pack - tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) - if [ -z "$tarball" ]; then - echo "::error::bun pm pack produced no .tgz files in $(pwd)" - exit 1 - fi - set +e - output=$(npm publish "$tarball" --access public --provenance 2>&1) - code=$? - set -e - echo "$output" - if [ $code -ne 0 ]; then - if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then - echo "::notice::Tolerating 'already published' error for @aictrl/plugin" - else - exit $code - fi - fi + run: ${{ github.workspace }}/.github/scripts/publish-if-new.sh "@aictrl/plugin" - name: Publish @aictrl/sdk working-directory: packages/sdk - run: | - # See @aictrl/util step for wrapper rationale (issue #51). - rm -f *.tgz - bun pm pack - tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) - if [ -z "$tarball" ]; then - echo "::error::bun pm pack produced no .tgz files in $(pwd)" - exit 1 - fi - set +e - output=$(npm publish "$tarball" --access public --provenance 2>&1) - code=$? - set -e - echo "$output" - if [ $code -ne 0 ]; then - if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then - echo "::notice::Tolerating 'already published' error for @aictrl/sdk" - else - exit $code - fi - fi + run: ${{ github.workspace }}/.github/scripts/publish-if-new.sh "@aictrl/sdk" - name: Publish platform binary packages working-directory: packages/cli/dist/@aictrl @@ -216,24 +151,4 @@ jobs: - name: Publish @aictrl/cli working-directory: packages/cli - run: | - # See @aictrl/util step for wrapper rationale (issue #51). - rm -f *.tgz - bun pm pack - tarball=$(ls -1 *.tgz 2>/dev/null | head -n1) - if [ -z "$tarball" ]; then - echo "::error::bun pm pack produced no .tgz files in $(pwd)" - exit 1 - fi - set +e - output=$(npm publish "$tarball" --access public --provenance 2>&1) - code=$? - set -e - echo "$output" - if [ $code -ne 0 ]; then - if echo "$output" | grep -qE 'EPUBLISHCONFLICT|E409|cannot publish over|already published'; then - echo "::notice::Tolerating 'already published' error for @aictrl/cli" - else - exit $code - fi - fi + run: ${{ github.workspace }}/.github/scripts/publish-if-new.sh "@aictrl/cli"