ci(release): 1.0.0 cut mechanics + fixes from the dry-run rehearsal - #2759
Conversation
Coverage Report for ./packages/components/
File CoverageNo changed files found. |
There was a problem hiding this comment.
Pull request overview
Adjusts the publish.yml GitHub Actions workflow to support the one-time 1.0.0 cut and the post-cut stable publishing behavior on main, including a rehearsal mode for validating the release plan without mutating npm or the repository.
Changes:
- Adds
workflow_dispatchinputsrelease_as(force an explicit version) anddry_run(runlerna publish --dry-runand skip pushing/tags/release). - Removes
--conventional-prerelease="*"from the normalmainpublishing path somaingraduates to stableX.Y.Zreleases. - Gates the “push commit/tag + GitHub release” step to not run during
dry_run.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
🚀 Preview DeploymentPreview environments are ready:
Images:
|
…wald#2769) Three defects found by the mittwald#2769 rehearsal, all in publish.yml. Staged here on the rehearsal branch for verification; fold into mittwald#2759 once confirmed. - The release_as branch omitted --conventional-commits, which is also what writes CHANGELOG.md. lerna bumped the versions but wrote no changelog entry, so the extraction below silently picked up the PREVIOUS version's section. Verified in the fork: CHANGELOG.md at tag 1.0.0 still started with `# [0.2.0-alpha.999]`, no 1.0.0 section anywhere. - The extraction matched `^# \[`, i.e. H1 headers only. conventional-changelog varies the header level with the bump type, so a patch release renders as `## [1.0.1]` and was skipped — the release then carried the previous version's notes. Reproduced against a fixture; `^#+ \[` fixes it. - A body over 125000 characters makes `gh release create` fail with a 422, after the publish succeeded and the tag was pushed. Truncate instead: this step must not be able to hard-fail once npm has the packages. Whether lerna-lite keeps generating changelogs when an explicit version is combined with --conventional-commits is not documented — the next fork dispatch verifies it.
…0 cut Drop --conventional-prerelease="*" on the main path so pushes to main graduate to stable X.Y.Z (dist-tag latest) instead of 0.x-alpha.N. Add two workflow_dispatch inputs: - release_as: force an explicit version, bypassing conventional derivation, for the one-time 1.0.0 cut (RFC #2711). - dry_run: rehearse build + version + 'lerna publish --dry-run' without pushing, tagging, or publishing to npm. The next path (publish-next.yml), the chore(release): skip-guard, the mutate-main concurrency group, and the provenance/Rekor-409 handling are untouched. Refs #2738. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three defects the dry-run rehearsal found in the release-notes path, all of which would first have shown up at the 1.0.0 cut itself. - The `release_as` branch omitted `--conventional-commits`, which is also the flag that writes CHANGELOG.md. lerna bumped the versions and wrote no changelog entry, so the extraction below silently picked up the PREVIOUS version's section — and succeeded, because that section is small. The 1.0.0 Release would have carried the last alpha's notes. Verified both ways on the fork: without the flag, CHANGELOG.md at tag 1.0.0 had no 1.0.0 section at all; with it, tag 2.0.0 has one. - The extraction matched `^# \[`, i.e. H1 only. conventional-changelog varies the header level with the bump type, so a patch release renders as `## [1.0.1]` and was skipped, and the notes came from the release before it. Observed live on the fork; `^#+ \[` fixes it and was confirmed against both a major (H1) and three patch releases (H2). - A body over 125000 characters makes `gh release create` fail with a 422 — after the publish succeeded and the tag was pushed, so it leaves packages published, git advanced and no Release. Truncate instead: this step must not be able to hard-fail once npm has the packages.
ADR 0004 §6 asks for `X.Y.0-next.N` — a MINOR prerelease line, because `next`
is where features collect. `--conventional-prerelease` alone does not produce
it: once the version is a prerelease, semver's `inc('prerelease')` only moves
the counter, whatever the commit type. The base is frozen by the first
prerelease publish on the line, and that one is a patch by construction —
`next` is branched off a stable `main` and the first thing to arrive is a
forward-merged `fix:`.
Measured in the rehearsal: the line produced `1.0.3-next.0` from two `fix:`
commits and then `1.0.3-next.1` — the latter containing a feature, with the
minor never moving. `flow@next` would advertise a patch while shipping
features, against ADR 0005.
Split by the current version: while it is stable, seed the line with
`preminor --preid next`; once it is a prerelease, let conventional-prerelease
count as before. Automatic on purpose — after every promotion `next` lands on
a stable version again and would otherwise restart as a patch line, so a
one-time seed at the cut would not hold. Touches the promotion mechanics in
#2724.
Rehearsing the cascade surfaced two problems around its conflict path and one around its concurrency, plus the tooling gap underneath all of them. `lerna.json` carries the same top-level `version` field as the package manifests but had no merge driver, so it merged as a plain 3-way merge and conflicted whenever both lines had bumped since their last common ancestor — the steady state once a `next` prerelease and a `main` release fall between two forward-merges. Pure version churn then escalated, which is exactly what the drivers exist to prevent. GitHub does not run merge drivers — they live in local git config, which is not versioned. A conflict resolved on the PR therefore shows the full version/CHANGELOG churn as conflicts (35 files in the rehearsal, 34 of them noise). So the resolution belongs in a developer checkout, and now takes one command each way: `pnpm sync:resolve` reproduces the merge with the drivers active, `--continue` verifies no markers are left, commits, pushes and opens the PR. The cascade escalates to an ISSUE rather than a PR — a pull request is a change proposal, and until someone has resolved the conflict there is nothing to propose. `dev:init-merge-drivers` registers the drivers for a developer checkout; `prepare` runs it on every install. The shared `mutate-next` concurrency group does not serialize, contrary to ADR 0004 §5: GitHub keeps one pending run per group and evicts it when a newer run arrives, so forward-merge runs are dropped rather than delayed, and a cancelled run is not a failure so §10 never fires. A `workflow_run` trigger now re-runs the cascade when publish-next completes — precisely when the group frees up — and `forward-merge-drift.yml` is the net underneath, which also ages an open escalation so a forgotten one cannot block the cascade silently. Because the catch-up fires after every `next` publish, the guard step gained a one-API-call pre-flight so the common no-op never clones the repository. That pre-flight is also where the escalation issue gets closed: `next` containing `main` again is the definition of a resolved escalation, and `Closes #n` in the sync PR cannot do it — GitHub only auto-closes linked issues when a PR merges into the default branch, and the sync PR targets `next`.
Both sections describe mechanisms that do not behave as written. §5 claimed a shared concurrency group serializes every writer of a branch. It does not: GitHub keeps at most one pending run per group and evicts it when a newer run arrives, so excess runs are dropped rather than delayed — measured, with timestamps. The section now says what actually happens, documents the catch-up trigger and the drift check, and records why giving forward-merge its own group was rejected. §4 described a sync PR that a human resolves. That cannot work as intended, because GitHub does not run the `.gitattributes` merge drivers, so the churn §3 removes reappears on the PR. The section now describes the escalation issue and `pnpm sync:resolve`, and notes why the issue cannot be closed by a `Closes #n` in the sync PR. Status line and header carry an amendment note; the reasoning and measurements live on #2769.
…2769) The rehearsal's steady state disproved an assumption two of these workflows were built on. ADR 0004 §6/§7 has the cascade DROP a merge that produces no code delta — the `chore(release):` bump is absorbed entirely by the merge drivers — so `next` never receives `main`'s tip commit. After every release `main` therefore sits permanently one commit ahead of `next` while being content-identical. Observed live: "Merge succeeded. No code delta after merge — skipping push", with `compare/next...main` reporting `ahead_by=1` from then on. Two things were wrong as a result: - The drift check tested ancestry (`merge-base --is-ancestor`), which is violated in exactly that steady state — it would have escalated an issue every hour about drift that does not exist. It now probes the merge the way the cascade does, with the drivers registered, and asks whether the tree changes. - Closing the escalation issue hung off the `ahead_by == 0` pre-flight, which is therefore almost never true. It moves to the three exits where the cascade actually finishes healthy: pushed, pushed-after-retry, or no code delta. The workflow header claimed `main` stays an ancestor of `next`; corrected, with a note not to write ancestry-based checks against this cascade. ADR 0004 §1 makes the same claim and needs the same correction.
§1 claimed the forward-merge makes `main` an ancestor of `next`, which §6/§7 then contradicts: a merge carrying no code delta is dropped, so the `chore(release):` bump never reaches `next` and `main` sits permanently one commit ahead. Anything asking "has `next` fallen behind?" has to probe the merge rather than the ancestry — an `is-ancestor` test reports drift forever, as the rehearsal's drift check did.
The seed is needed in two situations, and the first version of this fix only covered one. a) The line is created — `next` branched off a stable `main`. b) The line was promoted — `next` merged into `main`, `main` graduated to that version, and `next` kept its own. Per ADR 0004 §7 the back-merge is churn-only and the merge driver keeps `next`'s version, so the prerelease base SURVIVES every promotion. `next` at `2.1.0-next.5` with `main` graduated to `2.1.0` keeps producing `2.1.0-next.6` — a prerelease of an already-released version, which semver sorts BELOW `2.1.0`. `flow@next` would advertise a lower version than `flow@latest`, the exact opposite of what §6 promises. Both collapse into one rule: if the base version of `next` is at or below what `main` has already released, open a fresh minor prerelease line derived from MAIN's version; otherwise count up. Deriving from `main` matters in case (b) — several releases may have landed since, and a `preminor` off `next`'s own stale base could still land below them. Decision table, verified: (2.0.2, 2.0.2) → 2.1.0-next.0 · (2.1.0-next.5, 2.0.3) → count up · (2.1.0-next.5, 2.1.0) → 2.2.0-next.0 · (2.1.0-next.5, 2.3.0) → 2.4.0-next.0 · (2.2.0-next.0, 2.1.4) → count up. Self-correcting by construction: a line that ended up on a wrong base — the rehearsal fork sat on `1.0.3-next.9` against `main` at `2.0.2` — is pulled back to `2.1.0-next.0` on its next run, without re-cutting the branch.
The guard that gates the whole command asked whether `next` contains every commit from `main`. It does not, and never will: the forward-merge deliberately drops a merge carrying no code delta (ADR 0004 §6/§7), so after every release `main` keeps a `chore(release):` commit that stays behind. Run against the rehearsal fork in exactly that state, the command refused with "⛔ Hard-stop: main is ahead of next — forward-merge incomplete" while the cascade was working as designed. The diagnosis in that message is the bug: the forward-merge was complete. As written, the promotion can never be started while everything is healthy — and the promotion is the model's core release action. The guard now probes the merge with the drivers registered and asks whether the tree changes, which is the question it meant to ask all along. Same correction as the drift check and ADR 0004 §1.
…entional (#2769) The first real promotion PR, produced by `/prepare-release` in the rehearsal fork, failed the `Conventional PR title` check: the command opens it as `Release x.y.0`, which carries no Conventional Commit type. Routing passed — but only because that title happens to carry no `feat` marker. `release/*` is what `/prepare-release` actually creates, and it was missing from the promotion exemption list, so the pass was luck rather than intent. The title becomes `chore(promotion): promote <from> to x.y.0`. Deliberately not `chore(release):` — that prefix is the skip-guard `publish.yml` uses to avoid re-publishing its own release commit, so a repo configured to use PR titles as merge-commit subjects would silently publish nothing. `release/*` joins `sync/*` in both exemption blocks, which also matches how #2724 plans to detect a promotion merge (head branch `release/*`).
#2724) The first real promotion PR came out `CONFLICTING` across 35 files — 34 of them mechanical. GitHub does not run the `.gitattributes` merge drivers, so a merge computed on its side resurfaces every version and changelog divergence between the lines, in this direction exactly as in `main → next`. The command is the right place to fix it: it runs locally, it registers the drivers, and after the guard fix it already performs this very merge as a probe before discarding the result. Now it keeps it — `release/x.y.0` is cut from `<from>` and `<to>` is merged into it before the push. It cannot change content: the guard already established that merging `<to>` into `<from>` produces no code delta, so this only moves ancestry, which is what the promotion is meant to establish anyway. Verified against a scratch repo reproducing the real shape — `main` carrying a churn-only release bump, `next` on its own prerelease line with a feature: Merge made by the 'ort' strategy → no conflict lerna.json: 2.3.0-next.1 → next's version kept Button.tsx: code feature → the feature kept main is an ancestor of release/2.3.0: yes PR release/2.3.0 -> main: mergeable, conflict-free This is Path A of the sketch on #2724.
…urce (#2769) `**/CHANGELOG.md merge=ours` (ADR 0004 §3) is written for `main → next`, where keeping the source branch's file is right. Applied to the promotion it is wrong: `next`'s changelog carries the prerelease line interleaved with the stable entries it forward-merged, and promoting it overwrites the stable history on `main`. The rehearsal produced exactly that. `main` came out of a promotion carrying # [2.3.0-next.6](compare/2.3.0-next.5...2.3.0-next.6) ## [2.2.5](compare/2.3.0-next.4...2.2.5) ← "Version bump only" # [2.3.0-next.5](compare/2.3.0-next.4...2.3.0-next.5) ## [2.2.4](compare/2.3.0-next.3...2.2.4) ← empty — two version lines interleaved, stable releases comparing against prereleases of the other line, and their bodies hollowed out because the content had been attributed to the prerelease entries generated on `next`. `publish.yml` builds GitHub Release bodies from this file, so the damage reaches users: the next stable release would extract an empty prerelease section as its notes. The promotion now restores the target's changelogs after the merge. Verified against a scratch repo reproducing the shape: without the fix CHANGELOG.md = next's (2.2.5 gone) with the fix CHANGELOG.md = main's (2.2.5, 2.2.0 intact) lerna.json 2.3.0-next.5, Button.tsx carries the feature, main still an ancestor of release/2.3.0 Code and version from `next`, changelog from `main`. lerna prepends the graduated entry on merge and the prerelease entries drop out of the record, which is correct — they were never published under `latest`. ADR 0004 §3 gains the caveat.
A promotion merge carries `next`'s prerelease version onto `main` — the release branch already contains `main`, which is what makes the PR mergeable at all. Left to derive the next version, lerna sees a prerelease as the current one and continues that line: the rehearsal put `2.3.0-next.6` under dist-tag `latest`, across eight workflow runs that were all green. `publish.yml` now identifies the merge from the PR's head branch and, for `release/*`, versions explicitly to the version that branch names. Both explicit paths — the one-time cut dispatch and every promotion — go through the same `lerna version <target>` call that #2769 already verified. One trap worth the comment it now carries: the association endpoint is REST, so the field is `head.ref`. `headRefName` is the GraphQL name that `gh pr view --json` uses, and reading it off a REST response yields an empty string — the graduation would silently never fire. Verified against the rehearsal's own promotion merge (`head.ref = release/2.3.0`), and against a `chore(release):` commit, which has no PR association at all and is therefore correctly not treated as a promotion. The sketch on #2724 has the same confusion and needs the same correction. This is the graduation half of #2724. The curated-notes half — building the GitHub Release body from the PR's marker block instead of the changelog — remains open there.
…om marker block Two coupled rehearsal findings from the 2.4.0 dry run (RFC #2711 / #2724): 1. The promotion PR carried the prerelease version onto the stable line: the release/x.y.0 branch inherited next's x.y.0-next.N in lerna.json and every package.json, so the PR diff read `2.3.0 -> 2.4.0-next.3` and the published version hinged entirely on publish.yml re-deriving it via the ctx step. Now the version is graduated IN the PR (prepare-release runs `lerna version x.y.0`), so the diff reads honestly `2.3.0 -> 2.4.0`. publish.yml detects the already-graduated branch and skips its own `lerna version` — re-running it is not idempotent (dies on `tag already exists`, or writes a second empty "Version bump only" entry). Verified locally against the 2.4.0 rehearsal. 2. The GitHub-release body was still built from CHANGELOG.md, but a prerelease->stable graduation only ever yields a "Version bump only" changelog entry. publish.yml now reads the curated notes from the PR body's `release-notes` marker block (trimming blank lines prettier pads adjacent to the markers), falling back to the changelog section only when there is no PR or no marker block (the one-time cut, or a plain dispatch). Release step also derives the tag from lerna.json instead of `git describe` (which would resolve to the inherited prerelease tag) and creates it at the merge commit when absent. prepare-release.md updated to match the new model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Porting the re-seed rule from the rehearsal fork copied the whole file, and with it two changes that must never leave the fork: the `setup-node` redirect to `vars.REHEARSAL_REGISTRY` with the `@mittwald` scope, and `NODE_AUTH_TOKEN` on the publish step. On `mittwald/flow` that variable does not exist, so `next` publishes would have gone somewhere other than npmjs. The same copy also reverted `--no-private`, which main had dropped in the meantime. This is finding 14 in the other direction: the rehearsal branch and the upstream branch must not exchange whole files, only the intended hunks. Caught by grepping the branch for rehearsal markers before opening the PR — which is now the thing to do every time, not a lucky habit.
0c0b51c to
d42f67a
Compare
Formatting only. The rehearsal commits were made with `--no-verify` because this worktree had no `node_modules`, so the pre-commit hook — and with it `format:check` — never ran. CI caught it.
…2724) The 1.0.0 cut is a forced bump over an empty commit range, so conventional-changelog has nothing to describe and the Release body comes out as "Version bump only for package @mittwald/flow-project" — measured on the fork at 2.0.0. #2769 finding 9 decided to fix that by hand, because building machinery for a single attended operation was not worth it. Finding 15 changed the arithmetic. The cut is now dispatched on its own branch rather than merged (merging would itself publish), so the branch's PR is still open while the workflow runs — and the machinery already exists: a promotion reads its curated notes from the PR's marker block. The cut can use the same path, so this is reuse rather than new mechanism. `Identify merge context` now also runs for a `release_as` dispatch and resolves the open PR for the dispatched branch. Everything downstream is unchanged: the release step already prefers a marker block and falls back to the changelog section when there is none. Verified the lookup against the real PR.
#2769) The standing path decided its line by a flag that had to be removed by hand, atomically with the 1.0.0 bump: too early and the next `fix:` publishes a premature stable `0.2.0`, too late and it publishes `1.0.1-alpha.0` under `latest`. Finding 15 then showed the mechanism meant to make that atomic — "the merge is a no-op because the title is `ci:`" — does not hold, since `--force-publish` releases on every commit type. Whether this is a prerelease line is a property of the current version, so read it there. While `main` sits on `0.2.0-alpha.N` the prerelease flag applies; the moment the cut sets a stable `1.0.0`, the same condition graduates every later push. The switch happens inside the cut itself, leaving no window at all, and `publish-next.yml` already decides its own line the same way. Consequence: there is nothing left in this branch that must wait for the cut. It can be merged like any other change — its own merge publishes an ordinary `0.2.0-alpha.N`, exactly as every merge to `main` does today.
Give the repo a durable, issue-independent rough overview of the two-line release model (the map): main = Stable/latest, next = Collection/next, plus an on-demand major line, with the mechanics (conventional PR titles, forward-merge cascade, promotion, the 1.0.0 cut) and the public contract. The detailed cut runbook stays out-of-git; the doc points at ADR 0004, ADR 0005, and RFC #2711. Trim CONTRIBUTE.md's Releases section to a contributor summary that links to the new doc (bidirectional). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (4)
docs/adr/0004-forward-merge-main-into-next.md:127
- This bullet has several missing spaces/backticks (e.g.
versionfield fromnextwhile …), which makes it hard to read and renders the inline code spans incorrectly.
- **`**/package.json`** → a **JSON-aware merge driver** (a small Node script) that, on conflict, keeps the `version`field from`next`while merging all other changes (e.g. genuine dependency bumps from`main`) with the normal 3-way result. A blanket `merge=ours`on`package.json`is rejected — it would silently drop legitimate dependency changes from`main`.
.github/scripts/sync-resolve.cjs:111
- The “already up to date” guard uses an ancestry check (
merge-base --is-ancestor origin/main origin/next), but the release model explicitly allowsmainto be ahead ofnextby churn-only release commits while still being content-identical. That makes this guard almost always false in the steady state and can causepnpm sync:resolveto open unnecessary sync PRs when there is no code delta. Probe the merge result (content) instead of ancestry, with merge drivers enabled.
if (
git(["merge-base", "--is-ancestor", "origin/main", "origin/next"])
.status === 0
) {
console.log(
docs/adr/0004-forward-merge-main-into-next.md:112
- Markdown formatting is currently broken in this bullet (missing backticks/spaces), which makes the glob pattern and
lernareferences render incorrectly.
This issue also appears on line 127 of the same file.
- **`**/CHANGELOG.md
merge=ours`** — keep `next`'s changelog; `lerna`regenerates`next`'s entries from the merged commits anyway. Per-package `CHANGELOG.md`
are only the machine record (RFC #2711), so this loss is inconsequential.
.github/workflows/forward-merge-drift.yml:121
MAX_DRIFT_MINUTEScomes from a workflow_dispatch input (string) and is used in a numeric-ltcomparison. If someone dispatches with a non-integer value, this fails with a shell “integer expression expected” error. Adding a small validation produces a clearer failure mode.
# A forward-merge run for a fresh push may simply still be queued. Only
# drift that OUTLIVES the normal cascade means something was dropped.
if [ "$age_min" -lt "$MAX_DRIFT_MINUTES" ]; then
echo "Below the ${MAX_DRIFT_MINUTES} minute threshold — a forward-merge run is probably still pending. Not escalating."
exit 0
Result of an additional "happy-path"-rehearsal with this changes applieLegend✅ done & verified · ⏳ in progress · ⏸️ waiting on a decision · ❌ failed Phase 0 — Isolate the environmentPrior rehearsal infrastructure survived (teardown was never run), reused as-is.
Wiring commitStarting point: fork Built on branch
Arming
Phase 2 — main: version bump + stable publishA first cut (run Re-dispatched on the scoped wiring:
Phase 3 — Create
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (4)
.github/scripts/sync-resolve.cjs:130
- When the merge succeeds cleanly, the script immediately instructs to push/open a PR. If the merge produced no tree delta (e.g.
nextalready hadmain, or only churn was absorbed by drivers), this would create a pointless sync PR. It should detect the no-delta case and stop/reset instead.
if (merge.status === 0) {
console.log(
"\n✔ The merge resolved cleanly — the drivers absorbed everything.\n" +
" Run `pnpm sync:resolve --continue` to push it and open the PR.",
);
.github/workflows/publish.yml:102
- The comment says the curated-notes half of #2724 is still open, but this workflow now extracts curated notes from the PR marker block later in the job. This is misleading for future maintainers reading the workflow.
# This is the graduation half of #2724; the curated-notes half (building
# the GitHub Release body from the PR's marker block instead of the
# changelog) is still open there.
.github/workflows/forward-merge.yml:98
- When
ahead_byis 0 the workflow exits early and never callsclose_sync_issue. If a sync issue was opened for a previously-blocked merge and later resolved via the sync PR, a subsequent run can hit this early-exit and leave the sync issue open indefinitely even though the cascade is healthy again.
if [ "$ahead" = "0" ]; then
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "::notice::'next' already contains every commit from 'main' — nothing to merge."
exit 0
fi
.github/scripts/sync-resolve.cjs:104
- This early-exit uses an ancestry check (
merge-base --is-ancestor) to decide whethernext“contains”main. In the steady state described by ADR 0004,maincan be ahead by churn-only commits while being content-identical, so ancestry is not a reliable “nothing to resolve” signal here.
This issue also appears on line 126 of the same file.
if (
git(["merge-base", "--is-ancestor", "origin/main", "origin/next"])
.status === 0
) {
console.log(
What this is
The last release-infrastructure item before the 1.0.0 cut (#2738), plus
everything the dry-run rehearsal (#2769) found while exercising it.
The rehearsal ran the whole model on a fork against a throwaway registry:
the cut, the
nextline, the forward-merge cascade, the routing guards and —for the first time anywhere — a promotion. It produced 15 findings, 13 of
them fixed here; one is a decision (#2769 finding 9) and one is the warning at
the top of this description. Every claim below was measured, not reasoned about; the
evidence lives on #2769.
The changes
The cut —
publish.yml--conventional-prerelease="*"on themainpath → pushes tomaingraduate to stable
X.Y.Zunder dist-taglatest.workflow_dispatchinputsrelease_as(force an explicit version for theone-time 1.0.0 cut) and
dry_run(build + version +lerna publish --dry-run, nothing pushed).--conventional-commitson therelease_aspath. It is also what writesCHANGELOG.md; without it the 1.0.0 Release would have silently carried theprevious version's notes.
^#+ \[).conventional-changelog varies the level with the bump type, so a patch release
renders as
## [1.0.1]and was skipped entirely — observed live.gh release createafter the publish succeeded and the tag was pushed,leaving packages published, git advanced and no Release.
The promotion — closes both halves of #2724
publish.ymlidentifies a promotion merge from the merged PR's head branchand versions accordingly, and builds the GitHub Release body from the
curated marker block in that PR, falling back to the changelog section.
lerna.json, notgit describe --tags: a pre-graduatedpromotion creates no new tag, so
git describewould resolve to theprerelease tag the release branch inherited and release the wrong ref.
/prepare-releasemerges the target into the release branch, so the PR ismergeable at all — GitHub does not run
.gitattributesmerge drivers, andwithout this the first promotion PR came out conflicting across 35 files.
merge=oursiswritten for
main → next; applied to the promotion it overwritesmain'sstable history with the interleaved prerelease one.
The cascade —
forward-merge.yml,forward-merge-drift.ymlpnpm sync:resolve/--continueresolves it in two commands, locally, where the drivers actuallyrun. A PR is a change proposal; until someone has resolved the conflict there
is nothing to propose, and the PR that appears afterwards is a clean merge.
lerna.jsongains a merge driver. It carries the sameversionfield as themanifests and conflicted on pure version churn without one.
workflow_runcatch-up re-runs the cascade whenpublish-nextcompletes,and
forward-merge-drift.ymlis the net underneath.dev:init-merge-drivers(run byprepare) registers the drivers locally.The
nextline —publish-next.ymlOpens a fresh minor prerelease line whenever
next's base is at or below whatmainhas released, deriving the target from main's version. Without it theline freezes on the base its first publish happened to pick — a patch line by
construction — and after a promotion it publishes below
latest.Guards —
commit-guard.ymlrelease/*joinssync/*in the promotion exemptions, and the promotion PRtitle becomes a Conventional Commit (
chore(promotion): …, deliberately notchore(release):, which ispublish.yml's own skip-guard).Docs
ADR 0004 §1, §3, §4 and §5 corrected — three of them stated mechanisms that do
not behave as written.
CONTRIBUTE.mddocuments the two-command conflict path.What the rehearsal actually proved
Three things were wrong in the same way: they asked about ancestry where the
answer is content. ADR 0004 §1 claims
mainbecomes an ancestor ofnext;§6/§7 then drops the churn-only merge, so it never does. The drift check would
have opened a phantom issue every hour, and
/prepare-releaserefused to startat all — "main is ahead of next — forward-merge incomplete", in a perfectly
healthy repository.
The promotion was broken in five independent places and none of them would
have surfaced before the first real promotion — weeks after the cut, with
nextalready in use.
And a green cascade is not a correct cascade: the first promotion completed
across eight workflow runs, all green, and put a prerelease under
latest.Cut sequence (execute at cut time — merging this PR does none of it)
release_as=1.0.0+dry_run=true; confirm itcomputes
1.0.0and the publish plan is correctmain; pre-check thatgit logsince the last tag has nounreleased
fix:/feat:release/1.0.0frommainand open a PR for it carrying thecurated 1.0.0 notes between
<!-- release-notes:start -->/<!-- release-notes:end -->. The branch has no diff; the PR exists tocarry the notes, the same way a promotion PR does. Without it the Release
body falls back to the changelog, which for a forced bump over an empty
commit range reads
**Note:** Version bump only for package @mittwald/flow-project(Dry-run rehearsal for the 1.0.0 cut #2769 finding 9).publish.ymlonrelease/1.0.0withrelease_as=1.0.0→ build → version1.0.0→ publishlatest→ pushcommit+tag → GitHub Release from the curated block. The push step does
git push origin HEAD:main, a fast-forward. Close the PR asmerged-by-push and delete the branch.
nextoffmain@1.0.0→ activates forward-merge /publish-next / routing guard
main. From here the standing path publishes stableX.Y.Zautomatically: the version is no longer a prerelease, which is the only
condition it consults.
Residual risk
npm's Trusted Publisher OIDC, provenance and the
@sigstore/signRekor-409patch only engage against npmjs, so the rehearsal could not exercise them — the
real cut is unrehearsed at exactly that point. Treat the first publish as
attended;
only_publishis the recovery path.That binding did get tested, involuntarily: when the fork briefly lost its
registry redirect, a publish aimed at the real npmjs and was rejected outright.
Nothing reached it.
Refs #2769, #2738, #2711, #2724. Closes the graduation and curated-notes work
tracked in #2724.