Skip to content

ci(release): 1.0.0 cut mechanics + fixes from the dry-run rehearsal - #2759

Merged
mfal merged 22 commits into
mainfrom
chore/release-1.0.0-cut
Aug 10, 2026
Merged

ci(release): 1.0.0 cut mechanics + fixes from the dry-run rehearsal#2759
mfal merged 22 commits into
mainfrom
chore/release-1.0.0-cut

Conversation

@mfal

@mfal mfal commented Jul 30, 2026

Copy link
Copy Markdown
Member

Mergeable like any other change. Its own merge publishes an ordinary
0.2.0-alpha.N, exactly as every merge to main does today — nothing here
waits for the cut any more.

Two earlier versions of this description said otherwise, and both were wrong.
"The merge is a no-op because the title is a non-releasing ci:" is false:
--force-publish releases on every commit type, measured on the rehearsal fork
(#2769 finding 15). And dropping --conventional-prerelease="*" here would
have made that merge publish a premature stable 0.2.0 — so the flag is gone:
publish.yml now derives its line from the current version instead, which
switches inside the cut itself and leaves no window in either direction.

The cut is still a deliberate workflow_dispatch — see the sequence at the
bottom.

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 next line, 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

  • Drop --conventional-prerelease="*" on the main path → pushes to main
    graduate to stable X.Y.Z under dist-tag latest.
  • workflow_dispatch inputs release_as (force an explicit version for the
    one-time 1.0.0 cut) and dry_run (build + version + lerna publish --dry-run, nothing pushed).
  • --conventional-commits on the release_as path. It is also what writes
    CHANGELOG.md; without it the 1.0.0 Release would have silently carried the
    previous version's notes.
  • Release-notes extraction matches any header level (^#+ \[).
    conventional-changelog varies the level with the bump type, so a patch release
    renders as ## [1.0.1] and was skipped entirely — observed live.
  • The body is capped at GitHub's 125000-character limit. Exceeding it fails
    gh release create after the publish succeeded and the tag was pushed,
    leaving packages published, git advanced and no Release.

The promotion — closes both halves of #2724

  • publish.yml identifies a promotion merge from the merged PR's head branch
    and versions accordingly, and builds the GitHub Release body from the
    curated marker block in that PR, falling back to the changelog section.
  • The tag comes from lerna.json, not git describe --tags: a pre-graduated
    promotion creates no new tag, so git describe would resolve to the
    prerelease tag the release branch inherited and release the wrong ref.
  • /prepare-release merges the target into the release branch, so the PR is
    mergeable at all — GitHub does not run .gitattributes merge drivers, and
    without this the first promotion PR came out conflicting across 35 files.
  • It takes the target's changelogs, not the source's. merge=ours is
    written for main → next; applied to the promotion it overwrites main's
    stable history with the interleaved prerelease one.
  • Its pre-flight probes the merge instead of testing ancestry — see below.

The cascade — forward-merge.yml, forward-merge-drift.yml

  • A conflict escalates to an issue, and pnpm sync:resolve /
    --continue resolves it in two commands, locally, where the drivers actually
    run. 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.json gains a merge driver. It carries the same version field as the
    manifests and conflicted on pure version churn without one.
  • A workflow_run catch-up re-runs the cascade when publish-next completes,
    and forward-merge-drift.yml is the net underneath.
  • dev:init-merge-drivers (run by prepare) registers the drivers locally.

The next line — publish-next.yml

Opens a fresh minor prerelease line whenever next's base is at or below what
main has released, deriving the target from main's version. Without it the
line 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.yml

release/* joins sync/* in the promotion exemptions, and the promotion PR
title becomes a Conventional Commit (chore(promotion): …, deliberately not
chore(release):, which is publish.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.md documents 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 main becomes an ancestor of next;
§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-release refused to start
at 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 next
already 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)

  • All pre-cut gates in 1.0.0 release — tracking issue (fast fixes, curated feature releases) #2738 green
  • Dry-run: dispatch with release_as=1.0.0 + dry_run=true; confirm it
    computes 1.0.0 and the publish plan is correct
  • Freeze main; pre-check that git log since the last tag has no
    unreleased fix:/feat:
  • Create release/1.0.0 from main and open a PR for it carrying the
    curated 1.0.0 notes between <!-- release-notes:start --> /
    <!-- release-notes:end -->. The branch has no diff; the PR exists to
    carry 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).
  • The cut: dispatch publish.yml on release/1.0.0 with
    release_as=1.0.0 → build → version 1.0.0 → publish latest → push
    commit+tag → GitHub Release from the curated block. The push step does
    git push origin HEAD:main, a fast-forward. Close the PR as
    merged-by-push and delete the branch.
  • Branch next off main @ 1.0.0 → activates forward-merge /
    publish-next / routing guard
  • Unfreeze main. From here the standing path publishes stable X.Y.Z
    automatically: 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/sign Rekor-409
patch 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_publish is 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.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for ./packages/components/

Status Category Percentage Covered / Total
🔵 Lines 85.52% 579 / 677
🔵 Statements 85.42% 592 / 693
🔵 Functions 87.75% 129 / 147
🔵 Branches 74.43% 262 / 352
File CoverageNo changed files found.
Generated in workflow #6045 for commit eaf76e6 by the Vitest Coverage Report Action

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_dispatch inputs release_as (force an explicit version) and dry_run (run lerna publish --dry-run and skip pushing/tags/release).
  • Removes --conventional-prerelease="*" from the normal main publishing path so main graduates to stable X.Y.Z releases.
  • 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.

Comment thread .github/workflows/publish.yml Outdated
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

Type URL
docs pr-2759.docs.review.flow-components.de
storybook pr-2759.storybook.review.flow-components.de

Images:

  • docs: ghcr.io/mittwald/flow/docs:pr-2759
  • storybook: ghcr.io/mittwald/flow/storybook:pr-2759

@mfal mfal added 1.0 Must be done before releasing 1.0 pre-1.0 Blocks the 1.0.0 cut — must ship before 1.0.0 labels Jul 31, 2026
@mfal mfal mentioned this pull request Aug 3, 2026
48 tasks
mfal added a commit to mfal/flow-1-0-rehearsal that referenced this pull request Aug 5, 2026
…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.
mfal and others added 15 commits August 6, 2026 14:51
…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.
@mfal
mfal force-pushed the chore/release-1.0.0-cut branch from 0c0b51c to d42f67a Compare August 6, 2026 12:57
@mfal mfal changed the title ci(publish): stable-main publishing + release_as/dry_run for the 1.0.0 cut ci(release): 1.0.0 cut mechanics + fixes from the dry-run rehearsal Aug 6, 2026
mfal added 3 commits August 6, 2026 15:06
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.
mfal and others added 2 commits August 6, 2026 15:54
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>
@mfal mfal mentioned this pull request Aug 6, 2026
17 tasks

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 allows main to be ahead of next by churn-only release commits while still being content-identical. That makes this guard almost always false in the steady state and can cause pnpm sync:resolve to 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 lerna references 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_MINUTES comes from a workflow_dispatch input (string) and is used in a numeric -lt comparison. 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

@mfal

mfal commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Result of an additional "happy-path"-rehearsal with this changes applie

Legend

✅ done & verified · ⏳ in progress · ⏸️ waiting on a decision · ❌ failed


Phase 0 — Isolate the environment

Prior rehearsal infrastructure survived (teardown was never run), reused as-is.

  • ✅ Fork mfal/flow-1-0-rehearsal exists; Actions enabled (all).
  • ✅ Fork variable REHEARSAL_REGISTRY = https://npm.p-verefh.project.space
    (set 2026-08-05).
  • ✅ Fork secrets present: PUBLISH_PAT, REHEARSAL_NPM_TOKEN.
  • ✅ Verdaccio alive: GET /-/ping{}. Carried surviving dist-tags from the
    earlier run (@mittwald/flow-react-components: latest 0.2.0-alpha.1010).
  • ✅ No npmjs credential in the fork; the OIDC binding to mittwald/flow is the
    real backstop (finding 14).

Wiring commit

Starting point: fork main was a clean upstream mirror at 0.2.0-alpha.1010,
carrying all #2759 fixes already (release_as, dry_run,
current-version-based line derivation). No cherry-pick needed.

Built on branch rehearsal/1.0.0-cut, commit c0043f9ee
ci(rehearsal): wire the 1.0.0-cut rehearsal (#2769):

  • publish.yml / publish-next.yml: setup-node
    registry-url = vars.REHEARSAL_REGISTRY (scope @mittwald);
    NODE_AUTH_TOKEN = secrets.REHEARSAL_NPM_TOKEN on the Publish steps.
    lerna publish invocations unchanged.
  • ✅ Workflows reduced to four under test: publish, publish-next,
    forward-merge, commit-guard. Deleted the other twelve (incl. the hourly
    forward-merge-drift cron).
  • ✅ Added rehearsal/verdaccio.config.yaml and REHEARSAL.md.
  • Scoped to @mittwald/react-tunnel for a fast happy path: lerna.json
    packages narrowed to packages/react-tunnel (verified authoritative over
    pnpm-workspace.yamlapps/* sit off the release line, so lerna reads the
    lerna.json field), so version + publish touch only react-tunnel. Build step
    pnpm nx build react-tunnel (measured 658ms vs. the full monorepo build).
    Verdaccio config carves react-tunnel out as local-only (it is not a flow-*
    name, so the proxied @mittwald/* block would otherwise merge npmjs's real
    versions). Running Verdaccio must be redeployed with this config for the
    carve-out to take effect.

Arming

  • ✅ Pushed rehearsal/1.0.0-cut:main to the fork (eaf76e637..c0043f9ee).
    Actions were already on, so the push fired publish (run 31163748851) and
    forward-merge (run 31163748862) at 08:55:48Z. Both cancelled by the
    operator immediately — no publish, no push. forward-merge would have been
    dormant anyway (next does not exist yet).
  • Fork main is now armed with the rehearsal wiring.

Phase 2 — main: version bump + stable publish

A first cut (run 31165542255) succeeded end-to-end and was then rolled
back
to re-wire the rehearsal for a faster happy path (react-tunnel scoping,
see Phase 0). Rollback: deleted the 1.0.0 GitHub Release + tag, cleared the
1.0.0 from Verdaccio, reset fork main to the pre-cut wiring commit.

Re-dispatched on the scoped wiring: publish.yml on main, release_as=1.0.0,
dry_run=false. Run 31166838163, all steps green.

  • @mittwald/react-tunnel on Verdaccio under dist-tag latest = 1.0.0
    (tarball present). The local 1.0.0 correctly overrides the proxied npmjs
    latest — so the assertion is clean even without the Verdaccio redeploy.
    (experimental: 0.2.0-alpha.35 is the leftover proxied npmjs tag — not
    asserted on.)
  • ✅ GitHub Release 1.0.0: prerelease:false, draft:false, target main.
  • ✅ Tag 1.0.0; main at version 1.0.0 (lerna.json + react-tunnel manifest).
  • ✅ Anti-ratchet ordering held.
  • ✅ Finding 4 (oversized release body) fix exercised: the fork's regenerated
    full-history changelog section tripped the 125000-char limit, and the step
    truncated to 124869 chars instead of hard-failing with HTTP 422.

Phase 3 — Create next

Branched next off main @ 1.0.0 (commit b854725db) — after the cut, so
the cut's own release-commit push never triggered the cascade mid-cut.

  • next exists at 1.0.0.
  • ✅ The push: next fired publish-next.yml, which correctly skipped (run
    31167043659, 0s): next's head commit is chore(release): bump version to 1.0.0, so the skip-guard !startsWith(msg, 'chore(release):') blocks it. The
    next line is therefore NOT seeded on creation — it activates on the first
    forward-merge in Phase 4 (a chore(sync):/merge commit, not chore(release)).

Phase 4 — Forward-merge cascade (the happy path under test)

Pushed a fix: to main: commit 53c64fb5e
fix(react-tunnel): correct misspelled committedChildren local variable.

  • Double trigger (ADR 0004 §6/§7): the one fix: push fired
    publish.yml (run 31167433002) and forward-merge.yml (run 31167432897)
    in parallel — different concurrency groups (mutate-main vs mutate-next).
  • publish.yml bumped main 1.0.0 → 1.0.1, published react-tunnel@1.0.1
    under latest, cut GitHub Release 1.0.1 (success).
  • Happy path: forward-merge.yml logged "Merge succeeded", merged
    main → next as --no-ff and pushed directly to next, no PR:
    b854725db..71d73e6d1. next head is now
    chore(sync): forward-merge main into next (53c64fb5e).

Phase 5 — next prerelease publish

The push to next triggered publish-next.yml (run 31167455571, success).

  • ✅ Version step seeded the line correctly (findings 7/11): next: 1.0.0 · main: 1.0.1 → "Opening a fresh next line at 1.1.0-next.0" →
    react-tunnel: 1.0.0 => 1.1.0-next.0. A minor prerelease line, not the
    broken patch line.
  • Key assertion: npm dist-tags on Verdaccio = latest: 1.0.1,
    next: 1.1.0-next.0. The next publish did not move latest.
  • No GitHub Release for the prerelease — only 1.0.1 (Latest) and 1.0.0.
    (experimental: 0.2.0-alpha.35 is the leftover proxied npmjs tag.)

Result

Happy path end-to-end green on the react-tunnel-scoped wiring: cut → next
fix: → forward-merge (main → next, --no-ff, no PR) → publish-next
(1.1.0-next.0 under next, latest untouched). Findings 4 (release-body
truncation) and 7 (minor prerelease seed) were exercised live and held.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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. next already had main, 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_by is 0 the workflow exits early and never calls close_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 whether next “contains” main. In the steady state described by ADR 0004, main can 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(

@mfal
mfal merged commit 9b31e5e into main Aug 10, 2026
12 checks passed
@mfal
mfal deleted the chore/release-1.0.0-cut branch August 10, 2026 05:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.0 Must be done before releasing 1.0 pre-1.0 Blocks the 1.0.0 cut — must ship before 1.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants