Skip to content

ci: make pushing a tag the only manual release step - #901

Merged
grunch merged 4 commits into
mainfrom
ci/release-on-tag-push
Aug 7, 2026
Merged

ci: make pushing a tag the only manual release step#901
grunch merged 4 commits into
mainfrom
ci/release-on-tag-push

Conversation

@grunch

@grunch grunch commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

Releasing currently means: bump package.json + package-lock.json, write the CHANGELOG.md section, merge, and only then tag. This moves the mechanical part into the workflow so pushing a v* tag is the only manual step.

The trigger is unchanged — on: push: tags: ['v*'], nothing else creates releases.

Cutting a release

git fetch origin && git tag v0.16.0 origin/main && git push origin v0.16.0

The tag must be at the current main head. The bump from 0.15.2 to 0.16.0 is handled by the workflow. Writing a CHANGELOG.md section beforehand is optional, and still preferred over the generated fallback.

What the workflow does

  1. Requires the tagged commit to be exactly the main head (/compare must return identical). ahead means the tag was never merged; behind means the branch moved on after tagging. Both are rejected, because the tag gets moved onto the bump commit and either case would pull commits into the release that were never tagged.
  2. Sets version in package.json and version + packages[""].version in package-lock.json from the tag. No-op if they already match.
  3. Asserts the parent each bump commit was built on. The blob sha sent to the Contents API only guards against a concurrent change to that same file, so this closes the window between the head check and the writes.
  4. Re-points the tag at the bump commit, so the released tree reports the correct version — bot/start.ts:291 reads package.json at runtime.
  5. Builds release notes from the CHANGELOG.md section when present, falling back to commit subjects since the previous tag.

Release runs share a concurrency: release group so two tags pushed close together queue instead of interleaving their bumps.

Why the Contents API instead of git push

main has required signed commits enabled, so an unsigned push from Actions would be rejected. Commits created through the Contents API are signed by GitHub, so they pass. Branch protection requires no PR review, so GITHUB_TOKEN with contents: write is sufficient.

Deliberate trade-offs

  • Release tags end up unsigned. A signature covers the object it was made on, so moving a tag necessarily invalidates it — no implementation can preserve one here. Annotated tags are preserved: if you push git tag -a, the tag object is recreated over the bump commit with the same message. git tag -s still works, but is downgraded to annotated with a ::warning:: in the log.
  • Two commits per release (chore: bump package.json, chore: sync package-lock.json), each triggering ci_to_main. The Contents API writes one file per commit; that is also what makes those commits GitHub-signed, which main requires. Collapsing them into one needs the Git Database API, whose commits are not signed.
  • Known debt: the Contents API cannot read or write blobs above 1 MB. package-lock.json is at ~264 KB. When it nears the limit this has to move to the Git Database API. Until then the script fails with an explicit message rather than an opaque parse error.

Recovery from an interrupted run

If a run dies between the bump commits and the re-point, main has the bump but the tag does not. The script detects that exact state and prints the fix:

git fetch origin && git tag -f v0.16.0 origin/main && git push -f origin v0.16.0

The re-run then finds the versions already correct, skips the bumps, and publishes the release.

Test plan

Verified locally:

  • node --check passes and release.yaml parses as YAML
  • 10 end-to-end scenarios against a mocked GitHub API: lightweight tag, annotated tag (tag object recreated), signed tag (warned + downgraded), already-bumped (no commits, tag untouched), interrupted-run recovery (explains the fix), genuinely behind, tag not merged, branch moving mid-release (aborts before the tag moves), lockfile over 1 MB, malformed tag
  • Notes step: the version guard rejects v0.16.0"; system("id"); # and v$(id); escaped dots stop 0.16.0 matching a 0x16y0 heading; the real 0.15.0 section still extracts; the fallback excludes this workflow's own bump commits
  • The JSON transform reproduces both files byte for byte apart from the 3 version fields, so bump commits carry no formatting noise

Not verifiable outside GitHub — the live API calls. The first tagged release should be watched in the Actions log.

The Release workflow already fired only on `v*` tags, but preparing a
release still required bumping package.json and package-lock.json by
hand and writing a CHANGELOG section before tagging.

The workflow now derives the version from the tag and syncs those files
itself, then re-points the tag at the bump commit so the released tree
reports the right version (bot/start.ts reads package.json). Release
notes still come from the CHANGELOG section when it exists, and fall
back to commit subjects since the previous tag when it does not.

Version files are committed through the Contents API rather than
`git push` because main requires signed commits, and only API-created
commits are signed by GitHub. A tag that is not already merged into the
default branch is rejected instead of being moved.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The release workflow now synchronizes package versions when a semantic-version tag is pushed. It validates tag history, updates version files through GitHub’s API, repoints changed tags, and generates fallback release notes.

