From 127975fd2f26c7a46267b3afc50a14d469a8c5c0 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 18 Aug 2026 15:36:58 +0530 Subject: [PATCH 1/6] ci: cut the release when the release PR merges Releasing currently means someone remembers to tag master by hand, which is also the only thing that triggers the NPM publish. The merge of the release PR already carries everything a release needs - the version bump in package.json and the CHANGELOG entry - so use it as the signal. On a push to master the workflow compares package.json against the previous commit and stops unless the version changed and is not already tagged, so ordinary merges do nothing and a re-run is a no-op. When it does fire it tags that exact commit, publishes a GitHub release whose notes are the CHANGELOG section for the version (matching what we have been pasting in by hand), and then publishes to NPM. The publish is called directly rather than left to the tag push: a tag created with GITHUB_TOKEN does not trigger workflows, so the existing tag-triggered job would silently never run. release.yml therefore gains a workflow_call trigger and keeps its tag trigger for manual tags; its job-level ref check is dropped because the tag filter and the existing 3.x.x version check already cover it. Co-authored-by: Cursor --- .github/workflows/release-on-version-bump.yml | 128 ++++++++++++++++++ .github/workflows/release.yml | 6 +- 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release-on-version-bump.yml diff --git a/.github/workflows/release-on-version-bump.yml b/.github/workflows/release-on-version-bump.yml new file mode 100644 index 0000000..499758e --- /dev/null +++ b/.github/workflows/release-on-version-bump.yml @@ -0,0 +1,128 @@ +name: Release on version bump + +# Releases are cut by merging the release PR (generated by cb-sdk-gen) into +# master. That merge is the only signal needed: it carries the version bump and +# the CHANGELOG entry, so this workflow tags the merge commit, publishes a GitHub +# release whose notes are that CHANGELOG entry, and then publishes to NPM. +# +# Nothing happens on a push that does not change the version in package.json, and +# a version that is already tagged is left alone, so re-running this is safe. +on: + push: + branches: + - master + +permissions: + contents: read + +jobs: + detect: + name: Detect version bump + runs-on: ubuntu-latest + outputs: + release: ${{ steps.check.outputs.release }} + version: ${{ steps.check.outputs.version }} + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + # Full history so the previous commit's package.json and the existing + # tags are available. + fetch-depth: 0 + + - name: Compare version against the previous commit + id: check + env: + BEFORE: ${{ github.event.before }} + run: | + NEW_VERSION=$(jq -r .version package.json) + + # github.event.before is unusable after a force push (all zeros) or if + # the commit is gone, so fall back to the first parent. + PREV="$BEFORE" + if [ -z "$PREV" ] || [ "$PREV" = "0000000000000000000000000000000000000000" ] \ + || ! git cat-file -e "$PREV^{commit}" 2>/dev/null; then + PREV=$(git rev-parse --verify HEAD^ 2>/dev/null || true) + fi + + OLD_VERSION="" + if [ -n "$PREV" ]; then + OLD_VERSION=$(git show "$PREV:package.json" 2>/dev/null | jq -r .version 2>/dev/null || true) + fi + + echo "previous commit: ${PREV:-}" + echo "version: ${OLD_VERSION:-} -> $NEW_VERSION" + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + + if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then + echo "No version bump in this push; nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -n "$(git ls-remote --tags origin "refs/tags/v$NEW_VERSION")" ]; then + echo "v$NEW_VERSION is already tagged; nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Releasing v$NEW_VERSION." + echo "release=true" >> "$GITHUB_OUTPUT" + + github-release: + name: Tag and publish GitHub release + needs: detect + if: ${{ needs.detect.outputs.release == 'true' }} + runs-on: ubuntu-latest + permissions: + # Creating the tag and the release. + contents: write + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Extract release notes from CHANGELOG + env: + VERSION: ${{ needs.detect.outputs.version }} + run: | + # The section runs from its own "### v (date)" heading to the + # next version heading. Matched as a literal prefix so the dots in the + # version are not treated as wildcards. + awk -v ver="$VERSION" ' + BEGIN { head = "### v" ver " " } + substr($0, 1, length(head)) == head { found = 1; print; next } + found && /^### v/ { exit } + found { print } + ' CHANGELOG.md > /tmp/release-notes.md + + if [ ! -s /tmp/release-notes.md ]; then + echo "::error::No '### v$VERSION' section found in CHANGELOG.md, so there are no release notes to publish. Fix the changelog on master and re-run this workflow." + exit 1 + fi + + echo "Release notes:" + cat /tmp/release-notes.md + + - name: Create the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.detect.outputs.version }} + run: | + # --target pins the tag to the commit that was pushed rather than to + # whatever master points at by the time this runs. The tag does not + # exist yet, so this call creates it. + gh release create "v$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "v$VERSION" \ + --notes-file /tmp/release-notes.md + + publish: + name: Publish to NPM + needs: [detect, github-release] + if: ${{ needs.detect.outputs.release == 'true' }} + # Called directly rather than left to the tag push: a tag created with + # GITHUB_TOKEN does not trigger other workflows, so relying on the push + # event would silently skip the NPM publish. + uses: ./.github/workflows/release.yml + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c95eff..e129c7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,12 @@ on: push: tags: - 'v3*' - + # Called by release-on-version-bump.yml once it has tagged the merge commit, + # because a tag created by GITHUB_TOKEN does not trigger the push event above. + workflow_call: + jobs: release: - if: startsWith(github.ref, 'refs/tags/v3') runs-on: ubuntu-latest steps: From ab5f3310efe7c0b5d6ec9f306759e57e1bc3f910 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Wed, 19 Aug 2026 11:12:37 +0530 Subject: [PATCH 2/6] ci: add a dry-run dispatch to the release workflow Gives a way to exercise the release path on GitHub without creating anything: a manual run resolves the version, prints the release notes it would publish, and stops before the tag, the release and the NPM publish. It defaults to on, so a manual run cannot release by accident - pushes to master are unaffected and still release for real. Co-authored-by: Cursor --- .github/workflows/release-on-version-bump.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-on-version-bump.yml b/.github/workflows/release-on-version-bump.yml index 499758e..8af7f8c 100644 --- a/.github/workflows/release-on-version-bump.yml +++ b/.github/workflows/release-on-version-bump.yml @@ -11,6 +11,16 @@ on: push: branches: - master + # Manual runs are for checking what a release would do: they default to a dry + # run that resolves the version and prints the notes without creating the tag, + # the release or the NPM publish. + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (resolve the version and print the release notes, create nothing)" + required: false + default: true + type: boolean permissions: contents: read @@ -107,7 +117,13 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.detect.outputs.version }} + DRY_RUN: ${{ inputs.dry_run == true }} run: | + if [ "$DRY_RUN" = "true" ]; then + echo "Dry run: would tag $GITHUB_SHA as v$VERSION and publish the release notes above. Nothing was created." + exit 0 + fi + # --target pins the tag to the commit that was pushed rather than to # whatever master points at by the time this runs. The tag does not # exist yet, so this call creates it. @@ -120,7 +136,7 @@ jobs: publish: name: Publish to NPM needs: [detect, github-release] - if: ${{ needs.detect.outputs.release == 'true' }} + if: ${{ needs.detect.outputs.release == 'true' && inputs.dry_run != true }} # Called directly rather than left to the tag push: a tag created with # GITHUB_TOKEN does not trigger other workflows, so relying on the push # event would silently skip the NPM publish. From 3a977b1064b43839a93702a6de45102862c7efac Mon Sep 17 00:00:00 2001 From: cb-alish Date: Wed, 19 Aug 2026 11:20:41 +0530 Subject: [PATCH 3/6] ci: stop release notes leaking past older changelog headings Replaying past releases through the workflow surfaced a case where the notes would have been wrong. The section terminator only recognised "### v" headings, but entries up to v3.0.2 use "## v", so releasing the version above them (v3.1.0) extracted its own notes plus the whole of v3.0.2, v3.0.1 and v3.0.0. Any heading whose first word is a version now ends the section, at any depth, and the version is compared as a whole word so a release cannot pick up a "-beta" section of the same number. CRLF changelogs are tolerated too. Checked against all 192 versions in CHANGELOG.md: each section starts at its own heading and stops before the next. Co-authored-by: Cursor --- .github/workflows/release-on-version-bump.yml | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-on-version-bump.yml b/.github/workflows/release-on-version-bump.yml index 8af7f8c..d007e5b 100644 --- a/.github/workflows/release-on-version-bump.yml +++ b/.github/workflows/release-on-version-bump.yml @@ -95,14 +95,33 @@ jobs: env: VERSION: ${{ needs.detect.outputs.version }} run: | - # The section runs from its own "### v (date)" heading to the - # next version heading. Matched as a literal prefix so the dots in the - # version are not treated as wildcards. + # The section runs from this version's heading to the next version + # heading. Any heading whose first word is a version ends it: depth and + # spacing vary over the file's history ("### v3.30.1 (2026-08-11)" is + # the norm, older entries use "## v3.0.2 (2024-11-09)"), and treating + # only "### v" as a boundary would swallow the older entries whole. + # The version is compared as a whole word, so releasing 3.31.0 cannot + # pick up a "v3.31.0-beta.1" section. awk -v ver="$VERSION" ' - BEGIN { head = "### v" ver " " } - substr($0, 1, length(head)) == head { found = 1; print; next } - found && /^### v/ { exit } - found { print } + function heading_version(line, rest) { + if (line !~ /^##+[[:space:]]*v?[0-9]+\.[0-9]+/) return "" + rest = line + sub(/^##+[[:space:]]*/, "", rest) + sub(/[[:space:]].*$/, "", rest) + sub(/^v/, "", rest) + return rest + } + { + line = $0 + sub(/\r$/, "", line) + this = heading_version(line) + if (this != "") { + if (this == ver) { found = 1; print line; next } + if (found) exit + next + } + if (found) print line + } ' CHANGELOG.md > /tmp/release-notes.md if [ ! -s /tmp/release-notes.md ]; then From f1f7e95d85e779e2af06af90b52da2f37f2b6f53 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Wed, 19 Aug 2026 13:14:52 +0530 Subject: [PATCH 4/6] ci: drop the explanatory comments from the release workflows Co-authored-by: Cursor --- .github/workflows/release-on-version-bump.yml | 28 ------------------- .github/workflows/release.yml | 2 -- 2 files changed, 30 deletions(-) diff --git a/.github/workflows/release-on-version-bump.yml b/.github/workflows/release-on-version-bump.yml index d007e5b..aa92445 100644 --- a/.github/workflows/release-on-version-bump.yml +++ b/.github/workflows/release-on-version-bump.yml @@ -1,19 +1,9 @@ name: Release on version bump -# Releases are cut by merging the release PR (generated by cb-sdk-gen) into -# master. That merge is the only signal needed: it carries the version bump and -# the CHANGELOG entry, so this workflow tags the merge commit, publishes a GitHub -# release whose notes are that CHANGELOG entry, and then publishes to NPM. -# -# Nothing happens on a push that does not change the version in package.json, and -# a version that is already tagged is left alone, so re-running this is safe. on: push: branches: - master - # Manual runs are for checking what a release would do: they default to a dry - # run that resolves the version and prints the notes without creating the tag, - # the release or the NPM publish. workflow_dispatch: inputs: dry_run: @@ -36,8 +26,6 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 with: - # Full history so the previous commit's package.json and the existing - # tags are available. fetch-depth: 0 - name: Compare version against the previous commit @@ -47,8 +35,6 @@ jobs: run: | NEW_VERSION=$(jq -r .version package.json) - # github.event.before is unusable after a force push (all zeros) or if - # the commit is gone, so fall back to the first parent. PREV="$BEFORE" if [ -z "$PREV" ] || [ "$PREV" = "0000000000000000000000000000000000000000" ] \ || ! git cat-file -e "$PREV^{commit}" 2>/dev/null; then @@ -85,7 +71,6 @@ jobs: if: ${{ needs.detect.outputs.release == 'true' }} runs-on: ubuntu-latest permissions: - # Creating the tag and the release. contents: write steps: - name: Checkout repo @@ -95,13 +80,6 @@ jobs: env: VERSION: ${{ needs.detect.outputs.version }} run: | - # The section runs from this version's heading to the next version - # heading. Any heading whose first word is a version ends it: depth and - # spacing vary over the file's history ("### v3.30.1 (2026-08-11)" is - # the norm, older entries use "## v3.0.2 (2024-11-09)"), and treating - # only "### v" as a boundary would swallow the older entries whole. - # The version is compared as a whole word, so releasing 3.31.0 cannot - # pick up a "v3.31.0-beta.1" section. awk -v ver="$VERSION" ' function heading_version(line, rest) { if (line !~ /^##+[[:space:]]*v?[0-9]+\.[0-9]+/) return "" @@ -143,9 +121,6 @@ jobs: exit 0 fi - # --target pins the tag to the commit that was pushed rather than to - # whatever master points at by the time this runs. The tag does not - # exist yet, so this call creates it. gh release create "v$VERSION" \ --repo "$GITHUB_REPOSITORY" \ --target "$GITHUB_SHA" \ @@ -156,8 +131,5 @@ jobs: name: Publish to NPM needs: [detect, github-release] if: ${{ needs.detect.outputs.release == 'true' && inputs.dry_run != true }} - # Called directly rather than left to the tag push: a tag created with - # GITHUB_TOKEN does not trigger other workflows, so relying on the push - # event would silently skip the NPM publish. uses: ./.github/workflows/release.yml secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e129c7f..452e521 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,6 @@ on: push: tags: - 'v3*' - # Called by release-on-version-bump.yml once it has tagged the merge commit, - # because a tag created by GITHUB_TOKEN does not trigger the push event above. workflow_call: jobs: From df6b3f1ee0a61e78a8397540eb3704d83d0333ba Mon Sep 17 00:00:00 2001 From: cb-alish Date: Wed, 19 Aug 2026 13:32:46 +0530 Subject: [PATCH 5/6] ci: only let master publish a release The push trigger was already master-only, but a manual dispatch was not: run from any branch with the dry-run box unchecked, it would have tagged that branch's commit, published a release and pushed to NPM. A non-master dispatch is now always a dry run, so branches can still be used to check what a release would do without being able to cut one. Co-authored-by: Cursor --- .github/workflows/release-on-version-bump.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-on-version-bump.yml b/.github/workflows/release-on-version-bump.yml index aa92445..4e4c552 100644 --- a/.github/workflows/release-on-version-bump.yml +++ b/.github/workflows/release-on-version-bump.yml @@ -115,7 +115,13 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.detect.outputs.version }} DRY_RUN: ${{ inputs.dry_run == true }} + REF: ${{ github.ref }} run: | + if [ "$REF" != "refs/heads/master" ]; then + echo "Releases are cut from master only, so $REF cannot publish one. Continuing as a dry run." + DRY_RUN=true + fi + if [ "$DRY_RUN" = "true" ]; then echo "Dry run: would tag $GITHUB_SHA as v$VERSION and publish the release notes above. Nothing was created." exit 0 @@ -130,6 +136,6 @@ jobs: publish: name: Publish to NPM needs: [detect, github-release] - if: ${{ needs.detect.outputs.release == 'true' && inputs.dry_run != true }} + if: ${{ needs.detect.outputs.release == 'true' && inputs.dry_run != true && github.ref == 'refs/heads/master' }} uses: ./.github/workflows/release.yml secrets: inherit From 61ab96521c18556ad24bf96e414b5c0076bd3bf2 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Thu, 20 Aug 2026 16:52:31 +0530 Subject: [PATCH 6/6] ci: let a half-finished release be completed on a re-run An existing tag only proved the GitHub release had been created, not that NPM had accepted the package, so a run that tagged and then failed to publish could not be recovered: every re-run saw the tag, decided there was nothing to do and skipped the publish for good. release.yml has no dispatch trigger either, so there was no manual way back short of deleting and re-pushing the tag. The two halves are now tracked separately, each against the state that proves it: the GitHub release is created when no release exists for the version, and NPM is published when the registry does not have the version. Either can run without the other, so a failed publish is picked up by the next push or by a manual dispatch, and a release that lost its notes can be recreated for an existing tag. Nothing to do when both are already in place. Two guards come with it. The workflow is serialised per ref, so a second push cannot release while the first is mid-flight, and the release is refused if the commit is no longer reachable from master, which stops a force-pushed-away commit from being tagged. Co-authored-by: Cursor --- .github/workflows/release-on-version-bump.yml | 80 ++++++++++++++----- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-on-version-bump.yml b/.github/workflows/release-on-version-bump.yml index 4e4c552..7ff71ac 100644 --- a/.github/workflows/release-on-version-bump.yml +++ b/.github/workflows/release-on-version-bump.yml @@ -15,25 +15,32 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: detect: - name: Detect version bump + name: Decide what needs releasing runs-on: ubuntu-latest outputs: - release: ${{ steps.check.outputs.release }} version: ${{ steps.check.outputs.version }} + create_release: ${{ steps.check.outputs.create_release }} + publish: ${{ steps.check.outputs.publish }} steps: - name: Checkout repo uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Compare version against the previous commit + - name: Check the version, the release and npm id: check env: BEFORE: ${{ github.event.before }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | NEW_VERSION=$(jq -r .version package.json) + PACKAGE=$(jq -r .name package.json) PREV="$BEFORE" if [ -z "$PREV" ] || [ "$PREV" = "0000000000000000000000000000000000000000" ] \ @@ -50,31 +57,46 @@ jobs: echo "version: ${OLD_VERSION:-} -> $NEW_VERSION" echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then - echo "No version bump in this push; nothing to release." - echo "release=false" >> "$GITHUB_OUTPUT" - exit 0 + if gh release view "v$NEW_VERSION" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + CREATE_RELEASE=false + else + CREATE_RELEASE=true fi - if [ -n "$(git ls-remote --tags origin "refs/tags/v$NEW_VERSION")" ]; then - echo "v$NEW_VERSION is already tagged; nothing to release." - echo "release=false" >> "$GITHUB_OUTPUT" + if npm view "$PACKAGE@$NEW_VERSION" version >/dev/null 2>&1; then + PUBLISH=false + else + PUBLISH=true + fi + + echo "github release v$NEW_VERSION missing: $CREATE_RELEASE" + echo "$PACKAGE@$NEW_VERSION missing from npm: $PUBLISH" + echo "create_release=$CREATE_RELEASE" >> "$GITHUB_OUTPUT" + echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT" + + if [ "$CREATE_RELEASE" = "false" ] && [ "$PUBLISH" = "false" ]; then + echo "v$NEW_VERSION is already released and published; nothing to do." exit 0 fi - echo "Releasing v$NEW_VERSION." - echo "release=true" >> "$GITHUB_OUTPUT" + if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then + echo "No version bump in this push, but v$NEW_VERSION is only half released; finishing it." + fi + + echo "Releasing v$NEW_VERSION: create github release=$CREATE_RELEASE, publish to npm=$PUBLISH." github-release: name: Tag and publish GitHub release needs: detect - if: ${{ needs.detect.outputs.release == 'true' }} + if: ${{ needs.detect.outputs.create_release == 'true' }} runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout repo uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Extract release notes from CHANGELOG env: @@ -127,15 +149,35 @@ jobs: exit 0 fi - gh release create "v$VERSION" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$GITHUB_SHA" \ - --title "v$VERSION" \ - --notes-file /tmp/release-notes.md + git fetch --quiet origin master + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then + echo "::error::$GITHUB_SHA is no longer reachable from master, so its history was rewritten. Refusing to tag it as v$VERSION." + exit 1 + fi + + if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists; creating the missing release for it." + gh release create "v$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --title "v$VERSION" \ + --notes-file /tmp/release-notes.md + else + gh release create "v$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "v$VERSION" \ + --notes-file /tmp/release-notes.md + fi publish: name: Publish to NPM needs: [detect, github-release] - if: ${{ needs.detect.outputs.release == 'true' && inputs.dry_run != true && github.ref == 'refs/heads/master' }} + if: >- + ${{ !cancelled() + && needs.detect.result == 'success' + && needs.detect.outputs.publish == 'true' + && inputs.dry_run != true + && github.ref == 'refs/heads/master' + && (needs.github-release.result == 'success' || needs.github-release.result == 'skipped') }} uses: ./.github/workflows/release.yml secrets: inherit