Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6336-changeset-overwrite-guard.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
---
---

Internal CI-only change: a new report-only `Changeset Overwrite Report` job in
`changeset-guard.yml` names any `.changeset/*.md` a change modified or deleted
without having added it, and prints the release declaration that was there.

No published package changes, so this releases nothing.

objectui#6336 — a hand-picked `changesets`-style filename can land on one that
already exists, and the overwrite is silent in both directions that should catch
it: `git status` reports ` M` rather than `??`, and a deleted release declaration
is flagged by nothing downstream. The cost lands on a third party — whichever
earlier pull request's declaration disappears — and surfaces only when a package
fails to bump.

Report-only is measured, not cautious: across all 5281 first-parent commits on
`main`, every one of the 19 modifications of a pre-existing changeset was
legitimate, so a blocking gate would have failed all of them.
`OS_CHANGESET_OVERWRITE_ENFORCE=1` flips it for whoever revisits that with a new
measurement.
73 changes: 68 additions & 5 deletions .github/workflows/changeset-guard.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,16 @@ name: Changeset Guard
# no per-job path filter either, so the gate lives here, with the inverse
# trigger: it runs *only* when `.changeset/**` changes.
#
# It needs no install and no build — a checkout plus one `node` call, a few
# seconds — so keep it that way if you add checks to it.
# It needs no install and no build — a checkout plus a `node` call per job, a
# few seconds — so keep it that way if you add checks to it.
#
# ── Two jobs, opposite questions ─────────────────────────────────────────────
#
# `no-major` — the LEVEL a pending changeset declares. Reads the tree.
# `overwrite` — whether this change touched a changeset it did not ADD. Reads
# the diff against the merge base, so it alone needs
# `fetch-depth: 0` (objectui#6336). Report-only; the script's
# header carries the history that chose that over blocking.
#
# ── Self-coverage (objectui#6321) ────────────────────────────────────────────
#
Expand All@@ -38,8 +46,9 @@ name: Changeset Guard
# `performance-budget.yml`, was closed in #6315).
#
# The convention those five follow is "list what the `run:` step below actually
# executes" — own YAML plus the invoked script — not the transitive import
# graph. `half-state-patrol.yml` additionally lists `scripts/invoked-as.mjs`,
# executes" — own YAML plus the invoked scripts, both of them — not the
# transitive import graph. `half-state-patrol.yml` additionally lists
# `scripts/invoked-as.mjs`,
# but that is a documented one-off (its own header's divergence #2, tied to
# objectui#5791's port from objectstack) and NOT the pattern the other three
# self-including, script-running gates follow: `node-esm-load-gate.yml`,
Expand All@@ -51,7 +60,19 @@ name: Changeset Guard
# every gate that happens to import it. So this filter matches the
# three-of-four majority and does not add it either.
#
# Also deliberately NOT listed: `scripts/__tests__/check-changeset-no-major.test.ts`.
# Deliberately NOT listed for the same reason: `scripts/check-changeset-presence.mjs`.
# `check-changeset-overwrite.mjs` imports its base-ref resolver, its `git diff`
# wrapper and its frontmatter reader rather than growing a THIRD copy of them —
# the second copy, in `check-i18n-en-drift.mjs`, inherited a real defect from the
# first draft of that resolver and had to be fixed to match under objectui#3766,
# which is the drift a third copy would reopen. It is an import, not something a
# `run:` step executes, and a change to it is already caught by the root vitest
# suite (`check-changeset-presence.test.ts` and `check-changeset-overwrite.test.ts`
# both exercise it) on any pull request touching `scripts/**` — the same
# `~ partial` reasoning applied to `invoked-as.mjs` just above.
#
# Also deliberately NOT listed: `scripts/__tests__/check-changeset-no-major.test.ts`
# and `scripts/__tests__/check-changeset-overwrite.test.ts`.
# That file matches the `~ partial` pattern `published-dist-gate.yml` and
# `spec-range-floors.yml` already set for their own gate scripts' `__tests__`
# files — the test lives under `scripts/**`, which the `Decide whether this
Expand All@@ -68,12 +89,14 @@ on:
- '.changeset/**'
- '.github/workflows/changeset-guard.yml'
- 'scripts/check-changeset-no-major.mjs'
- 'scripts/check-changeset-overwrite.mjs'
push:
branches: [main]
paths:
- '.changeset/**'
- '.github/workflows/changeset-guard.yml'
- 'scripts/check-changeset-no-major.mjs'
- 'scripts/check-changeset-overwrite.mjs'