Changes

Release synchronization

Layer / File(s) Summary
Release tag and version synchronization
.github/scripts/release-sync.mjs
The script validates environment variables and semantic-version tags, reads package files through the authenticated Contents API, updates versions sequentially, commits changes, and repoints changed tags.
Tag-triggered release workflow
.github/workflows/release.yaml
The workflow fetches full history and tags, runs synchronization, generates fallback release notes, and passes the pushed tag to the release action.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit with a tag in sight,
Version files hop to the right.
Commits march in a tidy line,
Release notes bloom in package light.
The final tag lands—thump, all fine!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: making a tag push the only required manual release step.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/release-on-tag-push

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
.github/scripts/release-sync.mjs (2)

51-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Re-serialization rewrites the whole file.

JSON.stringify(data, null, 2) discards the original formatting and key order is preserved only for plain objects. If the repository formats package.json or package-lock.json differently, every release produces a large unrelated diff. Consider patching the version line in the original text instead of re-emitting the parsed object.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/release-sync.mjs around lines 51 - 62, Update commitJsonFile
to preserve the original file text rather than re-serializing data with
JSON.stringify, and patch only the relevant version line before base64 encoding
and committing. Keep the existing API update, commit message, branch, and
returned SHA behavior unchanged.

27-43: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout and retry to the API helper.

Every call is a blocking network request with no timeout and no retry. A transient 5xx or a secondary rate limit aborts the run after some commits are already created, which leaves the tag pointing at the old commit while the branch carries a partial bump. Add AbortSignal.timeout and a small retry for 5xx and 403 rate-limit responses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/release-sync.mjs around lines 27 - 43, The api helper must
bound each fetch with AbortSignal.timeout and retry a small number of times for
transient 5xx responses and 403 rate-limit responses. Update api to preserve its
existing request and error behavior while retrying only those conditions, then
throw the final response error after retries are exhausted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/scripts/release-sync.mjs:
- Around line 66-75: Update assertTagIsOnBranch to accept only an identical
comparison status, rejecting behind and any other status before repointTag or
release updates proceed. Preserve the existing error behavior while ensuring a
tag whose target differs from the branch head cannot be moved or used for the
release.
- Around line 22-25: Update the tag validation around version in
release-sync.mjs to accept the prerelease and build-metadata formats supported
by the v* workflow trigger, while preserving validation of the required
MAJOR.MINOR.PATCH core. Ensure tags such as v1.2.3-rc.1 and v1.2.3+build remain
valid and malformed versions are still rejected.
- Around line 45-49: Update readJsonFile to handle GitHub Contents responses
whose encoding is not base64: request the raw media type so content remains
available, or explicitly fail with a clear error when file.encoding is not
base64 before parsing. Preserve the existing SHA and JSON parsing behavior for
valid base64 responses.

In @.github/workflows/release.yaml:
- Around line 30-46: Update the “Build release notes” run block to check that
CHANGELOG.md exists before invoking awk. When the file is absent, skip changelog
extraction and continue into the existing fallback that generates notes from git
log; preserve the current extraction behavior when the file is present.

---

Nitpick comments:
In @.github/scripts/release-sync.mjs:
- Around line 51-62: Update commitJsonFile to preserve the original file text
rather than re-serializing data with JSON.stringify, and patch only the relevant
version line before base64 encoding and committing. Keep the existing API
update, commit message, branch, and returned SHA behavior unchanged.
- Around line 27-43: The api helper must bound each fetch with
AbortSignal.timeout and retry a small number of times for transient 5xx
responses and 403 rate-limit responses. Update api to preserve its existing
request and error behavior while retrying only those conditions, then throw the
final response error after retries are exhausted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a5012591-9d77-424e-ad21-5576ec167aff

📥 Commits

Reviewing files that changed from the base of the PR and between a29b8a4 and 5724b9a.

📒 Files selected for processing (2)
  • .github/scripts/release-sync.mjs
  • .github/workflows/release.yaml

Comment thread .github/scripts/release-sync.mjs
Comment thread .github/scripts/release-sync.mjs
Comment thread .github/scripts/release-sync.mjs Outdated
Comment on lines +30 to +46
- name: Build release notes
run: |
VERSION="${GITHUB_REF_NAME#v}"
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md

# No hand-written changelog entry for this version: fall back to the
# commit subjects since the previous tag.
if [ ! -s /tmp/release-notes.md ]; then
PREVIOUS_TAG="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
RANGE="${GITHUB_REF_NAME}"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${GITHUB_REF_NAME}"
{
echo "### Changes"
echo
git log --no-merges --pretty='- %s' "$RANGE"
} > /tmp/release-notes.md
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard against a missing CHANGELOG.md.

