Repository navigation
ci(release): publish from the merge commit, without a release commit - #409
Conversation
The plan for making a merge into main, beta or alpha the whole release: no release commit, the conventionalcommits preset with a parser that reads only the commit header, merge-commit promotions, and the rulesets and settings that go with them. Every version it promises was reproduced by running semantic-release against a local copy of the repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A merge into main, beta or alpha is now the whole release. semantic-release no longer commits CHANGELOG.md and package.json back to the branch, so no ruleset has to be switched off around a release; the notes live in the GitHub release. - The conventionalcommits preset replaces angular, which ignored `!`. Its parser reads only the commit header: a squash body is the PR description, and a line in it could otherwise force a major release, print "closes" links, or crash the notes generator. A revert header releases a patch. - A test runs the analyzer and notes generator with the config's own options, so a preset or parser bump that changes their meaning fails CI. - A new pull request workflow lints PR titles into dev and hotfix commits into main, and holds each release branch to the branches it may take pull requests from, beta containing main before it is promoted. - commitlint refuses a BREAKING CHANGE footer without `!` in the header. - The release run links the main -> dev back-merge after every push to main. - check_build.yml goes; Tests already builds on three platforms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After every push to main, the release run merges main into dev with a merge commit and pushes it with a deploy key kept in the back-merge environment; without the key it prints the compare link as before. Promotion source refuses dev -> alpha and dev -> beta until dev contains main, so a back-merge that did not happen blocks the next prerelease instead of mis-numbering it. From the review of the whole PR: the conflict summary keeps its <name>/merge-main placeholder, a refused push fails at once instead of being retried as a lost race, the two checks become required on dev too, the commitlint rule reads the note's title so "Breaking change: none" no longer trips it, that rule gets a test, and the release job's actions are pinned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nothing has shipped from an alpha branch since 1.1.0-alpha.22, and a second prerelease line costs a promotion PR, a ruleset entry and a gate case each time. Left in the config, an alpha branch created by accident would publish on its first push. So release.config.cjs, release.yml, test.yml and pull-requests.yml stop naming it, and the plan records the decision as D9. The release flow replayed without the alpha steps gives the same beta and main versions as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SonarCloud fails the quality gate on a write permission declared at the workflow level (S8233). The block moves into the release job, unchanged, so each job states its own token; the back-merge job already had its read-only one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A hung push would otherwise hold the run for six hours, and the next merge into main would wait behind the release concurrency group. The plan also notes that the 1.x bootstrap PR needs the same job-level permissions as this one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…k-merge PR A feature PR merged with a merge commit lands its unlinted commits on dev, and nothing caught it. dev's ruleset now allows only squash (D10). The release run's back-merge pushes past it with the deploy key, and the one PR that must be a merge commit, a back-merge on a conflict or before the key exists, has an admin widen the ruleset for that merge. The job's two summaries, the plan and the instructions say so. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| it('releases nothing for a promotion merge commit or a documentation change', async () => { | ||
| expect( | ||
| await releaseOf( | ||
| 'Merge pull request #404 from apimatic/dev\n\nRelease', | ||
| 'docs: add the CONTEXT.md glossary (#365)' | ||
| ) | ||
| ).to.be.null; | ||
| }); | ||
| }); |
There was a problem hiding this comment.
A promotion does release, from the squash commits its merge brings in; only the merge commit's own header (Merge pull request #N from apimatic/dev) adds nothing. This test pairs that merge commit with a docs: commit only, so its name reads as "a promotion releases nothing". Showing both sides would say what's meant:
| it('releases nothing for a promotion merge commit or a documentation change', async () => { | |
| expect( | |
| await releaseOf( | |
| 'Merge pull request #404 from apimatic/dev\n\nRelease', | |
| 'docs: add the CONTEXT.md glossary (#365)' | |
| ) | |
| ).to.be.null; | |
| }); | |
| }); | |
| it('releases from the commits a promotion brings, never from its merge commit', async () => { | |
| const merge = 'Merge pull request #404 from apimatic/dev\n\nRelease'; | |
| expect(await releaseOf(merge, 'fix(portal): keep the header on one line (#398)')).to.equal('patch'); | |
| expect(await releaseOf(merge, 'docs: add the CONTEXT.md glossary (#365)')).to.be.null; | |
| }); | |
| }); |
| it('ignores a body line that only mentions breaking changes', () => { | ||
| expect(lint('fix(sdk): drop the legacy flag\n\n* Breaking change: none').valid).to.equal(true); | ||
| }); |
There was a problem hiding this comment.
Not a blocker, but the rule's case sensitivity doesn't match the parser's. The parser finds footer keywords case-insensitively (conventional-commits-parser's regex.js:17) and keeps the commit's own spelling as the note title, and the rule then tests that title case-sensitively. Running commitlint on fix(sdk): … with each body line:
| Refused | Accepted |
|---|---|
BREAKING CHANGE: … |
* Breaking change: none |
* BREAKING CHANGE: … |
breaking change: drops --legacy |
| BREAKING CHANGE | yes | |
Breaking-Change: … |
BREAKING CHANGE drops (no colon) |
BREAKING CHANGES: … (the plural is no keyword at all) |
So a lowercase footer that really is breaking passes lint and ships as a patch. Tolerating * Breaking change: none buys little: PR descriptions aren't linted today (see my comment on pull-requests.yml:50), only local commits and the commits of a hotfix PR into main are. I'd make the rule's regex /^BREAKING[ -]CHANGE$/i (see my comment on commitlint.config.cjs:8) and turn this case into a refusal:
it('refuses a breaking-change footer in any case under a header without !', () => {
expect(lint('fix(sdk): drop the legacy flag\n\nbreaking change: --legacy is removed').valid).to.equal(false);
});There was a problem hiding this comment.
Taken (f8693fb): the rule matches the title in any case, the lowercase footer is a refusal, and the test names the rule it expects. As your table implies, * Breaking change: none is now refused too, since the parser reads it as a note; the rule's message says to drop such a line, and a test pins that.
| rules: { | ||
| // The release reads only a commit's header, so a BREAKING CHANGE footer alone would ship as a minor or a patch. | ||
| "breaking-change-in-header": ({ header, notes }) => [ | ||
| !notes.some((note) => /^BREAKING[ -]CHANGE$/.test(note.title)) || /^\w+(\([^)]*\))?!:/.test(header ?? ""), |
There was a problem hiding this comment.
As I read it, this is valid when there's no BREAKING CHANGE footer or the header has !, i.e. a footer requires a !. The header regex is needed because the parser adds its own BREAKING CHANGE note for a ! header (conventional-commits-parser's CommitParser.js:229-241), so notes alone can't tell the two apart. Naming the two halves would make it read directly:
"breaking-change-in-header": ({ header, notes }) => {
const hasBreakingNote = notes.some((note) => /^BREAKING[ -]CHANGE$/i.test(note.title));
const headerHasBang = /^\w+(\([^)]*\))?!:/.test(header ?? "");
return [
!hasBreakingNote || headerHasBang,
"mark a breaking change with ! in the header, as in feat(sdk)!: …; a BREAKING CHANGE footer alone is ignored"
];
}The i flag is the case-sensitivity point from my comment on test/commitlint-config.test.ts.
There was a problem hiding this comment.
Taken as suggested (f8693fb), with the message extended to cover the Breaking change: none case from the thread above.
| if: github.base_ref == 'dev' | ||
| env: | ||
| TITLE: ${{ github.event.pull_request.title }} | ||
| run: printf '%s\n' "$TITLE" | pnpm exec commitlint --config .github/commitlint-pr-title.cjs --verbose |
There was a problem hiding this comment.
This lints the title only, so a BREAKING CHANGE: footer in the description, which becomes the squash body, is never checked, and the header-only parser drops it. A PR titled feat(sdk): drop v3 generation with BREAKING CHANGE: v3 is gone in its description silently ships as a minor. .ai/instructions.md:137 reads as if commitlint refuses that, but it only does for local commits and hotfix commits.
Either check the description here too:
- name: check the description for a breaking footer
if: github.base_ref == 'dev'
env:
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
run: |
if printf '%s\n' "$BODY" | grep -qE '^BREAKING[ -]CHANGE:' && ! printf '%s\n' "$TITLE" | grep -qE '^\w+(\([^)]*\))?!:'; then
echo "::error::The description has a BREAKING CHANGE footer but the title has no !, and the release reads only the title."
exit 1
fior narrow line 137 of the instructions to say a footer in a PR description is ignored.
There was a problem hiding this comment.
Taken, one level deeper (f8693fb): the step now lints the title plus the description as the squash message they become, through commitlint with .github/commitlint-pull-request.cjs (no title exempt, the body line-length rules off). So breaking-change-in-header is the one definition of a breaking note, and the * and ** forms the parser reads, which the grep would have missed, are refused too. Over the last 60 PRs merged into dev, 59 pass; the one refused is #343, whose description carries a real BREAKING CHANGE: footer under a feat(portal): title. The instructions now say a footer is ignored by the release and refused by the checks.
| changelogFile: "CHANGELOG.md" | ||
| preset: "conventionalcommits", | ||
| parserOpts: headerOnly, | ||
| // A revert squash lacks git's "This reverts commit" line; a matched custom rule skips the defaults, hence breaking first. |
There was a problem hiding this comment.
Order doesn't matter here: analyze-commit.js applies every matching rule and keeps the highest release, so "hence breaking first" points at the wrong invariant. What matters is that the breaking rule is in the list at all, because once a custom rule matches, the default rules (their major included) are skipped.
| // A revert squash lacks git's "This reverts commit" line; a matched custom rule skips the defaults, hence breaking first. | |
| // A revert squash lacks git's "This reverts commit" line; a matched custom rule skips the defaults, so breaking is repeated here. |
There was a problem hiding this comment.
Taken as suggested (f8693fb); the plan's sentence on rule order is corrected too.
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 | ||
| with: | ||
| persist-credentials: false | ||
| fetch-depth: 0 |
There was a problem hiding this comment.
Nit: the title path needs no history; only the hotfix path into main runs commitlint --from/--to.
| fetch-depth: 0 | |
| fetch-depth: ${{ github.base_ref == 'dev' && 1 || 0 }} |
(Written this way round because 0 is falsy in expressions, so github.base_ref == 'main' && 0 || 1 would always give 1.) The full pnpm install on every title or description edit is the bigger cost, for a check that only needs commitlint.
Fixes from mrafnadeem-apimatic's review of #409, and from two review passes over the fixes: - the release run on beta checks that beta contains main before it publishes, with the remedy in its summary, because a PR's Promotion source result goes stale once main gains a tag - a PR into dev is linted as its squash message, title plus description, so a BREAKING CHANGE footer in the description is refused unless the title carries !, and the commitlint rule matches the note title in any case, as the parser does - the release job times out after 30 minutes, and the back-merge runs whatever its result, since a timed-out job counts as cancelled - Promotion source and the guard treat only exit 1 as not an ancestor - the release-rule comment, the promotion test, the superseded apimatic-config sentence, the instructions cut to what every session needs, fetch-depth 1 on the title path, and the version placeholder tracked as #420 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
|
🎉 This PR is included in version 2.0.0-beta.3 🎉 The release is available on: Your semantic-release bot 📦🚀 |