concurrency:
group: changeset-guard-${{ github.event.pull_request.number || github.ref }}
Expand All@@ -97,3 +120,43 @@ jobs:
# See AGENTS.md §版本号策略, and the script's header for the full rationale.
- name: Verify no changeset declares a major bump
run: node scripts/check-changeset-no-major.mjs

# A SECOND job rather than a step in `no-major`, for one reason: this gate
# reads a diff and therefore needs `fetch-depth: 0`, and `no-major` reads the
# tree and wants to stay a depth-1 checkout. Two jobs also run in parallel,
# so the workflow is no slower than its slowest half.
overwrite:
name: Changeset Overwrite Report
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# The gate compares this change against its MERGE BASE with the target
# branch, so it needs history — checkout's default is a depth-1 clone
# where `git merge-base` has nothing to find. An unresolvable base is a
# hard failure in the script, never a skip, so getting this wrong is a
# red build rather than a silent pass; spelled out here so it stays
# that way. Same requirement, same reason, as `changeset-presence.yml`.
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'

# REPORT-ONLY today, and that is a measured choice rather than caution:
# all 19 modifications of a pre-existing changeset in this repository's
# history were legitimate (bump-level corrections, prose corrections,
# authors amending their own unreleased changeset), so a blocking gate
# would have failed every one of those pull requests. What it catches is
# the overwrite in objectui#6336, whose cost lands on a THIRD PARTY — the
# earlier pull request whose release declaration disappears — and is
# invisible when it happens: `git status` shows ` M`, not `??`. The step
# prints the declaration that was there and what is now gone from it.
# `OS_CHANGESET_OVERWRITE_ENFORCE=1` flips it to blocking for whoever
# revisits this with a new measurement.
- name: Report changesets this change did not add
run: node scripts/check-changeset-overwrite.mjs
51 changes: 45 additions & 6 deletions content/docs/guide/ci-cd-pipeline.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,7 +25,7 @@ one has its own section below.
|---|---|---|---|
| `ci.yml` | CI | Push / PR to `main`, `develop`; merge-queue builds | **Yes** — every job but the two coverage-lane jobs (`test-coverage` and `coverage-report`, push only) runs on PRs and on queue builds |
| `lint.yml` | Lint | Push / PR to `main`, `develop`; merge-queue builds; manual | **Yes** — ESLint **errors** only |
| `changeset-guard.yml` | Changeset Bump Policy| PR / push touching `.changeset/**` or the gate itself | **Yes** |
| `changeset-guard.yml` | Changeset Bump Policy, Changeset Overwrite Report | PR / push touching `.changeset/**` or either gate itself | **Yes** — the bump policy job only; the overwrite job is report-only |
| `changeset-presence.yml` | Changeset Declaration | PR to `main`, `develop` — **no path filter**; merge-queue builds | **Yes** — when a released package's `src/` changed and no changeset was added |
| `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** |
| `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** |
Expand DownExpand Up@@ -153,8 +153,8 @@ Two things follow for anyone editing this directory:
shapes is just as unrequirable without appearing here.
- **Changeset Bump Policy** (`changeset-guard.yml`) — an **inverse** path filter: its
`pull_request` trigger declares
`paths: ['.changeset/**', '.github/workflows/changeset-guard.yml', 'scripts/check-changeset-no-major.mjs']`,
so on a PR that touches none of those three the context is never created at all.
`paths: ['.changeset/**', '.github/workflows/changeset-guard.yml', 'scripts/check-changeset-no-major.mjs', 'scripts/check-changeset-overwrite.mjs']`,
so on a PR that touches none of those four neither of its contexts is created at all.
- **Bundle Analysis** (`performance-budget.yml`) — an ordinary path filter on the same
trigger, with the same consequence for every PR that matches none of its paths.
- **Live E2E (informational)** (`live-e2e.yml`) — the job carries `continue-on-error: true`,
Expand DownExpand Up@@ -1348,11 +1348,17 @@ changeset started nothing at all. Since [#3523](https://github.com/objectstack-a
job in them short-circuits, because `.changeset/**` is still on the in-job ignore list. The check
that has to read the changeset therefore still lives here.

The same `paths:` list also carries the gate's own YAML and
`scripts/check-changeset-no-major.mjs` ([#6321](https://github.com/objectstack-ai/objectui/issues/6321))
The same `paths:` list also carries the gate's own YAML and both scripts it runs,
`scripts/check-changeset-no-major.mjs` and `scripts/check-changeset-overwrite.mjs`
([#6321](https://github.com/objectstack-ai/objectui/issues/6321))
— self-coverage, not the inverse trigger above: without it, a PR that edits the gate is not the PR
that runs it, and the first real execution lands on someone else's unrelated `.changeset/**` PR.
Deliberately not listed: `scripts/invoked-as.mjs` (a dependency the gate script imports, but a
Deliberately not listed: `scripts/check-changeset-presence.mjs` (the overwrite gate imports its
base-ref resolver, `git diff` wrapper and frontmatter reader rather than growing a third copy —
the second copy, in `check-i18n-en-drift.mjs`, inherited a real defect from that resolver's first
draft and had to be fixed to match under
[#3766](https://github.com/objectstack-ai/objectui/issues/3766); the root vitest suite exercises it
on any PR touching `scripts/**`), `scripts/invoked-as.mjs` (a dependency the gate scripts import, but a
widely shared one — 40+ importers under `scripts/` — that `published-dist-gate.yml`,
`spec-range-floors.yml` and `node-esm-load-gate.yml` also import without listing; only
`half-state-patrol.yml` lists it, as a documented one-off) and the script's own
Expand All@@ -1370,6 +1376,39 @@ The one release that legitimately bumps the major is the one following `@objects
its major; it sets `OBJECTUI_ALLOW_MAJOR=1`. `pnpm test` asserts the same repository state, so
the rule survives this workflow being skipped.

#### Second job: Changeset Overwrite Report

Runs `scripts/check-changeset-overwrite.mjs`, which asks a different question of the same files:
did this change **modify or delete a `.changeset/*.md` that already existed at its merge base** —
a changeset it did not add? It is a separate job because it reads a diff and so needs
`fetch-depth: 0`, which the bump-policy job does not want.

[#6336](https://github.com/objectstack-ai/objectui/issues/6336) is why it exists. A dev run wrote
its changeset to a hand-picked `changesets`-style name that already existed on `main`, and the
heredoc overwrote an unrelated `@object-ui/plugin-charts: minor`. It was caught before any commit,
but the property that makes it worth a gate is that **the cost lands on a third party and is
invisible at the time it happens**: the agent that picks the colliding name loses nothing, and
whichever earlier PR's release declaration vanishes only discovers it when a package silently
fails to bump. Both signals that should catch it fail — `git status` shows `` M`` rather than
`??`, which reads as your own new file landing, and a deleted release declaration is not something
any later gate flags. With 424 accumulated changesets against an `adjective-animal-verb` name
space, the collision probability is not theoretical.

**It is report-only, and that is measured rather than cautious.** Across all 5281 first-parent
commits on `main`, 12 commits modified a pre-existing changeset (19 files) and **all 19 were
legitimate** — bump levels corrected when the pending release line changed, a "eleven" corrected
to "ten", a typo'd package name fixed, authors amending their own not-yet-released changeset. A
blocking gate would have failed every one of those PRs. Deletions are dominated by the release
itself (82 of 88 delete changesets alongside a package `CHANGELOG.md`, which is `changeset
version` emptying the queue); the job recognizes that shape and says so instead of reporting it.
`OS_CHANGESET_OVERWRITE_ENFORCE=1` flips the job to blocking for whoever revisits this with a new
measurement.

⭐ **The convention that makes the hazard impossible**: name a changeset after the issue it
settles — `.changeset/<issue>-<slug>.md`. The `adjective-animal-verb` names are safe when
`pnpm changeset` allocates them, because it allocates against the files already present; picking
one by hand is what removes that guarantee.

> **A changeset IS now required, by `changeset-presence.yml` — but there is still no
> `skip-changeset` mechanism.** Until [#3387](https://github.com/objectstack-ai/objectui/issues/3387)
> nothing in CI asked whether a PR had added one, and this note said so at length, because the
Expand Down
Loading
Loading