run steps use bash -e. If CHANGELOG.md does not exist, awk exits non-zero and the step fails before the fallback runs. Test for the file first.

🐛 Proposed fix
           VERSION="${GITHUB_REF_NAME#v}"
-          awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
+          : > /tmp/release-notes.md
+          if [ -f CHANGELOG.md ]; then
+            awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
+          fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Build release notes
run: |
VERSION="${GITHUB_REF_NAME#v}"
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
# No hand-written changelog entry for this version: fall back to the
# commit subjects since the previous tag.
if [ ! -s /tmp/release-notes.md ]; then
PREVIOUS_TAG="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
RANGE="${GITHUB_REF_NAME}"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${GITHUB_REF_NAME}"
{
echo "### Changes"
echo
git log --no-merges --pretty='- %s' "$RANGE"
} > /tmp/release-notes.md
fi
- name: Build release notes
run: |
VERSION="${GITHUB_REF_NAME#v}"
: > /tmp/release-notes.md
if [ -f CHANGELOG.md ]; then
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
fi
# No hand-written changelog entry for this version: fall back to the
# commit subjects since the previous tag.
if [ ! -s /tmp/release-notes.md ]; then
PREVIOUS_TAG="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
RANGE="${GITHUB_REF_NAME}"
[ -n "$PREVIOUS_TAG" ] && RANGE="${PREVIOUS_TAG}..${GITHUB_REF_NAME}"
{
echo "### Changes"
echo
git log --no-merges --pretty='- %s' "$RANGE"
} > /tmp/release-notes.md
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yaml around lines 30 - 46, Update the “Build
release notes” run block to check that CHANGELOG.md exists before invoking awk.
When the file is absent, skip changelog extraction and continue into the
existing fallback that generates notes from git log; preserve the current
extraction behavior when the file is present.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5724b9afe6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

};

const readJsonFile = async path => {
const file = await api(`/contents/${path}?ref=${branch}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin release contents to the tagged commit

In the tag-triggered release job, when the pushed tag is an older commit on the default branch—or the branch advances after the comparison—this reads the files from the branch head rather than the tagged revision. The subsequent Contents API writes therefore build on that newer head, and repointTag moves the release tag to a tree containing changes that were never in the pushed tag. Build the bump from the tagged commit and update the branch only if its head still matches, or reject tags that are not at the current head.

Useful? React with 👍 / 👎.

@@ -13,17 +16,41 @@ jobs:
runs-on: ubuntu-latest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize branch-mutating release jobs

When two v* tags are pushed close together, both instances of this job can mutate the same version files on the default branch concurrently. For example, one run can write package.json for tag A, a second run can overwrite it for tag B, and then the first run can write package-lock.json for tag A and point tag A at that mixed-version commit. Add workflow-level concurrency shared across all release tags, without canceling an in-progress release.

Useful? React with 👍 / 👎.

grunch added 3 commits August 7, 2026 17:30
Require the tag to point at the branch head instead of merely being an
ancestor of it. The bump commit is created on top of the head and the tag
is moved onto it, so a "behind" tag would silently pull commits into the
release that were never tagged.

Accept the prerelease and build metadata that the `v*` trigger already
lets through, reject Contents API responses that are not base64 encoded
with a clear message instead of an opaque parse error, and bound each
request so a stalled connection cannot hang the job.
The blob sha sent to the Contents API only guards against a concurrent
change to that same file, so the default branch could still advance
between the head comparison and the writes, leaving the tag pointing at
commits it never covered. Each bump now asserts the parent its commit
was built on and aborts before the tag is moved when it does not match.

Release runs also mutate a shared branch, so two tags pushed close
together could interleave their bumps. Serialize them with a workflow
concurrency group, without canceling in-progress runs.
Re-pointing the tag replaced the ref with a bare commit, so `git tag -a`
lost its message. Recreate the tag object over the bump commit when the
pushed tag was annotated. A signature cannot be carried over, since it
covers the object the tag was made on, so signed tags are downgraded
with an explicit warning and release tags are unsigned by design.

A run that died between the bump commits and the re-point failed on
re-run with "tag the current main head", which did not hint that the
bump had already landed. Detect that state and print the exact recovery
command instead.

Also harden the notes step, which interpolates the tag name into an awk
program: validate the version locally instead of relying on the sync
step running first, escape the dots so 0.16.0 cannot match 0x16y0, and
drop this workflow's own bump commits from the generated fallback.
@grunch
grunch merged commit 9e0db00 into main Aug 7, 2026
7 checks passed
@grunch
grunch deleted the ci/release-on-tag-push branch August 7, 2026 20:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant