Skip to content

fix(ci): stop publishing on paths that cannot reach a consumer (fixes #3023) - #3098

Open
mfal wants to merge 7 commits into
mainfrom
claude/title-driven-release-gate
Open

fix(ci): stop publishing on paths that cannot reach a consumer (fixes #3023)#3098
mfal wants to merge 7 commits into
mainfrom
claude/title-driven-release-gate

Conversation

@mfal

@mfal mfal commented Sep 2, 2026

Copy link
Copy Markdown
Member

Release relevance is decided by PATH, the changelog by TYPE, and the two could never agree (#3023). Both halves are fixed here, and the decision stays on the paths.

Needless releases

The denylist knew only repo-root prefixes, matched with startsWith, so everything under packages/** counted as publish-relevant wholesale:

Version Trigger Reaches a consumer?
1.0.14 #3010packages/components/.storybook/preview.tsx alone No
1.0.12 #3006 — a scripts-only package manifest, a package-local CONTRIBUTE.md, dev/cross-version/**, src/tests/visual/** No

Package-local denylist entries, segment-exact under packages/<name>/e2e-helpers/ is not e2e/:

  • .storybook/**, e2e/**, src/tests/**, dev/cross-version/**, dev/vitest/**
  • the package's CONTRIBUTE.md (no package lists it in files)
  • *.stories.tsx and *.test.* anywhere — .test. is not always the last extension (*.browser.test.remote.tsx)

packages/*/dev/** is deliberately not denylisted wholesale: in components and codemods that directory is the build — dev/vite/* holds the plugins vite.build.config.ts imports, dev/createDocPropertiesJson.ts writes the shipped dist/assets/doc-properties.json, dev/remote-components-generator/** generates view.ts and src/auto-generated/**, and codemods' build script is tsx dev/generateCli.ts && ….

The criterion is no consumer effect, not "not in the tarball": a tarball carries the package's scripts, yet nothing a consumer installs reads them.

isPublishRelevant is now a wrapper around classifyPath, which also returns the rule that decided — that is what the skip notice prints.

The key-diff refinement applies to every packages/*/package.json, not just the root one: a scripts-only diff cannot reach a consumer.

Releases with an empty changelog

changelogPreset renders every commit type now, so a chore, build, test or ci change that touches a package is no longer invisible — asserted by changelog-preset.test.mjs against the preset's own writer.transform, which needs no git history and no rendering.

What stays unrenderable, and is documented rather than fixed: a release whose cause lies outside every package — pnpm-lock.yaml, the root manifest, lerna.json. Lerna attributes commits per package, no package changed, so every changelog honestly reads "Version bump only". 1.1.11 is the live example, a lock-only Rollup bump.

The anomaly #3083 required explaining first is resolved: 1.1.6 shows a fix(codemods): entry in the components changelog because #3041 regenerated packages/components/MIGRATION.md, so components genuinely changed; #3040 (1.1.1) touched no package but codemods and correctly reads "Version bump only". The per-package attribution is consistent — only the rendered scope is the commit's, never the changelog's package.

Verified

docs/release-workflow.md is cut back to the mechanics: what the classifier excludes, what it judges by content, and the changelog facts.

Split out of this PR

A title-driven variant of this gate ([release] / [no-release] tags in the PR title) was built and withdrawn: the tag puts the decision in a hand-written token while the paths answer the question directly.

Fixes #3023. Fixes #3083.

🤖 Generated with Claude Code

mfal and others added 2 commits September 2, 2026 14:06
Relevance was path-based but only knew repo-ROOT prefixes, so everything
under `packages/**` counted as publish-relevant. #3010 cut 1.0.14 from
`packages/components/.storybook/preview.tsx` alone; #3006 cut 1.0.12 from a
scripts-only manifest, a `CONTRIBUTE.md`, `dev/cross-version/**` and
`src/tests/visual/**`. Neither can reach a consumer.

Two changes to the classifier:

- Package-local denylist entries, segment-exact under `packages/<name>/`:
  `.storybook/**`, `e2e/**`, `src/tests/**`, `dev/cross-version/**`,
  `dev/vitest/**`, `CONTRIBUTE.md`, plus `*.stories.tsx` and `*.test.*`
  anywhere. `classifyPath` replaces the bare `startsWith` and returns the
  rule that decided, which the skip notice now names.
- The key-diff refinement applies to every `packages/*/package.json`, not
  just the root one: a `scripts`-only diff cannot reach a consumer.
  `classifyRootManifestChange` becomes `classifyManifestChange` and the
  `decide` job fetches before/after for each changed manifest into
  `MANIFEST_SNAPSHOT_DIR`.

`packages/*/dev/**` is deliberately NOT denylisted, although #3023 proposed
it. In `components` and `codemods` that directory IS the build: `dev/vite/*`
holds the plugins `vite.build.config.ts` imports, `dev/createDocPropertiesJson.ts`
writes the shipped `dist/assets/doc-properties.json`,
`dev/remote-components-generator/**` generates `view.ts` and
`src/auto-generated/**`, and `codemods`' build script is
`tsx dev/generateCli.ts && …`. Only the two test-harness subtrees are safe.

Relevance stays path-based — no gate on commit type. `chore(deps):` and
`refactor:` change what consumers resolve.

For the empty changelog on real releases, `lerna.json` now configures
`changelogPreset` as an object and unhides every type. A release exists only
because something can reach a consumer, so it deserves a line;
`chore(deps): bump …` and a `docs:` commit on a shipped `AGENTS.md` (#2954,
1.0.11) are exactly the cases a path gate must not suppress.
`changelog-preset.test.mjs` asserts every type renders. No bump semantics
change: without `bumpStrict` the preset's `whatBump` returns patch for any
non-empty range regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The path classifier decided releases alone, and a file list nobody reads is a
poor place for a decision a maintainer has to understand. The title is now the
decision and the paths verify it (#3023).

The type gives the default — feat/fix/perf/revert release, docs/ci/chore/test/
build/style/refactor do not — and a `[release]` or `[no-release]` tag at the end
of the title overrides it. Only titles that contradict their type need a tag.
The type alone cannot decide: it states the author's intent, not the effect on
the artifact. Over the 80 merges since the gate landed the two disagreed 21
times, and a type-only gate would have swallowed 8 real releases, the 1.1.0
promotion among them.

Three classes cannot answer from the subject and fall back to the paths:
`chore(sync):` forward-merges (the subject cannot know what came over, ADR 0004
§6), Dependabot subjects (a group is not homogeneous in effect and
`commit-message.prefix` is per ecosystem, not per group), and an unparsable
subject or unknown type. `chore(release):` never releases, `chore(promotion):`
always does.

Verification happens twice, because the PR check can be outrun — GitHub lets
the merger edit the squash subject after every check has passed:

- `commit-guard.yml` fails the PR on a disagreement, naming the files.
- `publish.yml`'s `decide` job fails the RELEASE RUN on one, instead of
  guessing. Nothing publishes and nothing is skipped silently; the remedy is a
  corrected follow-up commit or a manual dispatch.

`release-relevance-guard.mjs` is replaced by `release-intent-guard.mjs`, which
serves both callers through `MODE=check|decide` rather than duplicating the
manifest-snapshot handling.

Tests and stories also leave `dist/types`. `unplugin-dts` emitted declarations
for all of `src`, which put 196 of flow-remote-react-components@1.1.10's 799
tarball entries under `dist/types/tests/**` and 252 story entries into
flow-react-components. All eight release builds now share `publishedDtsOptions`
from packages/core. No consumer loses a type: no package's `exports` has a
wildcard subpath, so those declarations were unreachable. A story's helper stays
— `dev/createDocPropertiesJson.ts` parses every `.tsx` under `src/` and ignores
only `*.stories.tsx`, so `Button/stories/lib.tsx` contributes five entries to
the published `doc-properties.json`.

`@mittwald/flow-core` becomes a declared devDependency of ext-bridge,
remote-core and remote-elements, which newly import it — that is also the nx
graph edge their build cache needs to invalidate on a core change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal force-pushed the claude/title-driven-release-gate branch from cb6e273 to b529f13 Compare September 2, 2026 12:06
The title-driven gate landed with the path work still running on every push, so
it added machinery instead of removing it. It now runs only where it is needed:

- `publish.yml` asks the title first (`MODE=title`, one node call on a string).
  The compare API and the manifest snapshots run only for the subjects that
  cannot answer — a `chore(sync):` forward-merge, a Dependabot bump, an
  unparsable one. Every ordinary release skips both.
- The push no longer re-verifies the title against the paths. `commit-guard.yml`
  already did that on the PR, and repeating it existed only to catch a squash
  subject edited in the merge dialog after the checks passed. That hole is now
  deliberate and documented: the remedy is a manual `workflow_dispatch`, and
  closing it would mean re-fetching and re-classifying every push for a rare
  manual edit.
- The manifest-snapshot loop stood duplicated in both workflows and is now
  `fetch-manifest-snapshots.sh`, which serves the PR check from git history and
  the release job from the contents API.
- The guard's modes are one job each: `title` decides from the subject, `paths`
  from the file list, `check` compares the two. Only `check` reconciles.

`docs/release-workflow.md` is cut back to the mechanics — what decides, what
overrides it, what the classifier excludes — with the reasoning and the
historical case numbers left in the code comments and the issues where they
belong. Net 52 lines shorter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for ./packages/components/

Status Category Percentage Covered / Total
🔵 Lines 78.69% 746 / 948
🔵 Statements 78.57% 763 / 971
🔵 Functions 80.09% 165 / 206
🔵 Branches 70.33% 377 / 536
File CoverageNo changed files found.
Generated in workflow #6597 for commit ca5c099 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

Preview environments are ready:

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

Images:

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

mfal and others added 3 commits September 2, 2026 14:53
Porting the `publishedDtsOptions` work from the previous branch brought
`version: 1.1.10` back with it — that branch sat on the 1.1.10 release, this one
on 1.1.11. The version-consistency guard caught it, which is exactly the merge
shape it exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The title-driven gate is withdrawn. `[release]` / `[no-release]` tags put the
decision in a hand-written token, and the paths answer the question directly —
so the paths keep deciding, as they have since #2931.

Removed: `release-title-lib.mjs` and its tests, `release-intent-guard.mjs`, the
`release-intent` job in `commit-guard.yml`, the title-first `decide` steps in
`publish.yml`, and `fetch-manifest-snapshots.sh` (extracted only to serve the
second caller that no longer exists). `release-relevance-guard.mjs` is back;
both workflows are byte-identical to the path-based version again.

Kept: the package-local denylist and the per-manifest key diff, the changelog
preset that renders every type, `publishedDtsOptions`, and the three manifest
versions.

`docs/release-workflow.md` describes the path-based model, cut back to the
mechanics — what the classifier excludes, what it judges by content, and the two
`dist/types` and changelog facts. 47 lines in, 50 out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The declaration-emit change is orthogonal to the release gate and reviews on its
own terms — it changes what the packages publish, not when they publish. It
lives in its own pull request now, together with the release-workflow note that
describes it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal mfal changed the title fix(ci): decide releases from the PR title and verify them against the paths (fixes #3023) fix(ci): stop publishing on paths that cannot reach a consumer (fixes #3023) Sep 2, 2026
Two paths were still counted as consumer-relevant although they cannot reach
one, and both cut releases:

- A package's ROOT Markdown was relevant wholesale. Nothing builds a root `.md`,
  so it reaches a consumer exactly if the package publishes it — and that only
  `files` can say. `flow-react-components` lists AGENTS.md, MIGRATION.md and
  USAGE.md; `remote-react-components` lists only USAGE.md, so its AGENTS.md
  reaches nobody. #3009 released through exactly that file. The `files` are read
  from the checkout rather than fetched: only the current state matters, and the
  job already has the tree. An unreadable manifest counts as shipped.
  Markdown DEEPER in a package keeps its path-level relevance —
  `codemods/src/migrations/*/entry.md` is a generator input, not documentation.
- `Dockerfile` and `.dockerignore` join the package-local denylist. They build
  the Storybook preview image from `dev/` and `.github/`, and #3008 pushed
  nothing else under `packages/`.

Replayed over `main` since #2933: the merges that reach no consumer under a
non-releasing type drop from 9 to 7 of 81.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant