ci: make pushing a tag the only manual release step - #901
Conversation
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.
WalkthroughThe 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. ChangesRelease synchronization
Estimated code review effort: 4 (Complex) | ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
.github/scripts/release-sync.mjs (2)
51-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoffRe-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 formatspackage.jsonorpackage-lock.jsondifferently, 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 winAdd 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.timeoutand 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
📒 Files selected for processing (2)
.github/scripts/release-sync.mjs.github/workflows/release.yaml
| - 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 |
There was a problem hiding this comment.
🩺 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.
| - 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.
There was a problem hiding this comment.
💡 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}`); |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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 👍 / 👎.
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.
Summary
Releasing currently means: bump
package.json+package-lock.json, write theCHANGELOG.mdsection, merge, and only then tag. This moves the mechanical part into the workflow so pushing av*tag is the only manual step.The trigger is unchanged —
on: push: tags: ['v*'], nothing else creates releases.Cutting a release
The tag must be at the current
mainhead. The bump from0.15.2to0.16.0is handled by the workflow. Writing aCHANGELOG.mdsection beforehand is optional, and still preferred over the generated fallback.What the workflow does
mainhead (/comparemust returnidentical).aheadmeans the tag was never merged;behindmeans 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.versioninpackage.jsonandversion+packages[""].versioninpackage-lock.jsonfrom the tag. No-op if they already match.bot/start.ts:291readspackage.jsonat runtime.CHANGELOG.mdsection when present, falling back to commit subjects since the previous tag.Release runs share a
concurrency: releasegroup so two tags pushed close together queue instead of interleaving their bumps.Why the Contents API instead of
git pushmainhas 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, soGITHUB_TOKENwithcontents: writeis sufficient.Deliberate trade-offs
git tag -a, the tag object is recreated over the bump commit with the same message.git tag -sstill works, but is downgraded to annotated with a::warning::in the log.chore: bump package.json,chore: sync package-lock.json), each triggeringci_to_main. The Contents API writes one file per commit; that is also what makes those commits GitHub-signed, whichmainrequires. Collapsing them into one needs the Git Database API, whose commits are not signed.package-lock.jsonis 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,
mainhas the bump but the tag does not. The script detects that exact state and prints the fix:The re-run then finds the versions already correct, skips the bumps, and publishes the release.
Test plan
Verified locally:
node --checkpasses andrelease.yamlparses as YAMLv0.16.0"; system("id"); #andv$(id); escaped dots stop0.16.0matching a0x16y0heading; the real0.15.0section still extracts; the fallback excludes this workflow's own bump commitsNot verifiable outside GitHub — the live API calls. The first tagged release should be watched in the Actions log.