Skip to content

Commit 1d491d8

Browse files
ci: harden the release pipeline against stray-tag cascades and empty bumps (#26)
A stray v0.5.0 tag left by a failed run caused a version-skip cascade (0.5.0 limbo on main, then 0.6.0/0.7.0 from rapid re-triggers). Add four guards to the bump job: - concurrency (group: release) so re-triggered releases queue instead of racing - a preflight that refuses an empty release (no non-release commits since the last tag) - a tag-exists check that fails fast BEFORE committing, instead of pushing main then failing on the tag - --atomic push so the release commit and tag land together or not at all fetch-depth: 0 on checkout so the preflight can read history + tags.
1 parent 15f6ce2 commit 1d491d8

1 file changed

Lines changed: 25 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ jobs:
106106
if: github.event_name == 'workflow_dispatch'
107107
needs: check
108108
runs-on: ubuntu-latest
109+
# Serialize releases: a second dispatch queues behind the first instead of racing it
110+
# (rapid re-triggers were stacking version bumps).
111+
concurrency:
112+
group: release
113+
cancel-in-progress: false
109114
permissions:
110115
contents: write
111116
outputs:
@@ -114,6 +119,8 @@ jobs:
114119
steps:
115120
- uses: actions/checkout@v7
116121
with:
122+
# Full history + tags so the preflight guards (last tag, "nothing to release") work.
123+
fetch-depth: 0
117124
# Admin PAT (fine-grained, dev-coach only, Contents: RW) so the push to the
118125
# protected main branch bypasses branch protection (enforce_admins is off).
119126
# The default GITHUB_TOKEN runs as github-actions[bot], which is not an admin
@@ -122,13 +129,28 @@ jobs:
122129
- uses: actions/setup-node@v6
123130
with:
124131
node-version-file: .node-version
132+
- name: Preflight — refuse an empty release
133+
run: |
134+
set -euo pipefail
135+
git fetch --tags --force --quiet origin
136+
LAST="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)"
137+
if [ -n "$LAST" ]; then
138+
N="$(git log "${LAST}..HEAD" --invert-grep --grep='^chore: release' --oneline | wc -l | tr -d ' ')"
139+
[ "$N" != "0" ] || { echo "::error::Nothing to release since ${LAST} (only release commits) — aborting empty bump."; exit 1; }
140+
fi
125141
- name: Bump, commit, tag, push
126142
id: bump
127143
run: |
128144
set -euo pipefail
129145
TARGET="${{ inputs.version != '' && inputs.version || inputs.bump }}"
130146
NEW="$(npm version "$TARGET" --no-git-tag-version)"
131147
NEW="${NEW#v}"
148+
# Fail fast if the tag already exists — prevents the stray-tag cascade / version skew.
149+
if git rev-parse -q --verify "refs/tags/v${NEW}" >/dev/null \
150+
|| git ls-remote --exit-code --tags origin "v${NEW}" >/dev/null 2>&1; then
151+
echo "::error::Tag v${NEW} already exists — delete it first: git push origin :refs/tags/v${NEW}"
152+
exit 1
153+
fi
132154
# keep the .mcpb manifest version in sync (the build also syncs it; commit it tidy)
133155
NEW="$NEW" node -e 'const fs=require("fs"),f="mcpb/manifest.json";fs.writeFileSync(f,fs.readFileSync(f,"utf8").replace(/"version": "[^"]+"/,`"version": "${process.env.NEW}"`))'
134156
# keep the Claude Code plugin manifest + bundled SKILL.md in sync with package.json
@@ -142,7 +164,9 @@ jobs:
142164
# double-publish. Publish/release still run in THIS workflow_dispatch run via needs.bump.
143165
git commit -m "chore: release v${NEW} [skip ci]"
144166
git tag "v${NEW}"
145-
git push origin HEAD:main "v${NEW}"
167+
# --atomic: main commit and tag land together or not at all — no half-state where
168+
# main got the bump but the tag push failed (which silently skips a version).
169+
git push --atomic origin HEAD:main "v${NEW}"
146170
echo "version=${NEW}" >> "$GITHUB_OUTPUT"
147171
echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
148172

0 commit comments

Comments
 (0)