fix(ci): cut releases from main, not from release/*

publish.yml ran from release/* so its version-bump commit would reach main
through a PR. That guarded a package.json version field and a lockfile and left
the shipped tree ungated, which is the wrong way round — and it was not
theoretical. On release/cli-readme:

  00:33:19  v0.2.2 published to npm
  00:44:45  v0.2.3 published to npm
  00:48:18  PR #10 opened — first chance to review any of it
  00:48:21  Checks and Branch policy run for the first time
  00:51:33  merged to main

Two versions reached users from a branch with no pull request, no CI run and no
reviewer, seven minutes before the code reached main. `main` is not branch
protected either, so the PR route was never enforced to begin with.

Publishing from main means npm gets the tree that was reviewed, merged and
checked. The bump commit pushed back to main is mechanical and has nothing in
it to review; branch-policy.yml still governs the route for everything else.

Dry runs stay unrestricted — they publish and push nothing, and validating
packaging from a branch before merging it is what they are for.

The tag is now pushed on its own, ahead of the branch. main takes merges that a
release/* branch did not, so the bump commit can lose a race that a tag ref
cannot; rebasing to win it would move the commit off the tree that was actually
published. If the branch push loses, the release is still complete and recorded
by the tag, and the job says exactly which cherry-pick finishes it.
This commit is contained in:
Nayan
2026-08-12 01:59:54 +05:30
parent ba9f62622c
commit 8f0d2ebe34
2 changed files with 50 additions and 8 deletions
+44 -6
View File
@@ -7,9 +7,17 @@ run-name: "Publish · ${{ inputs.version || inputs.bump }}${{ inputs.dry_run &&
# cli-vX.Y.Z and pushes — the CI equivalent of `make release` plus pushing the
# tag, with no local steps.
#
# Run it from your release/* branch: the version-bump commit lands there and
# reaches main through the normal release PR. branch-policy.yml is what makes
# that the only route in.
# Run it from main. What goes to npm should be what was reviewed, merged and
# checked — and the only way to guarantee that is to publish the tree main
# actually has. This used to run from release/* so the version-bump commit
# would reach main through a PR; that protected a package.json version field
# and a lockfile while leaving the shipped tree ungated, and it showed: v0.2.2
# and v0.2.3 both went out from release/cli-readme before that branch had a
# pull request or a single Checks run, reaching users seven minutes before
# they reached main.
#
# The bump commit this job pushes to main is mechanical and there is nothing in
# it to review. branch-policy.yml still governs the route for everything else.
#
# Note: the cli-v* tag is pushed with GITHUB_TOKEN, which by design does NOT
# trigger release.yml — so this workflow publishes here, and the two lanes can
@@ -47,6 +55,20 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
# First, and before the checkout, so a misdirected release costs three
# seconds instead of a version. Dry runs are exempt: they publish nothing
# and push nothing, and validating packaging from a branch before you
# merge it is exactly what they are for.
- name: Releases come from main
if: ${{ inputs.dry_run == false }}
run: |
if [ "$GITHUB_REF_NAME" != "main" ]; then
echo "::error::Publish runs on main, not '$GITHUB_REF_NAME' — this would ship code main does not have."
echo " Merge the PR first, then dispatch this on main."
echo " To validate packaging from this branch instead: make release:ci DRY=1"
exit 1
fi
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -165,6 +187,9 @@ jobs:
- name: Commit, tag and push
if: ${{ inputs.dry_run == false }}
env:
TAG: ${{ steps.ver.outputs.tag }}
NEXT: ${{ steps.ver.outputs.next }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
@@ -172,9 +197,22 @@ jobs:
# --no-verify skips the husky commit-msg and pre-push hooks. The
# pre-push hook blocks pushes to main, and commit-msg would reject
# nothing here, but both are pointless in CI.
git commit --no-verify -m "chore(release): cli v${{ steps.ver.outputs.next }}"
git tag -a "${{ steps.ver.outputs.tag }}" -m "${{ steps.ver.outputs.tag }}"
git push --no-verify origin "HEAD:${{ github.ref_name }}" --follow-tags
git commit --no-verify -m "chore(release): cli v$NEXT"
git tag -a "$TAG" -m "$TAG"
# The tag goes first and on its own, rather than riding along on
# --follow-tags. It is the release record — it names the exact tree
# npm now has — and a tag ref cannot lose a race to a merge the way a
# branch ref can. Rebasing to win that race is not an option: it would
# move the commit off the tree that was actually published.
git push --no-verify origin "refs/tags/$TAG"
if ! git push --no-verify origin "HEAD:$GITHUB_REF_NAME"; then
echo "::error::v$NEXT is published and tagged, but main moved during the run and the bump commit did not land."
echo " The release itself is complete — only the bookkeeping commit is missing."
echo " Put it on main with: git cherry-pick $TAG"
exit 1
fi
# Last, and after the push on purpose. This checks a release that npm has
# already made permanent — it cannot prevent a bad one, only make it loud
+6 -2
View File
@@ -55,9 +55,13 @@ done
cd "$(git rev-parse --show-toplevel)" || die "not inside a git repository"
[ -f "$PKG_JSON" ] || die "$PKG_JSON not found"
# Releases go out from main, because what ships to npm should be what was
# reviewed and merged. Cutting from a branch publishes the branch: v0.2.2 and
# v0.2.3 both reached users from release/cli-readme before that branch had a
# pull request, a Checks run, or a single reviewer.
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [ "$BRANCH" = "main" ]; then
warn "you are on main — releases normally go out from a release/* branch"
if [ "$BRANCH" != "main" ]; then
warn "you are on '$BRANCH' — releases go out from main, so this would ship unmerged code"
fi
if [ -z "${ALLOW_DIRTY:-}" ] && [ -n "$(git status --porcelain)" ]; then