Summary
A merge into
mainorbetabecomes the whole release. semantic-release no longer commitsCHANGELOG.mdandpackage.jsonback to the release branch. So no ruleset has to be switched off around a release, the branches stop drifting apart, and the release notes live in GitHub Releases. After a stable release or a hotfix, the release run mergesmainback intodevby itself. Thealphabranch is gone from the configuration: nothing has shipped on it since 1.1.0-alpha.22.The plan is
.ai/plans/release-pipeline.md(the first commit). This PR is its step 7.2; the rest are admin settings and release-day steps, listed at the end.How releasing works after this PR
flowchart LR F["feature / fix branch"] -- squash --> D[dev] D -- merge commit --> B[beta] B -- merge commit --> M[main] H["hotfix branch<br/>cut from main"] -- merge commit --> M M -. "back-merge by the release run<br/>(merge commit)" .-> D B --> NB[("npm @beta")] M --> NL[("npm @latest")]devmainbetadevonlyx.y.z-beta.Ntobeta, and a GitHub prereleasemainbeta; hotfix branches cut frommainx.y.ztolatest, and the GitHub release1.x(only if a 1.x patch is ever needed)1.x1.x.ytorelease-1.xA release branch never takes a squash or a rebase. semantic-release reads every commit on it to choose the version and write the notes, so a squashed promotion either releases nothing or lists a change twice. The only squashes happen on the way into
dev: one PR is one commit, and one line in the notes. Anddevtakes nothing but squashes: a feature PR merged with a merge commit would land commits nobody linted, and the next promotion would read every one of them.Day to day
Feature or fix: branch from
dev, open a PR, and Squash and merge. The PR title becomes the commit header, which decides the release and its line in the notes:fix(portal): …releases a patch andfeat(sdk): …a minor;feat(sdk)!: …is a breaking change, a major;revert(scope): …is a revert, a patch;docs:,chore:,refactor:,test:,ci:andbuild:release nothing.The description stays in the commit's history. Nothing in it reaches the notes or changes the version, and a
BREAKING CHANGE:footer in it is refused unless the title carries!.Beta: open a PR
dev → betaand Create a merge commit. The run publishes the prerelease, with notes covering what changed since the previous one.Stable: open a PR
beta → mainand Create a merge commit. The run publishes tolatest, with notes covering everything since the previous stable release, and then mergesmainback intodevwith a merge commit. The release tag sits on main's merge commit, and a beta cut from adevthat cannot reach it would get the wrong version, sodev → betais refused untildevcontainsmain.Hotfix:
main, make one conventional commit, and open a PR intomain.latest. The run back-merges it intodev.dev → betabefore the nextbeta → main.A back-merge the run could not make (its
back-mergejob failed on a conflict): GitHub's "Resolve conflicts" button would commit tomain, which is blocked. Until it is done,dev → betastays refused. Instead:<name>/merge-mainfromdev.origin/maininto it, resolve the conflicts, and open the PR intodev.dev.A 1.x patch: plan 7.6. The branch is cut from
v1.5.0and needs a one-time bootstrap PR, because 1.5.0's own workflows don't know1.x.What the checks enforce
Rulesets (after merge):
mainandbeta; squash only intodev, so the one kind of PR that must be a merge commit, a back-merge, needs an admin to widen the ruleset for it;Commit messagesandPromotion sourcemust pass on every protected branch, and the Tests matrix on the release branches;dev.Promotion source:betatakes PRs only fromdev, and only oncedevcontainsmain;maintakesbeta, and only oncebetacontainsmain;mainalso takes a hotfix branch, as long as it carries no commits fromdev;1.xnever goes intodev, and no fork targets a release branch.A PR's result is for its head, and a tag
maingains afterwards does not re-run it, so the release run onbetachecks again thatbetacontainsmainbefore it publishes. In both places only exit 1 of git's ancestry test means "not an ancestor"; a git error fails the check with git's own message instead of a wrong remedy.Commit messages:devis linted as the squash commit it becomes, title plus description: the title must be conventional, with no exemptions, so GitHub'sRevert "…"orMerge …fail;!in its header. ABREAKING CHANGE:footer alone, in a commit body or a PR description, is ignored by the release, so the check refuses one under a header without!.What each merge will publish
These come from replaying the flow against a local copy of the repository with this PR's config:
fixare ondev2.0.0-beta.32.0.0onlatestfeatgoes dev → beta2.1.0-beta.12.0.0-beta.4;Promotion sourcerefuses this promotion, and the release run would too2.0.1; beta then continues at2.1.0-beta.21.x, then afix1.5.1onrelease-1.xTwo things still need a person:
dev, resolved in a PR that an admin lets through as a merge commit; the next promotion is refused until then, so it cannot be missed;Changes
release.config.cjs@semantic-release/gitand@semantic-release/changelogare gone, and so is thealphabranch.conventionalcommitspreset replacesangular, which ignored!: afeat!:alone released nothing.noteKeywords,issuePrefixesandfieldPatternare allnull. A squash body is the PR description, where a line like* Breaking change: noneforced a major, every#Nprinted as "closes", and-notes-crashed the notes generator.revert(scope):header releases a patch. The breaking rule is repeated beside it because a matched custom rule skips the defaults; every matching rule applies and the highest wins.test/release-config.test.ts: runs the analyzer and notes generator with the config's own options, including a promotion that releases from the squash commits it brings and never from its merge commit. With the options emptied, it fails..github/workflows/pull-requests.yml:Commit messagesandPromotion source, as above. A PR intodevis linted as its squash message, title plus description, with.github/commitlint-pull-request.cjs: it exempts no title and turns off the body line-length rules, so the one commitlint rule below decides what a breaking note is. The title path checks out one commit; only the hotfix lint needs history.commitlint.config.cjs: the rule refusing aBREAKING CHANGE:footer without!. It matches the note's title in any case, as the parser finds the keyword, so a lowercase footer is refused, and so is a line such as* Breaking change: none, which the parser reads as a note; the message says to drop it.test/commitlint-config.test.tsruns the CLI over the footer, the!header, a lowercase footer, that line, a description's bold footer under a title with and without!, a long description, and GitHub's own titles.release.yml:alphaleaves the triggers; a concurrency group per branch, pinned actions, the token declared on the job, a 30-minute timeout on the release job, and a guard onbetathat it containsmainbefore anything is installed, with the remedy in the run summary. Theback-mergejob runs after every push tomainwhatever the release job's result, since a failed, timed-out or cancelled run may already have pushed the tag. With the deploy key it mergesmainintodevwith a merge commit and pushes; a push that lost a race is retried, and a conflict or a refused push fails at once with the steps in the run summary, the ruleset widening included. Without the key it prints the compare link, so this PR can merge before the key exists. It times out after ten minutes, so a hung push cannot hold the next release behind the concurrency group.test.yml:alphaleaves its pull request triggers.check_build.yml: deleted; Tests already builds on three platforms..ai/instructions.md: the default branch isdev; a three-sentence Releases section points at the plan's section 8 for promotions, hotfixes and back-merges; one paragraph gives the PR-title conventions. The procedure itself stays in the plan, since every session reads this file in full.Verification
pull-requests.yml, read from the YAML, ran against 20 cases, including the ancestry checks against the realorigin/main,origin/devandorigin/beta, and adevthat lacksmain. The PR lint step ran the same way over a description with a breaking footer under a title with and without!, a long description, CRLF bodies, and a GitHub revert title; over the last 60 PRs merged intodev, 59 pass, and the one refused is feat(portal): build the documentation portal locally with Fumadocs #343, whose description carries a realBREAKING CHANGE:footer under afeat(portal):title. Theback-mergejob's script ran against a throwaway repository: a clean back-merge, an already-mergeddev, a conflict, and a refused push, plus the rejection text git prints for a lost race. Thebetaguard ran against a checkout that containsmain, one that does not, and a repository with noorigin/main.After merge (admin, per the plan)
dev: squash only, 1 approval, required checksCommit messagesandPromotion source; bypass: deploy keys, always. A back-merge PR intodev(on a conflict, or before the key exists) is the one time an admin adds merge commit to this ruleset, for that merge only. A PR opened before this merge reports the checks after its next event; editing its title once is enough.release branches(main,beta): merge commit only, 1 approval, and the six Tests jobs,Commit messagesandPromotion sourceas required checks. "Up to date" off; admin bypass for pull requests only.release tags(v*): restrict updates and deletions.no-direct-commitslast.no-direct-commitsis gone: generate an ed25519 key pair; add the public half as a deploy key with write access; create theback-mergeenvironment, limit its deployment branches tomain, and add the private half as its secretBACK_MERGE_DEPLOY_KEY; delete both files. Until then the release run prints the back-merge link instead of pushing, and that PR needs the same widening of dev's ruleset.@2schema PR, then dev → beta for2.0.0-beta.3, then beta → main for2.0.0. Afterwards chore: retire CHANGELOG.md and the committed version after 2.0.0 #420 retiresCHANGELOG.mdand the committed version.Squash this PR into
devas usual: itsciheader releases nothing by itself.🤖 Generated with Claude Code