From ad5fed7fa94ec9c7a299847015593e0fe71dc7e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:39:04 +0000 Subject: [PATCH 1/6] =?UTF-8?q?fix(console,runner,types):=20a=20release's?= =?UTF-8?q?=20CHANGELOG.md=20is=20not=20a=20source=20=E2=80=94=20the=20con?= =?UTF-8?q?sole=20and=20runner=20sheets=20take=20source(none),=20and=20two?= =?UTF-8?q?=20censuses=20exclude=20it=20(objectui#11586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 17.7.0 release head (#5400) was red in four Test shards while main was green. All four failures come from the CHANGELOG.md files that `pnpm changeset:version` writes: - `apps/console/src/index.css` and `packages/runner/src/index.css` still left Tailwind's automatic source detection on. Detection scans prose in the package directory, CHANGELOG.md included. On the release head it compiled 11 rules into the console sheet and 5 into the runner sheet out of changelog text. Both entries now take `source(none)`, as `packages/components` did in objectui#9569. An ablation on main finds that every rule detection alone supplied came from a .md file: `.w-100` from a console docs proposal, and `.flex-shrink-0` and `.isolate` from the runner changelog. - The `AppComponentSchema.actions` census and the single-`bind` census `git grep` over CHANGELOG.md. They now exclude it with `:!*CHANGELOG.md`, the spelling the sibling censuses already use. Each stylesheet test gains a pin that compiles the entry from the package root and from an empty directory and requires the same rules. The runner test also pins that its changelog-only `flex-shrink-0` stays out. Claude-Session: https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL Co-authored-by: Claude --- ...586-tailwind-source-none-console-runner.md | 17 ++++++ .../src/index-css-scan-excludes-tests.test.ts | 58 +++++++++++++++---- apps/console/src/index.css | 19 +++++- .../published-stylesheet-sources.test.ts | 39 +++++++++++-- packages/runner/src/index.css | 27 ++++++--- .../src/__tests__/base-bind-declared.test.ts | 8 ++- ...ndler-keys-string-any-mirrors-7344.test.ts | 10 +++- 7 files changed, 152 insertions(+), 26 deletions(-) create mode 100644 .changeset/11586-tailwind-source-none-console-runner.md diff --git a/.changeset/11586-tailwind-source-none-console-runner.md b/.changeset/11586-tailwind-source-none-console-runner.md new file mode 100644 index 0000000000..e86b7652ac --- /dev/null +++ b/.changeset/11586-tailwind-source-none-console-runner.md @@ -0,0 +1,17 @@ +--- +'@object-ui/console': patch +'@object-ui/runner': patch +--- + +The console and runner stylesheets compile only from their declared `@source` +lines. Tailwind's automatic source detection is now off (`source(none)`), as it +already was for `@object-ui/components`. + +With detection on, Tailwind also scanned prose in each package's directory, and +`CHANGELOG.md` was part of it. A release writes every changeset body into that +file, so the release itself added utilities to the published CSS that no +component uses. On the 17.7.0 release head, that was 11 rules (about 9 kB) in the +console sheet and 5 in the runner sheet. Both sheets now compile to the same +rules wherever the build runs. The only rules dropped against the previous +build came from prose: one from a docs proposal in the console and two from +the runner's changelog. diff --git a/apps/console/src/index-css-scan-excludes-tests.test.ts b/apps/console/src/index-css-scan-excludes-tests.test.ts index a616493b54..044fa3f64e 100644 --- a/apps/console/src/index-css-scan-excludes-tests.test.ts +++ b/apps/console/src/index-css-scan-excludes-tests.test.ts @@ -51,16 +51,24 @@ * * ## Why `base` is passed explicitly * - * This entry opens with a bare `@import 'tailwindcss'`, so Tailwind's automatic - * source detection is ON and rooted at the PROCESS CWD -- vitest runs from the - * repo root while `pnpm --filter @object-ui/console build` runs from this app's - * directory. Passing `base` pins this reading to the directory the build runs - * in, so the test measures the stylesheet users are served rather than the one - * the test runner's CWD happens to produce. `base` governs only the automatic - * root; the `@source` lines always resolve against the entry's own directory. + * `base` is the root of Tailwind's AUTOMATIC source detection, and it defaults + * to the PROCESS CWD. vitest runs from the repo root, while + * `pnpm --filter @object-ui/console build` runs from this app's directory. + * Passing `base` pins this reading to the directory the build runs in. `base` + * governs only the automatic root; the `@source` lines always resolve against + * the entry's own directory. + * + * The entry now opens with `source(none)` (objectui#11586), so detection is off + * and `base` should change nothing. The third test below asserts exactly that: + * it compiles once from this app's directory and once from an empty one and + * requires the same rules. Before the switch, detection scanned this app's + * prose. On the 17.7.0 release head it compiled 11 rules out of the + * `CHANGELOG.md` that `pnpm changeset:version` writes, and one of them was + * components' sentinel, which turned the second test red. */ -import { readFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import postcss from 'postcss'; import tailwindPostcss from '@tailwindcss/postcss'; @@ -90,9 +98,11 @@ const SENTINEL_OTHER_TREE_VALUE = '3.7331px'; const SENTINEL_OTHER_TREE_HOST = 'packages/components/src/__tests__/index-css-scan-excludes-tests.test.ts'; -async function compileConsoleStylesheet(): Promise<{ css: string; selectors: Set }> { +async function compileConsoleStylesheet( + base: string = appRoot, +): Promise<{ css: string; selectors: Set }> { const source = await readFile(entry, 'utf8'); - const result = await postcss([tailwindPostcss({ base: appRoot })]).process(source, { + const result = await postcss([tailwindPostcss({ base })]).process(source, { from: entry, }); const selectors = new Set(); @@ -130,4 +140,30 @@ describe('apps/console/src/index.css @source scan', () => { expect(selectors.has('.bg-background')).toBe(true); expect(css).not.toContain(SENTINEL_OTHER_TREE_VALUE); }, 60_000); + + it('compiles the same rules from this app\'s directory as from an empty one — no rule comes from prose beside the source (objectui#11586)', async () => { + // Automatic detection is the only input that depends on `base`. If every + // rule survives a compile rooted at an empty directory, the `@source` lines + // supply all of them, so neither `CHANGELOG.md` nor `docs/` can add a rule. + // A negative test names one token. This one covers any file a future + // release writes. + const empty = await mkdtemp(join(tmpdir(), 'objectui-11586-')); + try { + const { selectors: fromApp } = await compileConsoleStylesheet(appRoot); + const { selectors: fromEmpty } = await compileConsoleStylesheet(empty); + + // CONTROL: comparing two empty compiles proves nothing. + expect(fromEmpty.size).toBeGreaterThan(2500); + + const onlyFromApp = [...fromApp].filter((s) => !fromEmpty.has(s)).sort(); + expect( + onlyFromApp, + 'these rules come from Tailwind\'s automatic detection scanning apps/console ' + + '(prose such as CHANGELOG.md or docs/) — keep `source(none)` on the entry', + ).toEqual([]); + expect([...fromEmpty].filter((s) => !fromApp.has(s))).toEqual([]); + } finally { + await rm(empty, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/apps/console/src/index.css b/apps/console/src/index.css index 58f6701cc8..ab355edfe5 100644 --- a/apps/console/src/index.css +++ b/apps/console/src/index.css @@ -1,4 +1,21 @@ -@import 'tailwindcss'; +/* + * `source(none)` turns off Tailwind's AUTOMATIC source detection, so the only + * inputs are the `@source` lines below (objectui#11586). It is the same switch + * `packages/components/src/index.css` took in objectui#9569, and that file's + * header records the mechanism. + * + * Detection roots at the process CWD, which is this app's directory when it + * builds, and it scans every non-ignored file there, prose included. + * `pnpm changeset:version` writes each pending changeset body into + * `CHANGELOG.md`, so on the 17.7.0 release head the console sheet gained 11 + * rules (9,450 bytes) compiled from changelog prose. Among them was the + * sentinel of components' own scan test, which the second test in + * `index-css-scan-excludes-tests.test.ts` asserts never reaches this sheet. + * On `main` it gained `.w-100`, out of a proposal under `docs/`. + * Ablated on objectui#11586: every rule that only detection supplied came from + * a `.md` file, and none came from source. + */ +@import 'tailwindcss' source(none); @import '@object-ui/app-shell/styles.css'; /* Typography plugin — generates the `prose` utilities. plugin-markdown's diff --git a/packages/runner/src/__tests__/published-stylesheet-sources.test.ts b/packages/runner/src/__tests__/published-stylesheet-sources.test.ts index f6949bd00d..c58b04c5e7 100644 --- a/packages/runner/src/__tests__/published-stylesheet-sources.test.ts +++ b/packages/runner/src/__tests__/published-stylesheet-sources.test.ts @@ -45,8 +45,9 @@ * uses no matter which directory the suite was launched from. */ import { describe, expect, it } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import postcss from 'postcss'; import tailwindPostcss from '@tailwindcss/postcss'; @@ -87,8 +88,8 @@ function classesIn(selector: string): string[] { * package root is what makes this reading independent of where vitest started * (from the repo root the same bytes scan the whole monorepo instead). */ -async function compileClasses(css: string): Promise> { - const result = await postcss([tailwindPostcss({ base: PACKAGE_ROOT })]).process(css, { +async function compileClasses(css: string, base: string = PACKAGE_ROOT): Promise> { + const result = await postcss([tailwindPostcss({ base })]).process(css, { from: ENTRY, }); const classes = new Set(); @@ -177,4 +178,34 @@ describe('@object-ui/runner published stylesheet — source set', () => { expect(removed.length).toBeGreaterThan(0); expect([...shipped].filter((cls) => !withTestSources.has(cls))).toEqual([]); }); + + it('compiles the same classes from the package root as from an empty directory — no class comes from prose (objectui#11586)', async () => { + // `base` only changes what Tailwind's automatic detection scans. If the + // sheet is the same when detection would root at an empty directory, the + // `@source` lines supply every class, and this package's `CHANGELOG.md`, + // which `pnpm changeset:version` writes changeset prose into, adds none. + // Before `source(none)` it added `flex-shrink-0` and `isolate` on `main`, + // and on the 17.7.0 release head also `paused`, `invert` and `flex-nowrap`. + const empty = mkdtempSync(join(tmpdir(), 'objectui-11586-')); + try { + const fromEmpty = await compileClasses(entryCss, empty); + // CONTROL: comparing two empty compiles proves nothing. + expect(fromEmpty.has('bg-popover')).toBe(true); + expect([...shipped].filter((cls) => !fromEmpty.has(cls)).sort()).toEqual([]); + expect([...fromEmpty].filter((cls) => !shipped.has(cls)).sort()).toEqual([]); + } finally { + rmSync(empty, { recursive: true, force: true }); + } + }); + + it("does not compile a class that only this package's CHANGELOG names", () => { + // CONTROL: the door must still exist. `CHANGELOG.md` is append-only, so the + // entry announcing the `flex-shrink-0` → `shrink-0` rename keeps naming it. + // If this goes red, re-point the probe at a token the file still carries. + // Do not delete it. + expect(readFileSync(resolve(PACKAGE_ROOT, 'CHANGELOG.md'), 'utf8')).toContain('flex-shrink-0'); + // The deprecated Tailwind v3 alias. No shipped source emits it, and it was + // in this sheet only because detection read that changelog entry. + expect(shipped.has('flex-shrink-0')).toBe(false); + }); }); diff --git a/packages/runner/src/index.css b/packages/runner/src/index.css index dd5f1a187a..eeedf05a92 100644 --- a/packages/runner/src/index.css +++ b/packages/runner/src/index.css @@ -1,4 +1,17 @@ -@import 'tailwindcss'; +/* + * `source(none)` turns off Tailwind's AUTOMATIC source detection, so the only + * inputs are the `@source` lines below (objectui#11586). It is the same switch + * `packages/components/src/index.css` took in objectui#9569. + * + * Detection roots at `packages/runner`, where `pnpm build` runs, and scans every + * non-ignored file there, `CHANGELOG.md` included. `pnpm changeset:version` + * writes each pending changeset body into that file. On the 17.7.0 release head, + * detection compiled `paused`, `invert` and `flex-nowrap` back into this sheet + * from changelog prose. Those are three of the test-sourced classes the + * `@source not` lines below exist to keep out. On `main` it compiled + * `flex-shrink-0` and `isolate` the same way. + */ +@import 'tailwindcss' source(none); /* Class-based dark variant: follow `.dark` on /ancestor instead of the OS `prefers-color-scheme`. Inlined here because @object-ui/runner @@ -24,10 +37,9 @@ * That covers runner's own tree and nothing else, so the utilities its * dependencies need were simply missing from the published bundle: fixing the * four sibling paths takes it from 228 to 1456 compiled selectors (21 kB -> - * 136 kB). The automatic root is still on (there is no `source(none)` here, and - * that parity question is objectui#8455), which is why the first line below is - * a no-op today and still belongs here: it is the declared intent, and it is - * what keeps runner's own source scanned the day the root moves. + * 136 kB). Detection is now off (`source(none)` above, objectui#11586), so the + * first line below is load-bearing. It is the only thing that scans runner's + * own source. */ @source './**/*.{ts,tsx}'; @source '../../components/src/**/*.{ts,tsx}'; @@ -41,9 +53,8 @@ * not become a published byte. Same two-line spelling as * `packages/plugin-kanban/src/index.css`, anchored one level up because this * entry scans four sibling packages instead of only its own tree — `../../` - * bases these on `packages/`, which contains every tree named above AND the - * automatic-detection root, so runner's own tests are covered by the same two - * lines (measured: negations do apply to files the automatic root found). + * bases these on `packages/`, which contains every tree named above, runner's + * own included, so runner's own tests are covered by the same two lines. * * Both lines earn their place. The file line removes nine test-sourced classes * (`flex-grow`, `flex-nowrap`, `h-[125px]`, `h-[400px]`, `invert`, `paused`, diff --git a/packages/types/src/__tests__/base-bind-declared.test.ts b/packages/types/src/__tests__/base-bind-declared.test.ts index 871a601404..c65a698749 100644 --- a/packages/types/src/__tests__/base-bind-declared.test.ts +++ b/packages/types/src/__tests__/base-bind-declared.test.ts @@ -209,12 +209,18 @@ describe('`bind` is declared in exactly one place (objectui#6357)', () => { // have been the wrong repair — it puts a permanent hole in the scan at the // one path most likely to grow a copy of the pattern. const PATTERN = 'bind' + '?:'; + // Every `CHANGELOG.md` is excluded. `pnpm changeset:version` writes changeset + // prose into them, and a changeset that reports a local declaration being + // REMOVED quotes the pattern. On the 17.7.0 release head that made the + // plugin-dashboard and types CHANGELOGs read as two new declarations + // (objectui#11586). A CHANGELOG declares nothing. + const EXCLUDE_CHANGELOGS = ':!*CHANGELOG.md'; let out: string; try { out = execFileSync( 'git', - ['grep', '-n', '-F', '--', PATTERN, 'packages'], + ['grep', '-n', '-F', '--', PATTERN, 'packages', EXCLUDE_CHANGELOGS], { cwd: REPO_ROOT, encoding: 'utf8' }, ); } catch (err: any) { diff --git a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts index c674ecd0e6..b70a05095d 100644 --- a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts +++ b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -393,6 +393,14 @@ const GREP_ENGINE_FLAG = '-P'; /** The scanned tree. `examples/` is in it because an example app is exactly * where a fourth reader would plausibly appear. */ const SCAN_ROOTS = ['packages', 'apps', 'examples'] as const; +/** Every `CHANGELOG.md` is outside the scanned tree. `pnpm changeset:version` + * writes each pending changeset body into one, and the changeset for a + * retirement names the key it retires. Without this exclusion the release + * commit itself reads as five new readers of the array, which is what turned + * the 17.7.0 release head red (objectui#11586). A CHANGELOG is history, not + * code. The sibling censuses exclude it with the same spelling + * (`ai-insights-retired-8800.test.ts`). */ +const SCAN_EXCLUDES = [':!*CHANGELOG.md'] as const; /** The engine, for the diagnostics below: a failure that says "the anchor is * dead" is only actionable if it also says WHICH git read it. */ @@ -414,7 +422,7 @@ const gitGrepFiles = (args: readonly string[]): string[] => { let out: string; let failure: Failure | undefined; try { - out = execFileSync('git', ['grep', ...args, '--', ...SCAN_ROOTS], { + out = execFileSync('git', ['grep', ...args, '--', ...SCAN_ROOTS, ...SCAN_EXCLUDES], { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], From 3aaf78bd3a938dc195009170c4e18d8d16cfed6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:50:00 +0000 Subject: [PATCH 2/6] =?UTF-8?q?test(types):=20cite=20eventable-schema-reti?= =?UTF-8?q?red-6497=20for=20the=20CHANGELOG=20exclusion=20=E2=80=94=20the?= =?UTF-8?q?=20retired=208800=20spelling=20must=20appear=20nowhere=20(objec?= =?UTF-8?q?tui#11586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census comment added in the previous commit named the retired symbol's own pin by file name. objectui#8800's spelling census allows that spelling in nothing but its listed exclusions, so the citation tripped it. The comment now cites a sibling census that uses the same `:!*CHANGELOG.md` spelling. Claude-Session: https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL Co-authored-by: Claude --- .../src/__tests__/handler-keys-string-any-mirrors-7344.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts index b70a05095d..735a8ca1e5 100644 --- a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts +++ b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -399,7 +399,7 @@ const SCAN_ROOTS = ['packages', 'apps', 'examples'] as const; * commit itself reads as five new readers of the array, which is what turned * the 17.7.0 release head red (objectui#11586). A CHANGELOG is history, not * code. The sibling censuses exclude it with the same spelling - * (`ai-insights-retired-8800.test.ts`). */ + * (`eventable-schema-retired-6497.test.ts`). */ const SCAN_EXCLUDES = [':!*CHANGELOG.md'] as const; /** The engine, for the diagnostics below: a failure that says "the anchor is From 29d4646245bd442b025c75c3bab6a42bc21a6925 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 02:02:29 +0000 Subject: [PATCH 3/6] =?UTF-8?q?chore(scripts):=20adjudicate=20the=20two=20?= =?UTF-8?q?tests=20objectui#11586=20taught=20to=20name=20a=20CHANGELOG=20?= =?UTF-8?q?=E2=80=94=20the=20runner=20pin=20reads=20it=20as=20a=20control,?= =?UTF-8?q?=20the=207344=20census=20only=20excludes=20it=20(objectui#11586?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `markdown-test-inputs` audits every test that resolves a markdown path. This change made two tests new candidates: - `published-stylesheet-sources.test.ts` reads `packages/runner/CHANGELOG.md` to prove its prose-only probe still exists. It gets the same entry as the components scan test (objectui#9569). - `handler-keys-string-any-mirrors-7344.test.ts` reads no markdown. Its only `.md` literal is the `:!*CHANGELOG.md` pathspec, and its walk lists the zod mirror sources. Claude-Session: https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL Co-authored-by: Claude --- scripts/markdown-test-inputs.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index f07616711b..3c5dedfc41 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -446,6 +446,17 @@ export const ADJUDICATED = new Map([ 'packages/plugin-markdown/src/toc-anchor-parity.test.tsx', { reads: ['content/docs/utilities/runner.mdx'] }, ], + // objectui#11586. This reads a CHANGELOG as a CONTROL, the same shape as the + // components entry above. The test asserts that the published sheet lacks a + // class only that prose names, and it reads the file to prove the prose still + // names it. + [ + 'packages/runner/src/__tests__/published-stylesheet-sources.test.ts', + { + reads: ['packages/runner/CHANGELOG.md'], + notRead: ['CHANGELOG.md'], + }, + ], [ 'packages/types/src/__tests__/action-callback-retired-7068.test.ts', { @@ -522,6 +533,16 @@ export const ADJUDICATED = new Map([ // objectui#10872 batch 4. `taughtActionButton()` extracts the first json fence // under the quick-start's "### Add Actions" heading -- the taught // `action:button` node -- and judges it on both zod faces, in a page and alone. + // objectui#11586. Reads no markdown. Its only `.md` literal is the + // `:!*CHANGELOG.md` pathspec that keeps every CHANGELOG OUT of its `git grep` + // census. + [ + 'packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts', + { + reads: [], + walker: 'not-markdown: lists the zod mirror sources in `packages/types/src/zod`', + }, + ], [ 'packages/types/src/__tests__/held-public-block-arms-10872.test.ts', { reads: ['content/docs/guide/quick-start.md'] }, From dff2a160a65b07ff309c70a2840bcbf9c09b7976 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 04:48:25 +0000 Subject: [PATCH 4/6] =?UTF-8?q?chore(changeset):=20drop=20the=2011586=20ch?= =?UTF-8?q?angeset=20the=20merge=20carried=20back=20=E2=80=94=20release=20?= =?UTF-8?q?#5400=20already=20consumed=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch's earlier PR (#11587) was squash-merged and its changeset was consumed by the 17.7.0 release commit (b493919c7, #5400). Merging origin/main into this branch kept the branch-side copy, which would re-announce an already-published change in the next release. Claude-Session: https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL Co-authored-by: Claude --- ...11586-tailwind-source-none-console-runner.md | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .changeset/11586-tailwind-source-none-console-runner.md diff --git a/.changeset/11586-tailwind-source-none-console-runner.md b/.changeset/11586-tailwind-source-none-console-runner.md deleted file mode 100644 index e86b7652ac..0000000000 --- a/.changeset/11586-tailwind-source-none-console-runner.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@object-ui/console': patch -'@object-ui/runner': patch ---- - -The console and runner stylesheets compile only from their declared `@source` -lines. Tailwind's automatic source detection is now off (`source(none)`), as it -already was for `@object-ui/components`. - -With detection on, Tailwind also scanned prose in each package's directory, and -`CHANGELOG.md` was part of it. A release writes every changeset body into that -file, so the release itself added utilities to the published CSS that no -component uses. On the 17.7.0 release head, that was 11 rules (about 9 kB) in the -console sheet and 5 in the runner sheet. Both sheets now compile to the same -rules wherever the build runs. The only rules dropped against the previous -build came from prose: one from a docs proposal in the console and two from -the runner's changelog. From 8b0a5072897ee65eaf7ace061989b4a697cbaaa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:10:18 +0000 Subject: [PATCH 5/6] =?UTF-8?q?feat(scripts):=20port=20release-github-rele?= =?UTF-8?q?ases.mjs=20from=20objectstack=20=E2=80=94=20one=20GitHub=20Rele?= =?UTF-8?q?ase=20per=20public=20package,=20bodies=20truncated=20to=20the?= =?UTF-8?q?=20API=20limit,=20plus=20a=20tag=20list=20for=20the=20push=20st?= =?UTF-8?q?ep=20(objectui#11596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from objectstack's scripts/release-github-releases.mjs (at 7d0781482): idempotent create-or-update by tag, convergence on a racing writer's 422 already_exists, a duplicate-Release audit that never deletes, and every body truncated to the Releases API's 125,000-character limit with a link to the full CHANGELOG entry. Adapted to this workspace: - package enumeration reads pnpm-workspace.yaml through the existing workspacePackageDirs (check-side-effects-array.mjs); private packages (the vscode extension, the site, the examples) are never targets; - `--print-tags` prints the `@` tags the release owes, for the workflow's tag-push step; - an empty release set is an error, not a quiet no-op; - `--dry-run` prints the planned tag -> Release list with body sizes and calls no API. The self-test's oversized repro is real: @object-ui/types' 17.7.0 entry. scripts/__tests__/release-github-releases.test.ts runs the self-test and the dry-run (no token, unreachable API URL) and pins the workflow wiring the next commit adds. Claude-Session: https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL Co-authored-by: Claude --- .../__tests__/release-github-releases.test.ts | 294 +++ scripts/release-github-releases.mjs | 1939 +++++++++++++++++ 2 files changed, 2233 insertions(+) create mode 100644 scripts/__tests__/release-github-releases.test.ts create mode 100644 scripts/release-github-releases.mjs diff --git a/scripts/__tests__/release-github-releases.test.ts b/scripts/__tests__/release-github-releases.test.ts new file mode 100644 index 0000000000..920e5b7661 --- /dev/null +++ b/scripts/__tests__/release-github-releases.test.ts @@ -0,0 +1,294 @@ +/** + * objectui#11596 — the publish lane pushes the version's git tags and creates its + * GitHub Releases itself, because `changesets/action@v1` no longer does. + * + * v1 pushes a tag and creates a Release only for the packages whose + * `New tag: @` line it parses from the publish script's stdout, and + * `@changesets/cli` v3 prints none. 17.6.0 and 17.7.0 reached npm with no tag and + * no Release while the run stayed green. Two steps of + * `.github/workflows/changeset-release.yml` now do that half of the action's + * `runPublish`, after the npm check: "Push the release tags" and "Create GitHub + * Releases", the second running `scripts/release-github-releases.mjs` (ported from + * objectstack). + * + * What this file pins, and why each half lives here: + * + * 1. The script's own logic — body truncation, idempotency, race convergence, + * the release set and the tag list — is its `--self-test`, which carries a + * battery floor of its own. This file runs it, so `pnpm test` fails when it + * does; nothing else in CI runs it. + * 2. The two steps' LANE SCOPING. A publish-lane step reachable from the + * `schedule` refresh lane, a Release step that runs `--dry-run` (exit 0, + * released nothing), or a Release step ahead of the tag push (the API then + * creates every tag itself, lightweight) are each a green run that is + * wrong. Workflow files are read by nothing else in this repository, so + * each property is asserted here, and each assertion is shown able to fail + * by a mutation that is itself asserted to have applied. + * + * ⛔ Nothing here calls the GitHub API or creates a tag. `--dry-run` is exercised + * with no token and an unreachable API URL, so a regression that made it write + * would fail rather than write. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { parse as parseYaml } from 'yaml'; + +import { repoRoot, workflowDir } from './workflow-checks'; + +const SCRIPT = path.join(repoRoot, 'scripts/release-github-releases.mjs'); +const workflowSource = fs.readFileSync(path.join(workflowDir, 'changeset-release.yml'), 'utf8'); + +const PUBLISH = 'Publish to npm'; +const VERIFY = 'Verify the release reached npm'; +const PUSH_TAGS = 'Push the release tags'; +const CREATE_RELEASES = 'Create GitHub Releases (bodies truncated to the API limit)'; + +const RELEASE_VERSION_SOURCE = '${{ needs.lane.outputs.manifest_version }}'; + +interface Step { + name?: string; + id?: string; + if?: string; + run?: string; + uses?: string; + env?: Record; +} + +interface Workflow { + permissions?: Record; + jobs: Record; steps?: Step[] }>; +} + +/** The `if:` text with the `${{ }}` wrapper and runs of whitespace normalised away. */ +function condition(step: Step | undefined): string { + return String(step?.if ?? '') + .replace(/^\s*\$\{\{\s*/, '') + .replace(/\s*\}\}\s*$/, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Every way the two steps can stop meaning "push this version's tags and create its + * Releases, on the publish lane only". Empty means the wiring holds. + */ +function wiringProblems(source: string): string[] { + const workflow = parseYaml(source) as Workflow; + const job = workflow.jobs?.release; + const steps = job?.steps ?? []; + const problems: string[] = []; + const indexOf = (name: string) => steps.findIndex((s) => s.name === name); + const stepNamed = (name: string) => steps.find((s) => s.name === name); + + for (const name of [PUBLISH, VERIFY, PUSH_TAGS, CREATE_RELEASES]) { + const count = steps.filter((s) => s.name === name).length; + if (count !== 1) problems.push(`the release job has ${count} step(s) named "${name}", not exactly one`); + } + if (problems.length) return problems; + + const order = [PUBLISH, VERIFY, PUSH_TAGS, CREATE_RELEASES].map(indexOf); + if (!order.every((at, i) => i === 0 || at > order[i - 1])) { + problems.push( + `steps run out of order (${[PUBLISH, VERIFY, PUSH_TAGS, CREATE_RELEASES].join(' -> ')} expected, ` + + `got positions ${order.join(', ')}): the tags must be pushed after the npm check and BEFORE the ` + + 'Releases, or the API creates each tag itself as a lightweight tag', + ); + } + + const publishId = stepNamed(PUBLISH)?.id; + const verifyId = stepNamed(VERIFY)?.id; + if (!publishId || !verifyId) { + problems.push(`the publish and npm-check steps need ids for the conditions to read (got ${publishId} / ${verifyId})`); + return problems; + } + + for (const name of [PUSH_TAGS, CREATE_RELEASES]) { + const step = stepNamed(name); + const cond = condition(step); + const required = [ + "github.event_name == 'push'", + '!cancelled()', + `steps.${publishId}.outcome == 'success'`, + `steps.${verifyId}.outcome == 'success'`, + ]; + for (const clause of required) { + if (!cond.includes(clause)) problems.push(`"${name}" if: is missing \`${clause}\` (got \`${cond}\`)`); + } + // Every clause is ANDed. An `||` — or a lane named at all — opens a second way in. + for (const forbidden of ['||', 'schedule', 'workflow_dispatch', 'always()']) { + if (cond.includes(forbidden)) { + problems.push(`"${name}" if: contains \`${forbidden}\`, which can reach it from outside the publish lane`); + } + } + if (step?.env?.RELEASE_VERSION !== RELEASE_VERSION_SOURCE) { + problems.push( + `"${name}" must take RELEASE_VERSION from ${RELEASE_VERSION_SOURCE} — the version the npm check ` + + `confirmed (got ${step?.env?.RELEASE_VERSION})`, + ); + } + } + + const pushRun = String(stepNamed(PUSH_TAGS)?.run ?? ''); + if (!pushRun.includes('node scripts/release-github-releases.mjs --print-tags')) { + problems.push('"Push the release tags" no longer reads the tag list from `release-github-releases.mjs --print-tags`'); + } + const pushes = pushRun.split('\n').filter((l) => /\bgit\s+push\b/.test(l)); + if (pushes.length !== 1) problems.push(`"Push the release tags" should push exactly once (found ${pushes.length})`); + for (const line of pushes) { + if (/--force|\s-f\b|--delete|\s-d\b|\bHEAD\b|--tags|--mirror|--all/.test(line)) { + problems.push(`"Push the release tags" pushes more than this version's tag refs: \`${line.trim()}\``); + } + } + + const releases = stepNamed(CREATE_RELEASES); + if (String(releases?.run ?? '').trim() !== 'node scripts/release-github-releases.mjs') { + problems.push( + `"Create GitHub Releases" must run the script bare — \`--dry-run\` and \`--self-test\` both exit 0 having ` + + `released nothing (got \`${releases?.run}\`)`, + ); + } + if (releases?.env?.GITHUB_TOKEN !== '${{ secrets.GITHUB_TOKEN }}') { + problems.push(`"Create GitHub Releases" needs GITHUB_TOKEN from secrets (got ${releases?.env?.GITHUB_TOKEN})`); + } + + // A tag push and a Release create are both `contents: write`. + const contents = job?.permissions ? job.permissions.contents : workflow.permissions?.contents; + if (contents !== 'write') { + problems.push(`the release job's effective \`contents\` permission is ${contents}, not write`); + } + + // The job guard is what keeps a version that is ALREADY on npm from reaching these steps. + const jobIf = String(job?.if ?? '').replace(/\s+/g, ' '); + if (!jobIf.includes("(github.event_name == 'push' && needs.lane.outputs.version_on_npm == 'false')")) { + problems.push('the release job no longer limits `push` to a version that is not on npm yet'); + } + + return problems; +} + +describe('the ported script passes its own self-test', () => { + it('reaches its verdict with every battery at or above its floor', () => { + const out = execFileSync(process.execPath, [SCRIPT, '--self-test'], { encoding: 'utf8' }); + expect(out).toMatch(/^✓ release-github-releases --self-test: \d+ assertions/m); + }, 60_000); +}); + +describe('--dry-run plans without calling the API', () => { + it('plans a Release for every tag the version owes, with no token and an unreachable API', () => { + // The anchor's manifest version: the release `main` currently declares, whose + // CHANGELOG entries `changeset version` wrote for every package of the group. + const version = JSON.parse(fs.readFileSync(path.join(repoRoot, 'packages/core/package.json'), 'utf8')).version; + const env: NodeJS.ProcessEnv = { + ...process.env, + RELEASE_VERSION: version, + GITHUB_REPOSITORY: 'objectstack-ai/objectui', + GITHUB_SHA: 'deadbeef', + GITHUB_API_URL: 'http://127.0.0.1:9', + }; + delete env.GITHUB_TOKEN; + delete env.GH_TOKEN; + delete env.PUBLISHED; + + const tags = execFileSync(process.execPath, [SCRIPT, '--print-tags'], { encoding: 'utf8', env }) + .trim() + .split('\n'); + const out = execFileSync(process.execPath, [SCRIPT, '--dry-run'], { encoding: 'utf8', env }); + + expect(tags.length, 'the tag list is empty, so the comparison below would compare nothing').toBeGreaterThan(0); + expect(tags.every((t) => t.endsWith(`@${version}`))).toBe(true); + const planned = out + .split('\n') + .filter((l) => l.includes('\t')) + .map((l) => l.split('\t')[0]); + // A public package that ships no CHANGELOG.md yet (a new one, before its first + // release) is tagged but gets no Release; the script says so on a `skipped` line. + const skipped = [...out.matchAll(/^skipped {2}(\S+) ships no CHANGELOG\.md$/gm)].map((m) => `${m[1]}@${version}`); + expect([...planned, ...skipped].sort()).toEqual([...tags].sort()); + expect(out).toMatch(new RegExp(`^${tags.length} release\\(s\\) planned .*, 0 failed to plan\\.$`, 'm')); + expect(out).not.toMatch(/^(created|updated) /m); + }, 60_000); +}); + +describe('changeset-release.yml pushes the tags and creates the Releases on the publish lane only', () => { + it('holds every wiring property on the real workflow', () => { + expect(wiringProblems(workflowSource)).toEqual([]); + }); +}); + +describe('the wiring pin can fail (non-vacuity)', () => { + // Each mutation is asserted to have APPLIED before its result is read — a no-op + // edit would leave the real workflow under test and report a green that means + // nothing. + function mutate(from: string, to: string, source = workflowSource): string { + const mutated = source.replace(from, to); + expect(mutated, `mutation did not apply: ${from}`).not.toBe(source); + return mutated; + } + + const PUSH_IF = + " - name: Push the release tags\n if: >-\n ${{ !cancelled() && github.event_name == 'push'"; + const RELEASE_IF = + " - name: Create GitHub Releases (bodies truncated to the API limit)\n if: >-\n ${{ !cancelled() && github.event_name == 'push'"; + + it('goes red when a step loses its event test', () => { + const mutated = mutate(PUSH_IF, PUSH_IF.replace(" && github.event_name == 'push'", '')); + expect(wiringProblems(mutated).join('\n')).toContain(`"${PUSH_TAGS}" if: is missing \`github.event_name == 'push'\``); + }); + + it('goes red when a step is opened to the refresh lane with an `||`', () => { + const mutated = mutate( + RELEASE_IF, + RELEASE_IF.replace("github.event_name == 'push'", "(github.event_name == 'push' || github.event_name == 'schedule')"), + ); + const problems = wiringProblems(mutated).join('\n'); + expect(problems).toContain(`"${CREATE_RELEASES}" if: contains \`||\``); + expect(problems).toContain(`"${CREATE_RELEASES}" if: contains \`schedule\``); + }); + + it('goes red when `!cancelled()` is weakened to `always()`', () => { + const mutated = mutate(RELEASE_IF, RELEASE_IF.replace('!cancelled()', 'always()')); + const problems = wiringProblems(mutated).join('\n'); + expect(problems).toContain(`"${CREATE_RELEASES}" if: is missing \`!cancelled()\``); + expect(problems).toContain(`"${CREATE_RELEASES}" if: contains \`always()\``); + }); + + it('goes red when the Release step runs --dry-run', () => { + const mutated = mutate( + ' run: node scripts/release-github-releases.mjs\n', + ' run: node scripts/release-github-releases.mjs --dry-run\n', + ); + expect(wiringProblems(mutated).join('\n')).toContain('must run the script bare'); + }); + + it('goes red when the version comes from anywhere but the lane output', () => { + const at = workflowSource.indexOf(` - name: ${CREATE_RELEASES}`); + const tail = mutate( + `RELEASE_VERSION: ${RELEASE_VERSION_SOURCE}`, + 'RELEASE_VERSION: ${{ github.ref_name }}', + workflowSource.slice(at), + ); + expect(wiringProblems(workflowSource.slice(0, at) + tail).join('\n')).toContain( + `"${CREATE_RELEASES}" must take RELEASE_VERSION from`, + ); + }); + + it('goes red when the Releases are created before the tags are pushed', () => { + const workflow = parseYaml(workflowSource) as Workflow; + const steps = workflow.jobs.release.steps ?? []; + const push = steps.findIndex((s) => s.name === PUSH_TAGS); + const releases = steps.findIndex((s) => s.name === CREATE_RELEASES); + expect(push, 'fixture: both steps exist').toBeGreaterThan(-1); + expect(releases).toBe(push + 1); + [steps[push], steps[releases]] = [steps[releases], steps[push]]; + const swapped = JSON.stringify(workflow); + expect(swapped).not.toBe(JSON.stringify(parseYaml(workflowSource))); + expect(wiringProblems(swapped).join('\n')).toContain('steps run out of order'); + }); + + it('goes red when the tag push widens to every local tag', () => { + const mutated = mutate('git push origin "${refs[@]}"', 'git push origin --tags'); + expect(wiringProblems(mutated).join('\n')).toContain('pushes more than this version\'s tag refs'); + }); +}); diff --git a/scripts/release-github-releases.mjs b/scripts/release-github-releases.mjs new file mode 100644 index 0000000000..083b5ea381 --- /dev/null +++ b/scripts/release-github-releases.mjs @@ -0,0 +1,1939 @@ +#!/usr/bin/env node +// ported from objectstack@7d0781482dbb6502aa33c29bec96ca03636f7df9 scripts/release-github-releases.mjs +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +/** + * release-github-releases.mjs — create (or update) one GitHub Release per + * published package, with a body that CANNOT exceed the Releases API's + * 125,000-character limit. + * + * ## Why this exists in this repository (objectui#11596) + * + * `.github/workflows/changeset-release.yml` publishes through + * `changesets/action@v1`, and v1 learns WHAT was published by parsing the + * publish script's stdout for `New tag: @` lines. Only for the + * packages it finds there does it push the git tag (`git.pushTag`) and create + * the GitHub Release (`runPublish` in the action's `src/run.ts`, v1.9.0). + * + * objectui#5296 moved this repository to `@changesets/cli` v3, whose + * `changeset publish` reports through `@clack/prompts` and an output report + * instead, and prints no `New tag` line at all. v1 therefore saw zero + * published packages, pushed no tag and created no Release — and failed + * nothing, because the publish lane's npm check reads npm only. 17.6.0 and + * 17.7.0 reached npm with neither. + * + * `changeset publish` still CREATES the tags on the runner; the workflow's + * "Push the release tags" step pushes them (using `--print-tags` below for the + * set it owes), and its "Create GitHub Releases" step runs this script. Neither + * depends on the action's stdout parse any more. + * + * ## Why the body limit matters here too (objectstack#4900) + * + * The action posted each package's raw CHANGELOG section as the Release body, + * and the Releases API rejects a body over 125,000 characters: + * + * HttpError: Validation Failed: + * {"resource":"Release","code":"custom","field":"body", + * "message":"body is too long (maximum is 125000 characters)"} + * + * objectstack hit that first, which is why it wrote this script. This + * repository hit it too, unnoticed: measured when this port was written, + * `@object-ui/app-shell`'s 17.5.0 section was 158,203 characters, and + * `@object-ui/app-shell@17.5.0` has a tag but no Release while its 38 + * siblings have both. The 17.7.0 sections are larger still — + * `packages/types/CHANGELOG.md`'s is past a million characters. ⛔ Those are + * one day's readings, not live figures: the self-test below re-measures the + * types section on every run and prints it. + * + * ## Contract + * + * Faithful to what `changesets/action` produced, minus the failure: same tag + * (`@`), same release name, same `prerelease` rule, and the same + * changelog-entry body — extracted with a direct port of the action's own + * `getChangelogEntry`, so an under-limit body is byte-identical to what the + * action would have posted. + * + * Beyond that it adds the properties the action's version lacked: + * + * - **Bounded.** A body over the limit is truncated at a line boundary, with + * an unbalanced code fence closed so the notice renders as markdown, and a + * link to the full entry in CHANGELOG.md at this exact commit. + * - **Idempotent.** Looks the release up by tag first: PATCH when it exists, + * POST when it does not. A re-run is a no-op-shaped update, never an + * `already_exists` 422 — which matters because a partial failure is the + * normal state to recover from, and because the backfill of 17.6.0 and + * 17.7.0 runs this same script by hand. + * - **Safe under a concurrent writer.** The read and the POST are two + * requests, and nothing excludes a second writer of one version's + * Releases: a re-run of the workflow, a hand-run backfill, or the + * action's own Release creation should its stdout parse ever find a + * package again (it is dormant under CLI v3, not removed). objectstack + * measured the race on its 17.6.0: nine POSTs answered + * `422 {"code":"already_exists","field":"tag_name"}` because the other + * writer created the release between the read and the POST. That 422 now + * means what it says — the release exists — so the script re-reads it by + * tag and PATCHes it, and two writers converge on one release per tag. Any + * OTHER 422 (the body limit above) still fails the release exactly as + * before. + * - **Duplicates are reported, never deleted.** The same race proved the API + * will ACCEPT two creates for one tag inside one second. No request + * sequence on this side can prevent that, so after its writes the script + * reads the Releases list and names every tag it released that has more + * than one. A duplicate this run CREATED one of fails the run + * (`::error::`) — the race was this run's, and without the failure the + * convergence above would turn a red race into a green run with a + * duplicate in it. A duplicate that predates this run's writes is a + * `::warning::`: it was the creating run's to fail. Either way the report + * names each Release id, its assets and the id the by-tag endpoint + * resolves to. Deleting a Release is a release act and the maintainer's + * call — this script never sends a DELETE. + * - **Per-package isolation.** The action ran the whole set through one + * `Promise.all`, so the first rejection abandoned the rest. This runs them + * sequentially, collects failures, and still exits non-zero — one + * package's oversized changelog can no longer cost its siblings their + * releases. + * + * Sequential is also deliberate for the writes: objectstack#2191 is the + * standing lesson that bursts of concurrent ref-creating requests race + * GitHub's backend. + * + * ⚠️ A POST for a tag that is NOT on the remote makes the API create it — a + * lightweight tag at `target_commitish` (this run's `GITHUB_SHA`). That is why + * the workflow pushes the annotated tags `changeset publish` created BEFORE + * this script runs, and why a run that could not push them still gets its + * Releases on the commit it published from, rather than none. + * + * Run: + * node scripts/release-github-releases.mjs # from changeset-release.yml + * node scripts/release-github-releases.mjs --self-test # verify the logic + * node scripts/release-github-releases.mjs --dry-run # plan + sizes only, no API call + * node scripts/release-github-releases.mjs --print-tags # the `@` tags owed, one per line + * + * Env: + * RELEASE_VERSION the version the publish lane shipped. Every publishable + * workspace package is released at this version (the + * Changesets `fixed` group bumps in lockstep). This is the + * input changeset-release.yml passes. + * PUBLISHED a changesets/action `publishedPackages` JSON; takes + * precedence when non-empty. Kept from the port, and NOT + * passed by this repository's workflow: under CLI v3 the + * action's parse reports `[]`, which is this card's defect. + * GITHUB_TOKEN repo-scoped token with `contents: write`. + * GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_API_URL / GITHUB_SERVER_URL + * standard Actions context. A hand-run backfill sets + * GITHUB_SHA to the commit the version was PUBLISHED from. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from './invoked-as.mjs'; +import { workspacePackageDirs } from './check-side-effects-array.mjs'; + +// ── The self-test's own battery roster and floor (objectstack#13489) ─────── +// +// `failures.length === 0` used to be this self-test's ONLY success condition, so +// "every case held" and "the cases never ran" printed the same line. What is +// pinned is the registered NAMES, not a number. Every section opens with +// `battery('')`, every assertion is attributed to the battery most +// recently opened, and the floor requires the OPENED set to equal the DECLARED +// set with each battery at or above its own count. +// +// ⛔ A pinned TOTAL is not the repair: a battery dropping from 9 cases to 3 +// keeps a total "right" the moment a sibling grows. +// +// The counts are a FLOOR, not an equality — adding cases is ordinary work and +// must not red. A battery BELOW its floor means cases stopped running; the +// remedy is to find what stopped registering. +const SELF_TEST_BATTERIES = Object.freeze({ + '1. The real oversized entry: types\' 17.7.0 section': 9, + '2. A short entry is passed through untouched': 3, + '3. Fence balancing when the cut lands inside a code block': 3, + '4. Surrogate pairs are never split': 3, + '5. Anchors and fence-aware heading parsing': 7, + '6. Target resolution: both producers, and neither': 9, + '7. Planning is per package, and a missing entry is loud': 6, + '8. Every package gets a release; existing ones are updated, not retried ─': 7, + '9. Idempotent re-run': 2, + '10. One package\'s failure does not abandon the others': 3, + '11. A racing writer\'s create converges instead of failing (422 already_exists)': 8, + '12. Two concurrent invocations leave exactly one release per tag': 6, + '13. Any other 422 still fails; a racer the read cannot see fails loudly, bounded': 8, + '14. Duplicate releases for one tag are reported, never deleted': 14, + '15. The tags the push step owes are exactly the release set': 5, +}); + +// DELETING an entry silences that battery's floor exactly as effectively as +// zeroing it, so the roster's own size is pinned too. +const SELF_TEST_BATTERY_FLOOR = 15; + +// The key an assertion is filed under when no battery is open. It is not a +// declared battery, so it reds by the same set difference rather than silently +// inflating whichever battery happened to run last. +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..'); + +/** + * The GitHub Releases API's documented maximum body length, as quoted verbatim + * in the 422 that objectstack#4900 is about: "body is too long (maximum is 125000 + * characters)". + */ +export const BODY_LIMIT = 125_000; + +/** + * Headroom held back from the limit. Absorbs the closing fence `balanceFences` + * may append and any accounting slip; the caller asserts the final body against + * the real limit regardless, so this only decides how far under we land. + */ +const SAFETY_MARGIN = 1_024; + +/** + * How many characters a string costs against the limit. + * + * GitHub counts CHARACTERS, not bytes — objectstack's failing spec section was + * 342,910 characters but 359,636 UTF-8 bytes, and the API quoted the former. We measure + * with JS `.length` (UTF-16 code units), which is >= the code-point count for + * every string and equal for everything outside the astral planes. So it can + * only ever over-estimate the cost, never under-estimate it, whichever of the + * two "character" definitions the API applies. + * + * @param {string} text + * @returns {number} + */ +export function measure(text) { + return text.length; +} + +/** + * Extract the changelog section for `version`. + * + * Direct port of `getChangelogEntry` from changesets/action's `src/utils.ts` + * (v1), including its code-fence skipping — a changeset body can legitimately + * contain `##` headings, and this repo's do (`## FROM → TO` inside migration + * notes). Ported rather than imported: the action is a bundled GitHub Action, + * not an npm dependency of this repo. + * + * @param {string} changelog full CHANGELOG.md text + * @param {string} version exact version string, e.g. `17.0.0-rc.2` + * @returns {string | null} the section content (trimmed), or null when absent + */ +export function getChangelogEntry(changelog, version) { + /** @type {{ index: number; depth: number } | undefined} */ + let headingStartInfo; + /** @type {number | undefined} */ + let endIndex; + + const regex = /^(#{1,6})\s(.*)$|^(`{3,})/gm; + /** @type {RegExpExecArray | null} */ + let match; + while ((match = regex.exec(changelog)) != null) { + // Skip over code blocks so headings inside them never match. + if (match[3]) { + const endOfCodeBlockRegex = new RegExp(`^${match[3]}`, 'gm'); + endOfCodeBlockRegex.lastIndex = regex.lastIndex; + const endMatch = endOfCodeBlockRegex.exec(changelog); + if (endMatch) { + regex.lastIndex = endOfCodeBlockRegex.lastIndex; + continue; + } + break; // unterminated fence — malformed changelog + } + + const headingDepth = match[1].length; + const headingText = match[2].trim(); + + if (headingText === version) { + headingStartInfo = { index: regex.lastIndex, depth: headingDepth }; + continue; + } + + if (headingStartInfo && headingDepth === headingStartInfo.depth) { + endIndex = match.index; + break; + } + } + + if (!headingStartInfo) return null; + return changelog.slice(headingStartInfo.index, endIndex).trim(); +} + +/** + * GitHub's heading slug for an in-file anchor: lowercase, drop punctuation, + * spaces to hyphens. `17.0.0-rc.2` -> `1700-rc2`. + * + * Cross-checked against `github-slugger` (the implementation GitHub's own + * renderer uses) over the version shapes this can see — `17.0.0-rc.2`, + * `16.1.0`, `17.0.0`, `1.2.3-beta.10` — which all agree. Kept as four lines + * rather than a dependency: the input domain here is semver strings, not + * arbitrary heading text. + * + * @param {string} text + * @returns {string} + */ +export function headingAnchor(text) { + return text + .trim() + .toLowerCase() + .replace(/[^\w\- ]+/g, '') + .replace(/ /g, '-'); +} + +/** + * Slice without ever splitting a surrogate pair — a lone surrogate is not valid + * UTF-8 and would be mangled or rejected on the way to the API. + * + * @param {string} text + * @param {number} max in UTF-16 code units + * @returns {string} + */ +export function sliceSafely(text, max) { + if (text.length <= max) return text; + let end = Math.max(0, max); + const last = text.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end -= 1; // high surrogate: drop it + return text.slice(0, end); +} + +/** + * Close a code fence left open by truncation. Without this the notice and the + * CHANGELOG link render INSIDE the code block — i.e. the one thing truncation + * owes the reader is exactly what gets swallowed. + * + * @param {string} text + * @returns {string} + */ +export function balanceFences(text) { + /** @type {string | null} */ + let open = null; + for (const line of text.split('\n')) { + const m = /^ {0,3}(`{3,}|~{3,})/.exec(line); + if (!m) continue; + const marker = m[1]; + if (open === null) open = marker; + else if (marker[0] === open[0] && marker.length >= open.length) open = null; + } + return open === null ? text : `${text}\n${open}`; +} + +/** + * Build the Release body for one package: the changelog entry when it fits, a + * truncated entry framed by a notice and a link to the complete one when it + * does not. + * + * @param {object} opts + * @param {string} opts.entry changelog section content + * @param {string} opts.tagName e.g. `@object-ui/types@17.7.0` + * @param {string} opts.changelogLabel repo-relative path shown to the reader + * @param {string} opts.changelogHref link to the full entry + * @param {number} [opts.limit] + * @returns {{ body: string; truncated: boolean; originalLength: number }} + */ +export function buildReleaseBody({ entry, tagName, changelogLabel, changelogHref, limit = BODY_LIMIT }) { + const originalLength = measure(entry); + if (originalLength <= limit) { + return { body: entry, truncated: false, originalLength }; + } + + const n = (v) => v.toLocaleString('en-US'); + const link = `[\`${changelogLabel}\`](${changelogHref})`; + + const notice = + [ + '> [!IMPORTANT]', + `> **This release note is truncated.** The changelog entry for \`${tagName}\` is`, + `> ${n(originalLength)} characters; the GitHub Releases API rejects any body over`, + `> ${n(limit)}. The complete entry is in ${link}.`, + '', + '---', + '', + ].join('\n') + '\n'; + + const footer = `\n\n---\n\n**Truncated here.** The rest of this entry is in ${link}.\n`; + + const budget = limit - measure(notice) - measure(footer) - SAFETY_MARGIN; + if (budget <= 0) { + throw new Error(`release body limit ${limit} is too small to hold even the truncation notice`); + } + + // Cut on a line boundary so markdown structures stay whole where possible. + /** @type {string[]} */ + const kept = []; + let used = 0; + for (const line of entry.split('\n')) { + const cost = kept.length === 0 ? measure(line) : measure(line) + 1; + if (used + cost > budget) break; + kept.push(line); + used += cost; + } + + // A single line longer than the whole budget still has to be cut somewhere. + let head = kept.length > 0 ? kept.join('\n') : sliceSafely(entry, budget); + head = balanceFences(head.replace(/\s+$/, '')); + + const body = notice + head + footer; + if (measure(body) > limit) { + throw new Error(`truncation produced ${measure(body)} characters, over the ${limit} limit`); + } + return { body, truncated: true, originalLength }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Workspace discovery +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Every non-private workspace package, by name. + * + * Membership comes from `workspacePackageDirs` in + * `scripts/check-side-effects-array.mjs` — an existing parse of + * `pnpm-workspace.yaml` that THROWS on a glob shape it does not understand, + * rather than a new private copy of that parse. The objectstack original reads + * objectstack's own shared enumerator, which this repository does not have. + * + * `private: true` is the same line `changeset publish` draws: a private + * package is never published, and `.changeset/config.json` sets + * `privatePackages.tag: false`, so it never gets a tag either. Here that is + * `object-ui` (`packages/vscode-extension`), a member of the `fixed` group that + * is versioned with it but released nowhere. + * + * @param {string} [root] + * @returns {Map} + */ +export function listWorkspacePackages(root = REPO_ROOT) { + /** @type {Map} */ + const byName = new Map(); + for (const dir of workspacePackageDirs(root)) { + let manifest; + try { + manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + } catch { + continue; // a workspace glob also matches directories that are not packages + } + if (!manifest.name || manifest.private === true) continue; + if (byName.has(manifest.name)) continue; + byName.set(manifest.name, { name: manifest.name, version: manifest.version, dir }); + } + return byName; +} + +/** + * The tag `changeset publish` creates for one package, and the name its + * Release takes: `@`. + * + * @param {{ name: string; version: string }} target + * @returns {string} + */ +export function tagNameOf(target) { + return `${target.name}@${target.version}`; +} + +/** + * Decide which `@` releases this run owes. + * + * `PUBLISHED` (the action's own `publishedPackages`) is authoritative when + * present. `RELEASE_VERSION` is the input this repository's workflow passes, + * because the action's `publishedPackages` is exactly what CLI v3 emptied (see + * the header): the release set is the whole publishable workspace at one + * version — correct here precisely because the Changesets `fixed` group bumps + * every public package in lockstep (`scripts/check-changeset-fixed.mjs` is the + * gate that keeps it true). + * + * @param {object} opts + * @param {string} [opts.publishedJson] + * @param {string} [opts.releaseVersion] + * @param {Map} opts.packages + * @returns {{ name: string; version: string; dir: string }[]} + */ +export function resolveReleaseTargets({ publishedJson, releaseVersion, packages }) { + const trimmed = (publishedJson ?? '').trim(); + if (trimmed && trimmed !== '[]') { + /** @type {{ name: string; version: string }[]} */ + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch (err) { + throw new Error(`PUBLISHED is not valid JSON: ${err instanceof Error ? err.message : err}`); + } + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('PUBLISHED parsed to an empty or non-array value'); + } + return parsed.map(({ name, version }) => { + const pkg = packages.get(name); + if (!pkg) throw new Error(`published package "${name}" is not in the workspace`); + return { name, version, dir: pkg.dir }; + }); + } + + if (releaseVersion) { + return [...packages.values()].map((pkg) => ({ ...pkg, version: releaseVersion })); + } + + return []; +} + +/** + * Assemble the Release payload for one target. Returns null (with a reason) + * when the package ships no CHANGELOG.md — changesets/action skips those too. + * + * @param {object} opts + * @param {{ name: string; version: string; dir: string }} opts.target + * @param {string} opts.serverUrl + * @param {string} opts.repository `owner/repo` + * @param {string} opts.ref commit-ish for the CHANGELOG permalink + * @param {string} [opts.root] + * @returns {{ tagName: string; body: string; prerelease: boolean; truncated: boolean; originalLength: number } | { skipped: string }} + */ +export function planRelease({ target, serverUrl, repository, ref, root = REPO_ROOT }) { + const tagName = tagNameOf(target); + const changelogPath = join(target.dir, 'CHANGELOG.md'); + let changelog; + try { + changelog = readFileSync(changelogPath, 'utf8'); + } catch { + return { skipped: `${target.name} ships no CHANGELOG.md` }; + } + + const entry = getChangelogEntry(changelog, target.version); + if (entry === null) { + throw new Error(`no changelog entry for ${tagName} in ${relative(root, changelogPath)}`); + } + + const changelogLabel = relative(root, changelogPath).split('\\').join('/'); + const changelogHref = `${serverUrl}/${repository}/blob/${ref}/${changelogLabel}#${headingAnchor(target.version)}`; + + const { body, truncated, originalLength } = buildReleaseBody({ + entry, + tagName, + changelogLabel, + changelogHref, + }); + + return { + tagName, + body, + // Same rule as changesets/action: any version carrying a prerelease tag. + prerelease: target.version.includes('-'), + truncated, + originalLength, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// GitHub API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Thrown by `create` when the POST answered `422` because a Release for the tag + * already exists — `{"code":"already_exists","field":"tag_name"}` in the + * response's `errors[]`, exactly as objectstack's 17.6.0 runs logged it. Nothing else + * raises it: a 422 for any other field or code stays a plain failure. + */ +export class ReleaseAlreadyExistsError extends Error { + /** + * @param {string} tagName + * @param {string} detail the API's answer, for the log + */ + constructor(tagName, detail) { + super(`POST release ${tagName} failed: ${detail}`); + this.name = 'ReleaseAlreadyExistsError'; + this.tagName = tagName; + } +} + +/** + * Whether a failed POST's answer is the Releases API saying the tag already + * has a Release. Judged on the structured `errors[]` entry, never on prose. + * + * @param {number} status + * @param {string} text the response body + * @returns {boolean} + */ +export function isTagAlreadyExists(status, text) { + if (status !== 422) return false; + let parsed; + try { + parsed = JSON.parse(text); + } catch { + return false; + } + const errors = Array.isArray(parsed?.errors) ? parsed.errors : []; + return errors.some((e) => e && e.code === 'already_exists' && e.field === 'tag_name'); +} + +/** + * Thin Releases client. `fetchImpl` is injected so the self-test drives the + * real request/response handling without a network. + * + * @param {object} opts + * @param {string} opts.apiUrl + * @param {string} opts.repository `owner/repo` + * @param {string} opts.token + * @param {typeof fetch} [opts.fetchImpl] + */ +export function createReleasesClient({ apiUrl, repository, token, fetchImpl = fetch }) { + const base = `${apiUrl}/repos/${repository}/releases`; + const headers = { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'x-github-api-version': '2022-11-28', + }; + + const readText = async (res) => { + try { + return await res.text(); + } catch { + return ''; /* body already consumed or unreadable */ + } + }; + const describe = (res, text) => `${res.status} ${res.statusText || ''} ${text.slice(0, 500)}`.trim(); + const readError = async (res) => describe(res, await readText(res)); + + return { + /** + * Look a release up by tag. The tag contains `/` and `@`; both must be + * percent-encoded to survive the path segment (verified against the live + * API: `%40object-ui%2Fcore%4017.5.0` resolves). + * + * @param {string} tagName + * @returns {Promise<{ id: number } | null>} + */ + async findByTag(tagName) { + const res = await fetchImpl(`${base}/tags/${encodeURIComponent(tagName)}`, { headers }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`GET release by tag ${tagName} failed: ${await readError(res)}`); + return await res.json(); + }, + + /** + * @param {{ tagName: string; body: string; prerelease: boolean; targetCommitish: string }} rel + * @throws {ReleaseAlreadyExistsError} when the tag already has a Release + */ + async create({ tagName, body, prerelease, targetCommitish }) { + const res = await fetchImpl(base, { + method: 'POST', + headers, + body: JSON.stringify({ + tag_name: tagName, + name: tagName, + body, + prerelease, + target_commitish: targetCommitish, + }), + }); + if (!res.ok) { + const text = await readText(res); + if (isTagAlreadyExists(res.status, text)) throw new ReleaseAlreadyExistsError(tagName, describe(res, text)); + throw new Error(`POST release ${tagName} failed: ${describe(res, text)}`); + } + return await res.json(); + }, + + /** + * One page of the repository's Releases, newest first. The list orders by + * `created_at`, which for a Release on a pushed tag is the TAG's date — + * measured on objectstack's 17.6.0: all 74 Release objects carry 02:49:42Z, the tagger + * date `changeset publish` wrote, while their `published_at` spans two + * minutes — so one version's Releases sit in one contiguous run of the + * list, duplicates beside their twins. + * + * @param {{ page: number; perPage: number }} opts + * @returns {Promise<{ id: number; tag_name: string; assets?: { name: string }[] }[]>} + */ + async listPage({ page, perPage }) { + const res = await fetchImpl(`${base}?per_page=${perPage}&page=${page}`, { headers }); + if (!res.ok) throw new Error(`GET releases page ${page} failed: ${await readError(res)}`); + return await res.json(); + }, + + /** + * @param {{ id: number; tagName: string; body: string; prerelease: boolean }} rel + */ + async update({ id, tagName, body, prerelease }) { + const res = await fetchImpl(`${base}/${id}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ name: tagName, body, prerelease }), + }); + if (!res.ok) throw new Error(`PATCH release ${tagName} failed: ${await readError(res)}`); + return await res.json(); + }, + }; +} + +/** + * After a `422 already_exists`, how long to wait before each by-tag re-read. + * The 422 proves the other writer's Release is in the store; the first read is + * immediate, and the two later ones absorb a read path that has not caught up + * with that write yet. Bounded: a Release the by-tag read still cannot see + * after the last one fails that package loudly rather than retrying forever. + */ +export const RACE_REREAD_DELAYS_MS = Object.freeze([0, 1_000, 3_000]); + +/** + * Create or update every release in `plans`, sequentially, isolating failures. + * + * A POST that answers `422 already_exists` lost a race with a concurrent + * writer (see the header): the Release it meant to create now exists, so the + * package converges by re-reading it by tag and PATCHing it, and is counted + * as updated (and in `converged`), not failed. + * + * @param {object} opts + * @param {ReturnType} opts.client + * @param {{ tagName: string; body: string; prerelease: boolean; truncated: boolean; originalLength: number }[]} opts.plans + * @param {string} opts.targetCommitish + * @param {(msg: string) => void} [opts.log] + * @param {(ms: number) => Promise} [opts.sleep] injected so the self-test does not wait + * @param {readonly number[]} [opts.rereadDelaysMs] + * @returns {Promise<{ created: string[]; updated: string[]; converged: string[]; createdIds: number[]; failed: { tagName: string; error: string }[] }>} + */ +export async function publishReleases({ + client, + plans, + targetCommitish, + log = console.log, + sleep = (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)), + rereadDelaysMs = RACE_REREAD_DELAYS_MS, +}) { + const created = []; + const updated = []; + const converged = []; + /** @type {number[]} */ + const createdIds = []; + const failed = []; + + for (const plan of plans) { + const size = plan.truncated + ? `truncated ${plan.originalLength} -> ${measure(plan.body)} chars` + : `${measure(plan.body)} chars`; + try { + const existing = await client.findByTag(plan.tagName); + if (existing) { + await client.update({ id: existing.id, ...plan }); + updated.push(plan.tagName); + log(`updated ${plan.tagName} (${size})`); + continue; + } + let release; + try { + release = await client.create({ ...plan, targetCommitish }); + } catch (err) { + if (!(err instanceof ReleaseAlreadyExistsError)) throw err; + // Another writer created this tag's Release between the read above and + // this POST. It exists, so this package's job is now an update of it. + let racing = null; + for (const delay of rereadDelaysMs) { + if (delay > 0) await sleep(delay); + racing = await client.findByTag(plan.tagName); + if (racing) break; + } + if (!racing) { + throw new Error( + `${err.message} — the tag already has a Release, but ${rereadDelaysMs.length} by-tag ` + + 're-read(s) still answered 404, so there is nothing this run can update.', + ); + } + await client.update({ id: racing.id, ...plan }); + updated.push(plan.tagName); + converged.push(plan.tagName); + log( + `updated ${plan.tagName} (${size}) — a concurrent writer created release ${racing.id} between ` + + "this run's read and its POST (422 already_exists); converged onto it", + ); + continue; + } + created.push(plan.tagName); + if (release && typeof release.id === 'number') createdIds.push(release.id); + log(`created ${plan.tagName} (${size})`); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + failed.push({ tagName: plan.tagName, error }); + log(`::error::GitHub Release for ${plan.tagName} failed: ${error}`); + } + } + + return { created, updated, converged, createdIds, failed }; +} + +/** Releases per list page the duplicate audit asks for — the API's maximum. */ +export const AUDIT_PER_PAGE = 100; + +/** + * How many list pages the duplicate audit reads at most: a thousand Releases, + * about fourteen versions of 69 packages back from the newest. + */ +export const AUDIT_MAX_PAGES = 10; + +/** + * Find every tag in `tagNames` that carries more than one Release object. + * + * Reads the Releases list newest first. One version's Releases are one + * contiguous run of that list (see `listPage`), so the walk stops at the first + * page holding none of `tagNames` after one that did, or at the end of the + * list, and is `complete` only when it stopped for one of those two reasons. + * Every tag it never saw is returned in `unseen`: the audit cannot vouch for + * those. Read-only — it never deletes, and nothing in this file does. + * + * @param {object} opts + * @param {ReturnType} opts.client + * @param {string[]} opts.tagNames + * @param {number[]} [opts.createdIds] ids this run's own POSTs created + * @param {number} [opts.perPage] + * @param {number} [opts.maxPages] + * @returns {Promise<{ + * duplicates: { tagName: string; releases: { id: number; assets: string[] }[]; byTagId: number | null; createdByThisRun: number[] }[]; + * unseen: string[]; + * pagesRead: number; + * complete: boolean; + * }>} + */ +export async function auditDuplicateReleases({ + client, + tagNames, + createdIds = [], + perPage = AUDIT_PER_PAGE, + maxPages = AUDIT_MAX_PAGES, +}) { + const wanted = new Set(tagNames); + /** @type {Map} */ + const byTag = new Map(); + let seenAny = false; + let complete = false; + let pagesRead = 0; + + for (let page = 1; page <= maxPages; page += 1) { + const releases = await client.listPage({ page, perPage }); + pagesRead += 1; + let hits = 0; + for (const rel of Array.isArray(releases) ? releases : []) { + if (!wanted.has(rel.tag_name)) continue; + hits += 1; + const list = byTag.get(rel.tag_name) ?? []; + list.push({ id: rel.id, assets: (rel.assets ?? []).map((a) => a.name) }); + byTag.set(rel.tag_name, list); + } + if (hits > 0) seenAny = true; + else if (seenAny) { + complete = true; // walked past this version's run of the list + break; + } + if (!Array.isArray(releases) || releases.length < perPage) { + complete = true; // the end of the list + break; + } + } + + const ours = new Set(createdIds); + const duplicates = []; + for (const tagName of tagNames) { + const releases = byTag.get(tagName); + if (!releases || releases.length < 2) continue; + releases.sort((a, b) => a.id - b.id); + // Which one `gh release view` / `gh release upload` resolve the tag to, + // and so where an asset uploaded by tag lands: the report's cleanup anchor. + let byTagId = null; + try { + byTagId = (await client.findByTag(tagName))?.id ?? null; + } catch { + byTagId = null; + } + duplicates.push({ + tagName, + releases, + byTagId, + createdByThisRun: releases.filter((r) => ours.has(r.id)).map((r) => r.id), + }); + } + + return { duplicates, unseen: tagNames.filter((t) => !byTag.has(t)), pagesRead, complete }; +} + +/** + * Turn an audit into the lines `main` prints: `errors` fail the run, + * `warnings` do not. A duplicate this run created one of is an error; one that + * predates this run's writes is a warning (the header says why). + * + * @param {Awaited>} audit + * @returns {{ errors: string[]; warnings: string[] }} + */ +export function describeDuplicates(audit) { + const errors = []; + const warnings = []; + for (const d of audit.duplicates) { + const list = d.releases + .map((r) => `${r.id} (${r.assets.length ? `assets: ${r.assets.join(', ')}` : 'no assets'})`) + .join(', '); + const anchor = + d.byTagId === null + ? 'The by-tag endpoint could not be read, so which one `gh release view` resolves the tag to is unknown.' + : `The by-tag endpoint — what \`gh release view\` and \`gh release upload\` resolve the tag to, and so ` + + `where an asset uploaded by tag is attached — answers ${d.byTagId}; the other(s), ` + + `${d.releases.filter((r) => r.id !== d.byTagId).map((r) => r.id).join(', ')}, are the surplus.`; + const head = `${d.tagName} has ${d.releases.length} GitHub Releases: ${list}. ${anchor}`; + const tail = + 'This script never deletes a Release; deleting the surplus is a release act and the maintainer\'s call.'; + if (d.createdByThisRun.length > 0) { + errors.push( + `${head} This run created ${d.createdByThisRun.join(', ')}: the Releases API accepted a second create ` + + `for one tag from a concurrent writer. ${tail}`, + ); + } else { + warnings.push( + `${head} None of them was created by this run, so it is reported, not failed: the run that created ` + + `them was the place to fail. ${tail}`, + ); + } + } + if (audit.unseen.length > 0 || !audit.complete) { + const unseen = audit.unseen.length + ? `${audit.unseen.length} tag(s) this run released never appeared in it (${audit.unseen.slice(0, 5).join(', ')}` + + `${audit.unseen.length > 5 ? ', …' : ''})` + : `it stopped at its ${audit.pagesRead}-page cap before walking past this version's Releases`; + warnings.push( + `the duplicate-Release audit read ${audit.pagesRead} page(s) of the Releases list and ${unseen}; ` + + 'whether those tags carry more than one Release is UNVERIFIED by this run.', + ); + } + return { errors, warnings }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Entry point +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The release set this invocation owes, from the environment. ⛔ Never empty: + * the objectstack original treated "no input" as a quiet no-op, but here the + * only caller is the publish lane, which always passes `RELEASE_VERSION` — so + * an empty set means the input was lost on the way, and a step that reported + * success over nothing would be objectui#11596's silent failure one step later. + * + * @returns {{ name: string; version: string; dir: string }[]} + */ +function targetsFromEnv() { + const targets = resolveReleaseTargets({ + publishedJson: process.env.PUBLISHED, + releaseVersion: process.env.RELEASE_VERSION, + packages: listWorkspacePackages(), + }); + if (targets.length === 0) { + throw new Error( + 'neither PUBLISHED nor RELEASE_VERSION names a release, so there is no tag and no Release to ' + + 'make. Refusing to report success over nothing (objectui#11596).', + ); + } + return targets; +} + +/** + * `--print-tags`: the `@` tags the release set owes, one per + * line — what the workflow's "Push the release tags" step pushes. No CHANGELOG + * read and no API call: a package that ships no CHANGELOG.md gets no Release + * (`planRelease` skips it) but `changeset publish` still tags it, so it is + * listed here. + */ +function printTags() { + for (const target of targetsFromEnv()) console.log(tagNameOf(target)); +} + +async function main({ dryRun = false } = {}) { + const repository = process.env.GITHUB_REPOSITORY; + if (!repository) throw new Error('GITHUB_REPOSITORY is required'); + const ref = process.env.GITHUB_SHA || 'main'; + const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'; + const apiUrl = process.env.GITHUB_API_URL || 'https://api.github.com'; + + const targets = targetsFromEnv(); + + /** @type {{ tagName: string; body: string; prerelease: boolean; truncated: boolean; originalLength: number }[]} */ + const plans = []; + /** @type {{ tagName: string; error: string }[]} */ + const planFailures = []; + for (const target of targets) { + try { + const plan = planRelease({ target, serverUrl, repository, ref }); + if ('skipped' in plan) { + console.log(`skipped ${plan.skipped}`); + continue; + } + plans.push(plan); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + planFailures.push({ tagName: tagNameOf(target), error }); + console.log(`::error::cannot build a release body for ${tagNameOf(target)}: ${error}`); + } + } + + if (dryRun) { + console.log(`dry run: no API call. ${repository}, CHANGELOG links at ${ref}. tag -> release body:`); + for (const plan of plans) { + console.log( + `${plan.tagName}\t${measure(plan.body)} chars${plan.truncated ? `\t(truncated from ${plan.originalLength})` : ''}` + + `${plan.prerelease ? '\tprerelease' : ''}`, + ); + } + const truncated = plans.filter((p) => p.truncated).length; + console.log( + `\n${plans.length} release(s) planned (${truncated} truncated to fit the ${BODY_LIMIT}-character limit), ` + + `${planFailures.length} failed to plan.`, + ); + if (planFailures.length) process.exit(1); + return; + } + + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (!token) throw new Error('GITHUB_TOKEN is required'); + + const client = createReleasesClient({ apiUrl, repository, token }); + const { created, updated, converged, createdIds, failed } = await publishReleases({ + client, + plans, + targetCommitish: ref, + }); + + // After the writes, never instead of them: a duplicate is made by a + // concurrent writer's create, and this run's last POST may be the one that + // completed the pair. + /** @type {{ errors: string[]; warnings: string[] }} */ + let duplicateReport = { errors: [], warnings: [] }; + try { + const audit = await auditDuplicateReleases({ client, tagNames: plans.map((p) => p.tagName), createdIds }); + duplicateReport = describeDuplicates(audit); + } catch (err) { + duplicateReport.warnings.push( + `could not read the Releases list to audit for duplicate Releases ` + + `(${err instanceof Error ? err.message : String(err)}); whether any tag this run released carries ` + + 'more than one Release is UNVERIFIED by this run.', + ); + } + for (const w of duplicateReport.warnings) console.log(`::warning::${w}`); + for (const e of duplicateReport.errors) console.log(`::error::${e}`); + + const truncatedCount = plans.filter((p) => p.truncated).length; + console.log( + `\n${created.length} created, ${updated.length} updated ` + + `(${converged.length} after a concurrent writer's create), ${failed.length + planFailures.length} failed, ` + + `${duplicateReport.errors.length} duplicated by this run ` + + `(${truncatedCount} body/bodies truncated to fit the ${BODY_LIMIT}-character limit).`, + ); + + const allFailures = [...planFailures, ...failed]; + if (allFailures.length || duplicateReport.errors.length) { + if (allFailures.length) console.error(`::error::${allFailures.length} GitHub Release(s) could not be published.`); + if (duplicateReport.errors.length) { + console.error( + `::error::${duplicateReport.errors.length} tag(s) carry more than one GitHub Release after this run's ` + + 'creates (named above). Nothing was deleted.', + ); + } + process.exit(1); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Self-test +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A `Response`-shaped stub, so the client's real request/response handling — + * status branching, error text, JSON decoding — is what gets exercised. + * + * @param {number} status + * @param {unknown} [payload] + */ +function stubResponse(status, payload) { + const text = JSON.stringify(payload ?? {}); + return { + status, + statusText: String(status), + ok: status >= 200 && status < 300, + async json() { + return JSON.parse(text); + }, + async text() { + return text; + }, + }; +} + +/** The tag date every stub Release of the version under test carries. */ +const STUB_TAG_DATE = '2026-10-02T02:49:42Z'; + +/** + * A stateful Releases store behind a `fetch`: records every call, answers the + * by-tag read, the list, POST and PATCH from the store, and enforces the tag's + * uniqueness the way the API usually does — a POST for a tag that already has + * a Release answers `422 already_exists`. + * + * @param {object} opts + * @param {Record} [opts.existing] tag -> release id, present before the run + * @param {Set} [opts.failCreateFor] POST answers the body-limit 422 (a non-race 422) + * @param {Set} [opts.raceCreateFor] a concurrent writer creates the tag's Release + * between this run's by-tag read and its POST + * @param {Set} [opts.acceptDuplicateCreateFor] the API accepts a second create for the + * tag instead of answering 422 — the same-second case objectstack's 17.6.0 measured + * @param {Map} [opts.hideAfterRace] the racing writer's Release stays invisible + * to this many by-tag reads after the race (Infinity: never visible to them) + * @param {{ id: number; tag_name: string; created_at: string; assets?: { name: string }[] }[]} [opts.seed] + * further Releases already in the store (older versions, pre-existing duplicates) + * @param {boolean} [opts.tick] yield a macrotask per request, so two concurrent runs interleave + */ +function stubFetch({ + existing = {}, + failCreateFor = new Set(), + raceCreateFor = new Set(), + acceptDuplicateCreateFor = new Set(), + hideAfterRace = new Map(), + seed = [], + tick = false, +} = {}) { + /** @type {{ method: string; url: string; body: any }[]} */ + const calls = []; + /** @type {{ id: number; tag_name: string; created_at: string; assets: { name: string }[]; by: string; name?: string; body?: string; prerelease?: boolean }[]} */ + const releases = []; + let nextId = 5000; + const insert = (tagName, by, extra = {}) => { + const rel = { id: nextId, tag_name: tagName, created_at: STUB_TAG_DATE, assets: [], by, ...extra }; + nextId += 1; + releases.push(rel); + return rel; + }; + for (const [tag, id] of Object.entries(existing)) releases.push({ id, tag_name: tag, created_at: STUB_TAG_DATE, assets: [], by: 'before the run' }); + for (const rel of seed) releases.push({ assets: [], by: 'seed', ...rel }); + const forTag = (tagName) => releases.filter((r) => r.tag_name === tagName).sort((a, b) => a.id - b.id); + const hidden = new Map(); + + const impl = async (url, init = {}) => { + if (tick) await new Promise((resolveTick) => setImmediate(resolveTick)); + const method = init.method ?? 'GET'; + const body = init.body ? JSON.parse(init.body) : undefined; + const u = String(url); + calls.push({ method, url: u, body }); + + if (method === 'GET') { + const byTag = /\/releases\/tags\/(.+)$/.exec(u); + if (byTag) { + const tag = decodeURIComponent(byTag[1]); + if ((hidden.get(tag) ?? 0) > 0) { + hidden.set(tag, hidden.get(tag) - 1); + return stubResponse(404, { message: 'Not Found' }); + } + // The lowest id — what the live by-tag endpoint answered for all three + // objectstack 17.6.0 duplicate pairs read while that script was written. + const [first] = forTag(tag); + return first ? stubResponse(200, first) : stubResponse(404, { message: 'Not Found' }); + } + const list = /\/releases\?per_page=(\d+)&page=(\d+)$/.exec(u); + if (list) { + const perPage = Number(list[1]); + const page = Number(list[2]); + const ordered = [...releases].sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id - a.id); + return stubResponse(200, ordered.slice((page - 1) * perPage, page * perPage)); + } + return stubResponse(404, { message: 'Not Found' }); + } + if (method === 'POST') { + const tag = body.tag_name; + if (failCreateFor.has(tag)) { + return stubResponse(422, { + message: 'Validation Failed', + errors: [{ resource: 'Release', code: 'custom', field: 'body', message: 'body is too long (maximum is 125000 characters)' }], + }); + } + if (raceCreateFor.has(tag) && forTag(tag).length === 0) { + insert(tag, 'racing writer'); + if (hideAfterRace.has(tag)) hidden.set(tag, hideAfterRace.get(tag)); + } + if (forTag(tag).length > 0 && !acceptDuplicateCreateFor.has(tag)) { + return stubResponse(422, { + message: 'Validation Failed', + errors: [{ resource: 'Release', code: 'already_exists', field: 'tag_name' }], + }); + } + return stubResponse(201, insert(tag, 'this run', { name: body.name, body: body.body, prerelease: body.prerelease })); + } + if (method === 'PATCH') { + const id = Number(/\/releases\/(\d+)$/.exec(u)?.[1]); + const rel = releases.find((r) => r.id === id); + if (!rel) return stubResponse(404, { message: 'Not Found' }); + Object.assign(rel, { name: body.name, body: body.body, prerelease: body.prerelease }); + return stubResponse(200, rel); + } + return stubResponse(405, { message: `stub: ${method} is not a request this script may send` }); + }; + return { impl, calls, releases, forTag }; +} + +// Returned by `selfTest()` only after its verdict is printed. The dispatch +// refuses anything else: a `return` that leaves the function above that line +// prints nothing and still exits 0 — a self-test that never finished, reported +// as one that passed (#13798). +const SELF_TEST_VERDICT = 'release-github-releases self-test reached its verdict'; + +async function selfTest() { + // The battery ledger this self-test's floor is evaluated against (#13489). + // `battery()` opens a battery; every assertion below is attributed to the one + // most recently opened, so a section that stops running stops registering and + // names ITSELF at the floor rather than going quiet. + const batterySeen = new Map(); + let openBattery = null; + const battery = (name) => { + openBattery = name; + }; + const registerCase = () => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); + }; + + /** @type {string[]} */ + const failures = []; + let assertions = 0; + const assert = (cond, msg) => { + registerCase(); + assertions += 1; + if (!cond) failures.push(msg); + }; + + const CTX = { + serverUrl: 'https://github.com', + repository: 'objectstack-ai/objectui', + ref: 'deadbeef', + }; + // The oversized repro is real, not synthetic: `@object-ui/types`'s 17.7.0 + // section, the largest entry in the release that had no Releases at all + // (objectui#11596). A CHANGELOG entry is history, so it stays the size it is. + const typesChangelog = readFileSync(join(REPO_ROOT, 'packages/types/CHANGELOG.md'), 'utf8'); + const coreChangelog = readFileSync(join(REPO_ROOT, 'packages/core/CHANGELOG.md'), 'utf8'); + + // ── 1. The real oversized entry: types' 17.7.0 section ───────────────────── + battery('1. The real oversized entry: types\' 17.7.0 section'); + const typesEntry = getChangelogEntry(typesChangelog, '17.7.0'); + assert(typesEntry !== null, 'the 17.7.0 entry is found in packages/types/CHANGELOG.md'); + assert( + typesEntry !== null && measure(typesEntry) > 1_000_000, + `types' 17.7.0 section is still the oversized repro (measured ${typesEntry === null ? 'n/a' : measure(typesEntry)}, expected >1,000,000)`, + ); + + const typesLabel = 'packages/types/CHANGELOG.md'; + const typesHref = `${CTX.serverUrl}/${CTX.repository}/blob/${CTX.ref}/${typesLabel}#1770`; + const big = buildReleaseBody({ + entry: typesEntry ?? '', + tagName: '@object-ui/types@17.7.0', + changelogLabel: typesLabel, + changelogHref: typesHref, + }); + assert(big.truncated, 'the oversized entry is reported as truncated'); + assert( + measure(big.body) <= BODY_LIMIT, + `the truncated body fits the API limit (got ${measure(big.body)}, limit ${BODY_LIMIT})`, + ); + assert(big.body.includes(typesHref), 'the truncated body links the full CHANGELOG entry, anchor included'); + assert(big.body.includes('truncated'), 'the truncated body says it was truncated'); + assert( + big.body.trimEnd().endsWith(`${typesLabel}\`](${typesHref}).`), + 'the closing pointer to CHANGELOG.md is the last thing in the body', + ); + assert( + big.body.split('\n').filter((l) => /^ {0,3}```/.test(l)).length % 2 === 0, + 'the truncated body leaves no code fence open', + ); + assert( + !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? `line ${i} ${'x'.repeat(44)}`), + '```ts', + ...Array.from({ length: 400 }, (_, i) => `const v${i} = ${'y'.repeat(40)};`), + '```', + ].join('\n'); + const fencedBuilt = buildReleaseBody({ + entry: fenced, + tagName: 'pkg@1.0.0', + changelogLabel: typesLabel, + changelogHref: typesHref, + limit: 4_000, + }); + assert(fencedBuilt.truncated, 'the fenced fixture is large enough to truncate'); + assert(measure(fencedBuilt.body) <= 4_000, 'the fenced fixture respects the limit it was given'); + assert( + fencedBuilt.body.split('\n').filter((l) => /^ {0,3}```/.test(l)).length % 2 === 0, + 'a cut inside a code fence is closed so the notice renders as markdown', + ); + + // ── 4. Surrogate pairs are never split ───────────────────────────────────── + battery('4. Surrogate pairs are never split'); + const astral = '🚀'.repeat(5_000); + const astralBuilt = buildReleaseBody({ + entry: astral, + tagName: 'pkg@1.0.0', + changelogLabel: typesLabel, + changelogHref: typesHref, + limit: 3_000, + }); + assert( + !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?= [...astral].length, 'measure() never under-counts against code points'); + + // ── 5. Anchors and fence-aware heading parsing ───────────────────────────── + // Expectations below are github-slugger's own output for these inputs. + battery('5. Anchors and fence-aware heading parsing'); + assert(headingAnchor('17.0.0-rc.2') === '1700-rc2', 'the version anchor matches GitHub heading slugs'); + assert(headingAnchor('16.1.0') === '1610', 'a stable version anchor drops its dots'); + assert(headingAnchor('17.7.0') === '1770', 'this repository\'s own release heading anchors as GitHub renders it'); + assert(headingAnchor('1.2.3-beta.10') === '123-beta10', 'a prerelease anchor keeps only its hyphen'); + const tricky = ['## 1.0.0', '', '```md', '## 0.9.0', '```', '', 'real content', '', '## 0.9.0', '', 'older'].join( + '\n', + ); + assert( + getChangelogEntry(tricky, '1.0.0')?.includes('real content') === true, + 'a `##` inside a fenced block does not end the entry', + ); + assert(getChangelogEntry(tricky, '0.9.0') === 'older', 'the following entry is still found after a fenced decoy'); + assert(getChangelogEntry(tricky, '2.0.0') === null, 'a missing version yields null rather than a wrong section'); + + // ── 6. Target resolution: both producers, and neither ────────────────────── + battery('6. Target resolution: both producers, and neither'); + const packages = listWorkspacePackages(); + assert(packages.has('@object-ui/core'), 'the workspace scan finds @object-ui/core'); + /** @type {Set} */ + const privateNames = new Set(); + for (const dir of workspacePackageDirs(REPO_ROOT)) { + try { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + if (manifest.private === true && manifest.name) privateNames.add(manifest.name); + } catch { + /* not a package directory */ + } + } + // `object-ui` (the vscode extension) is the control: a `fixed` member that is + // private, so a scan that admitted private packages would release it. + assert( + privateNames.has('object-ui') && [...privateNames].every((n) => !packages.has(n)), + `private packages are never release targets (private: ${[...privateNames].sort().join(', ')})`, + ); + // The release set is re-derived against a second source rather than pinned + // as a count: `.changeset/config.json`'s `fixed` group, minus its private + // members, IS the set `changeset publish` ships at one version. + const fixedConfig = JSON.parse(readFileSync(join(REPO_ROOT, '.changeset/config.json'), 'utf8')); + const fixedMembers = new Set((fixedConfig.fixed ?? []).flat()); + const fixedPublic = [...fixedMembers].filter((n) => !privateNames.has(n)).sort(); + assert( + fixedPublic.length > 0 && JSON.stringify([...packages.keys()].sort()) === JSON.stringify(fixedPublic), + `the release set is exactly the fixed group's public members, in both directions (workspace ${packages.size}, fixed-and-public ${fixedPublic.length})`, + ); + const fromPublished = resolveReleaseTargets({ + publishedJson: JSON.stringify([ + { name: '@object-ui/core', version: '17.7.0' }, + { name: '@object-ui/types', version: '17.7.0' }, + ]), + packages, + }); + assert(fromPublished.length === 2, 'PUBLISHED drives exactly the packages it lists'); + assert( + fromPublished.every((t) => typeof t.dir === 'string' && t.dir.length > 0), + 'each published package resolves to its workspace directory', + ); + const fromVersion = resolveReleaseTargets({ releaseVersion: '17.9.0', packages }); + assert( + fromVersion.length === packages.size && fromVersion.length > 0, + `RELEASE_VERSION covers the whole publishable workspace (got ${fromVersion.length})`, + ); + assert( + fromVersion.every((t) => t.version === '17.9.0'), + 'RELEASE_VERSION releases every package at the one fixed-group version', + ); + assert(resolveReleaseTargets({ packages }).length === 0, 'no input resolves to no targets, not a crash'); + assert( + resolveReleaseTargets({ publishedJson: '[]', releaseVersion: '1.2.3', packages }).length === packages.size, + 'an empty PUBLISHED array — what v1 reports under CLI v3 — falls through to RELEASE_VERSION', + ); + + // ── 7. Planning is per package, and a missing entry is loud ──────────────── + battery('7. Planning is per package, and a missing entry is loud'); + const typesPlan = planRelease({ + target: { name: '@object-ui/types', version: '17.7.0', dir: join(REPO_ROOT, 'packages/types') }, + ...CTX, + }); + assert('tagName' in typesPlan && typesPlan.tagName === '@object-ui/types@17.7.0', 'the tag is `@`'); + assert('truncated' in typesPlan && typesPlan.truncated === true, 'the types plan truncates'); + assert( + 'body' in typesPlan && measure(typesPlan.body) <= BODY_LIMIT, + 'the planned types body is within the API limit end to end', + ); + assert('prerelease' in typesPlan && typesPlan.prerelease === false, 'a GA version is not marked prerelease'); + // No CHANGELOG here carries a prerelease heading, so the rule is exercised on + // a fixture written to a throwaway directory and removed again. + const rcDir = mkdtempSync(join(tmpdir(), 'release-github-releases-')); + try { + writeFileSync(join(rcDir, 'CHANGELOG.md'), '# pkg\n\n## 1.0.0-rc.1\n\n- a change\n'); + const rcPlan = planRelease({ target: { name: '@object-ui/pkg', version: '1.0.0-rc.1', dir: rcDir }, ...CTX }); + assert('prerelease' in rcPlan && rcPlan.prerelease === true, 'an rc version is marked prerelease'); + } finally { + rmSync(rcDir, { recursive: true, force: true }); + } + let threw = false; + try { + planRelease({ + target: { name: '@object-ui/core', version: '99.99.99', dir: join(REPO_ROOT, 'packages/core') }, + ...CTX, + }); + } catch { + threw = true; + } + assert(threw, 'a version with no changelog entry fails loudly instead of releasing an empty body'); + + // ── 8. Every package gets a release; existing ones are updated, not retried ─ + battery('8. Every package gets a release; existing ones are updated, not retried ─'); + const plans = ['@object-ui/types', '@object-ui/core', '@object-ui/react'].map((name) => { + const plan = planRelease({ target: { name, version: '17.7.0', dir: packages.get(name).dir }, ...CTX }); + if (!('tagName' in plan)) throw new Error(`fixture package ${name} produced no plan`); + return plan; + }); + const mixed = stubFetch({ existing: { '@object-ui/core@17.7.0': 4242 } }); + const mixedResult = await publishReleases({ + client: createReleasesClient({ + apiUrl: 'https://api.github.com', + repository: CTX.repository, + token: 't', + fetchImpl: mixed.impl, + }), + plans, + targetCommitish: CTX.ref, + log: () => {}, + }); + assert( + mixedResult.created.length === 2 && mixedResult.updated.length === 1 && mixedResult.failed.length === 0, + `every package is released — 2 created, 1 updated (got ${mixedResult.created.length}/${mixedResult.updated.length}/${mixedResult.failed.length})`, + ); + assert( + mixedResult.updated[0] === '@object-ui/core@17.7.0', + 'the package that already had a release is the one updated', + ); + assert( + mixed.calls.some((c) => c.method === 'PATCH' && c.url.endsWith('/releases/4242')), + 'an existing release is PATCHed by id rather than re-POSTed', + ); + assert( + mixed.calls.filter((c) => c.method === 'POST').length === 2, + 'exactly the two absent releases are POSTed', + ); + assert( + mixed.calls.every((c) => c.method !== 'GET' || c.url.includes('%2F')), + 'the by-tag lookup percent-encodes the slash in a scoped package tag', + ); + assert( + mixed.calls + .filter((c) => c.method !== 'GET') + .every((c) => measure(c.body.body) <= BODY_LIMIT), + 'no request ever carries a body over the API limit', + ); + assert( + mixed.calls.filter((c) => c.method === 'POST').every((c) => c.body.target_commitish === CTX.ref), + 'a created release is pinned to the release commit', + ); + + // ── 9. Idempotent re-run ─────────────────────────────────────────────────── + battery('9. Idempotent re-run'); + const allExist = stubFetch({ + existing: Object.fromEntries(plans.map((p, i) => [p.tagName, 100 + i])), + }); + const rerun = await publishReleases({ + client: createReleasesClient({ + apiUrl: 'https://api.github.com', + repository: CTX.repository, + token: 't', + fetchImpl: allExist.impl, + }), + plans, + targetCommitish: CTX.ref, + log: () => {}, + }); + assert( + rerun.created.length === 0 && rerun.updated.length === 3 && rerun.failed.length === 0, + 're-running against fully-created releases updates all and fails none', + ); + assert( + allExist.calls.every((c) => c.method !== 'POST'), + 'a re-run never POSTs, so it cannot hit `already_exists`', + ); + + // ── 10. One package's failure does not abandon the others ────────────────── + battery('10. One package\'s failure does not abandon the others'); + const partial = stubFetch({ failCreateFor: new Set(['@object-ui/core@17.7.0']) }); + const partialResult = await publishReleases({ + client: createReleasesClient({ + apiUrl: 'https://api.github.com', + repository: CTX.repository, + token: 't', + fetchImpl: partial.impl, + }), + plans, + targetCommitish: CTX.ref, + log: () => {}, + }); + assert( + partialResult.created.length === 2 && partialResult.failed.length === 1, + 'a rejected release does not stop the remaining packages (the Promise.all defect)', + ); + assert( + partialResult.created.includes('@object-ui/types@17.7.0'), + '@object-ui/types still gets its release — a sibling\'s failure costs it nothing', + ); + assert( + partialResult.failed[0].error.includes('422'), + 'the failure carries the API status through to the log', + ); + + const clientOver = (stub) => + createReleasesClient({ apiUrl: 'https://api.github.com', repository: CTX.repository, token: 't', fetchImpl: stub.impl }); + const noSleep = async () => {}; + const [typesTag, coreTag] = plans.map((p) => p.tagName); + + // ── 11. A racing writer's create converges instead of failing ─────────── + // objectstack's 17.6.0 shape: this run reads the tag (404), the other writer's create + // lands, this run's POST answers 422 already_exists on tag_name. + battery('11. A racing writer\'s create converges instead of failing (422 already_exists)'); + const raced = stubFetch({ raceCreateFor: new Set([coreTag]) }); + /** @type {string[]} */ + const racedLog = []; + const racedResult = await publishReleases({ + client: clientOver(raced), + plans, + targetCommitish: CTX.ref, + log: (m) => racedLog.push(m), + sleep: noSleep, + }); + const [racer] = raced.forTag(coreTag); + assert( + raced.calls.some((c) => c.method === 'POST' && c.body.tag_name === coreTag), + 'the fixture really raced: this run POSTed the tag the other writer created first', + ); + assert( + racedResult.failed.length === 0, + `a 422 already_exists from a racing writer fails no release (got ${racedResult.failed.length}: ${racedResult.failed.map((f) => f.error).join(' | ')})`, + ); + assert( + racedResult.converged.length === 1 && racedResult.converged[0] === coreTag, + `the raced tag is reported as converged (got ${JSON.stringify(racedResult.converged)})`, + ); + assert( + racedResult.created.length === 2 && racedResult.updated.length === 1 && racedResult.updated[0] === coreTag, + 'the two unraced tags are created and the raced one is counted as an update', + ); + assert( + raced.calls.some((c) => c.method === 'PATCH' && c.url.endsWith(`/releases/${racer?.id}`)), + "it converges by PATCHing the racing writer's own release, found by a by-tag re-read", + ); + assert( + plans.every((p) => raced.forTag(p.tagName).length === 1), + 'the run converges to exactly one release per tag', + ); + assert( + racer?.body === plans[1].body && racer?.by === 'racing writer', + "the racing writer's release now carries this run's body", + ); + assert( + racedLog.some((l) => l.includes(coreTag) && l.includes('422 already_exists') && l.includes(String(racer?.id))), + 'the log names the convergence, the 422 and the release it converged onto', + ); + + // ── 12. Two concurrent invocations leave exactly one release per tag ──── + // Both writers over ONE store, interleaved request by request — a workflow re-run's + // publish-job step and push-lane backfill, minus the clock. + battery('12. Two concurrent invocations leave exactly one release per tag'); + const shared = stubFetch({ tick: true }); + const writer = () => + publishReleases({ client: clientOver(shared), plans, targetCommitish: CTX.ref, log: () => {}, sleep: noSleep }); + const [writerA, writerB] = await Promise.all([writer(), writer()]); + assert( + writerA.failed.length + writerB.failed.length === 0, + `neither concurrent writer fails a release (got ${writerA.failed.length} + ${writerB.failed.length}: ` + + `${[...writerA.failed, ...writerB.failed].map((f) => f.error).join(' | ')})`, + ); + assert( + plans.every((p) => shared.forTag(p.tagName).length === 1), + `two concurrent invocations leave exactly one release per tag (got ${plans.map((p) => shared.forTag(p.tagName).length).join('/')})`, + ); + assert( + writerA.converged.length + writerB.converged.length > 0, + 'the two invocations really raced — at least one POST answered 422 already_exists (otherwise this battery proves nothing)', + ); + assert( + writerA.created.length + writerB.created.length === plans.length, + 'across both writers every tag is created exactly once', + ); + assert( + writerA.created.length + writerA.updated.length === plans.length && + writerB.created.length + writerB.updated.length === plans.length, + 'each writer accounts for every tag as created or updated', + ); + assert( + shared.calls.every((c) => c.method !== 'DELETE'), + 'converging never deletes anything', + ); + + // ── 13. Any other 422 still fails; an invisible racer fails loudly ────── + battery('13. Any other 422 still fails; a racer the read cannot see fails loudly, bounded'); + const tooLong = stubFetch({ failCreateFor: new Set([coreTag]) }); + const tooLongResult = await publishReleases({ + client: clientOver(tooLong), + plans, + targetCommitish: CTX.ref, + log: () => {}, + sleep: noSleep, + }); + assert( + tooLongResult.failed.length === 1 && + tooLongResult.failed[0].tagName === coreTag && + tooLongResult.failed[0].error.includes('422') && + tooLongResult.failed[0].error.includes('"field":"body"'), + 'the body-limit 422 still fails that package, with the API answer in the error', + ); + assert(tooLongResult.converged.length === 0, 'the body-limit 422 is not mistaken for a race'); + assert( + tooLong.calls.filter((c) => c.method === 'GET' && c.url.includes(encodeURIComponent(coreTag))).length === 1 && + tooLong.calls.every((c) => c.method !== 'PATCH'), + 'the body-limit 422 triggers no re-read and no PATCH', + ); + assert( + isTagAlreadyExists(422, JSON.stringify({ errors: [{ code: 'already_exists', field: 'tag_name' }] })), + 'the classifier recognises objectstack\'s 17.6.0 answer, 422 already_exists on tag_name', + ); + assert( + !isTagAlreadyExists(422, JSON.stringify({ errors: [{ code: 'already_exists', field: 'name' }] })) && + !isTagAlreadyExists(409, JSON.stringify({ errors: [{ code: 'already_exists', field: 'tag_name' }] })) && + !isTagAlreadyExists(422, 'already_exists tag_name'), + 'only the structured tag_name entry on a 422 counts — another field, another status or bare prose does not', + ); + /** @type {number[]} */ + const slept = []; + const ghost = stubFetch({ raceCreateFor: new Set([coreTag]), hideAfterRace: new Map([[coreTag, Infinity]]) }); + const ghostResult = await publishReleases({ + client: clientOver(ghost), + plans, + targetCommitish: CTX.ref, + log: () => {}, + sleep: async (ms) => { + slept.push(ms); + }, + }); + assert( + ghostResult.failed.length === 1 && + ghostResult.failed[0].tagName === coreTag && + ghostResult.failed[0].error.includes('already_exists') && + ghostResult.failed[0].error.includes('404'), + 'a 422 already_exists whose release no by-tag re-read can see fails that package, saying both', + ); + assert( + ghost.calls.filter((c) => c.method === 'GET' && c.url.includes(encodeURIComponent(coreTag))).length === + 1 + RACE_REREAD_DELAYS_MS.length && + JSON.stringify(slept) === JSON.stringify(RACE_REREAD_DELAYS_MS.filter((d) => d > 0)), + `the re-read is bounded: ${RACE_REREAD_DELAYS_MS.length} attempts after the first read, waiting the declared delays`, + ); + const lagging = stubFetch({ raceCreateFor: new Set([coreTag]), hideAfterRace: new Map([[coreTag, 1]]) }); + const laggingResult = await publishReleases({ + client: clientOver(lagging), + plans, + targetCommitish: CTX.ref, + log: () => {}, + sleep: noSleep, + }); + assert( + laggingResult.failed.length === 0 && laggingResult.converged.length === 1 && lagging.forTag(coreTag).length === 1, + 'a racer the first re-read misses is found by a later one, and the run converges', + ); + + // ── 14. Duplicate releases are reported, never deleted ────────────────── + // objectstack's other 17.6.0 shape: the API ACCEPTED both creates. Older versions' + // Releases sit below this version's in the list, so the walk has to stop. + battery('14. Duplicate releases for one tag are reported, never deleted'); + const OLDER_TAG_DATE = '2026-09-29T07:54:50Z'; + const olderVersion = Array.from({ length: 12 }, (_, i) => ({ + id: 100 + i, + tag_name: `@object-ui/older-${i}@17.5.0`, + created_at: OLDER_TAG_DATE, + })); + const dup = stubFetch({ + raceCreateFor: new Set([typesTag]), + acceptDuplicateCreateFor: new Set([typesTag]), + seed: olderVersion, + }); + const dupResult = await publishReleases({ + client: clientOver(dup), + plans, + targetCommitish: CTX.ref, + log: () => {}, + sleep: noSleep, + }); + // An asset uploaded by tag attaches onto whichever release the tag resolves to. + const [typesFirst, typesSecond] = dup.forTag(typesTag); + typesFirst.assets = [{ name: 'release-asset.json' }]; + const dupAudit = await auditDuplicateReleases({ + client: clientOver(dup), + tagNames: plans.map((p) => p.tagName), + createdIds: dupResult.createdIds, + perPage: 2, + }); + assert( + dupResult.failed.length === 0 && dup.forTag(typesTag).length === 2, + 'the fixture really holds a duplicate: the API accepted a second create for the types tag', + ); + assert( + dupAudit.duplicates.length === 1 && dupAudit.duplicates[0].tagName === typesTag, + `the audit names exactly the duplicated tag (got ${JSON.stringify(dupAudit.duplicates.map((d) => d.tagName))})`, + ); + assert( + JSON.stringify(dupAudit.duplicates[0]?.releases.map((r) => r.id)) === JSON.stringify([typesFirst.id, typesSecond.id]), + 'the audit lists both release ids of the pair', + ); + assert( + dupAudit.duplicates[0]?.byTagId === typesFirst.id, + 'the audit names the id the by-tag endpoint resolves to — where an asset uploaded by tag lands', + ); + assert( + JSON.stringify(dupAudit.duplicates[0]?.createdByThisRun) === JSON.stringify([typesSecond.id]), + 'the audit knows which release of the pair this run created', + ); + assert( + dupAudit.unseen.length === 0 && dupAudit.complete, + 'every tag this run released was seen and the walk completed', + ); + assert( + dupAudit.pagesRead === 3, + `the walk stops at the first page past this version's releases, never reading the whole list (read ${dupAudit.pagesRead} of 8 pages)`, + ); + const dupReport = describeDuplicates(dupAudit); + assert( + dupReport.errors.length === 1 && + dupReport.errors[0].includes(typesTag) && + dupReport.errors[0].includes(`${typesFirst.id} (assets: release-asset.json)`) && + dupReport.errors[0].includes(`${typesSecond.id} (no assets)`) && + dupReport.errors[0].includes(`are the surplus`), + 'a duplicate this run created one of is an ERROR naming both ids, their assets and the surplus', + ); + assert( + dupReport.errors[0]?.includes('never deletes') && dupReport.warnings.length === 0, + 'the error says nothing was deleted, and there is no stray warning', + ); + assert( + [...shared.calls, ...dup.calls, ...raced.calls, ...ghost.calls].every((c) => ['GET', 'POST', 'PATCH'].includes(c.method)), + 'no request this script sends is anything but GET, POST or PATCH — it never DELETEs a release', + ); + const preexisting = stubFetch({ + seed: [ + { id: 7001, tag_name: typesTag, created_at: STUB_TAG_DATE, assets: [{ name: 'release-asset.json' }] }, + { id: 7002, tag_name: typesTag, created_at: STUB_TAG_DATE }, + ...olderVersion, + ], + }); + const preResult = await publishReleases({ + client: clientOver(preexisting), + plans, + targetCommitish: CTX.ref, + log: () => {}, + sleep: noSleep, + }); + const preReport = describeDuplicates( + await auditDuplicateReleases({ + client: clientOver(preexisting), + tagNames: plans.map((p) => p.tagName), + createdIds: preResult.createdIds, + }), + ); + assert( + preResult.updated.includes(typesTag) && + preexisting.calls.some((c) => c.method === 'PATCH' && c.url.endsWith('/releases/7001')) && + preexisting.calls.every((c) => c.method !== 'PATCH' || !c.url.endsWith('/releases/7002')), + 'a run over a pre-existing pair updates the one the tag resolves to and leaves the other alone', + ); + assert( + preReport.errors.length === 0 && preReport.warnings.length === 1 && preReport.warnings[0].includes('7002'), + 'a duplicate that predates this run is a WARNING naming the surplus, not a failure', + ); + const capped = describeDuplicates( + await auditDuplicateReleases({ + client: clientOver(dup), + tagNames: plans.map((p) => p.tagName), + createdIds: dupResult.createdIds, + perPage: 1, + maxPages: 2, + }), + ); + assert( + capped.warnings.some((w) => w.includes('UNVERIFIED')), + 'an audit that ran out of pages before seeing every tag says what it could not verify', + ); + assert( + describeDuplicates( + await auditDuplicateReleases({ client: clientOver(stubFetch({ seed: olderVersion })), tagNames: [typesTag], perPage: 5 }), + ).warnings.some((w) => w.includes('never appeared')), + 'a tag the list never shows is reported unseen, not silently cleared', + ); + + // ── 15. The tags the push step owes are exactly the release set ───────── + // changeset-release.yml's "Push the release tags" step reads `--print-tags` + // and pushes what it names, so the list has to be the release set: a tag the + // list omits is never pushed, and its Release would then be POSTed against a + // tag the remote does not have (the API creates a lightweight one). + battery('15. The tags the push step owes are exactly the release set'); + assert( + tagNameOf({ name: '@object-ui/core', version: '17.7.0' }) === '@object-ui/core@17.7.0', + 'a tag is `@`, the spelling `changeset publish` creates', + ); + const owed = resolveReleaseTargets({ releaseVersion: '17.9.0', packages }).map(tagNameOf); + assert( + owed.length === packages.size && new Set(owed).size === owed.length && owed.every((t) => t.endsWith('@17.9.0')), + 'one tag per publishable package, none twice, every one at the release version', + ); + /** @type {string[]} */ + const plannedTags = []; + for (const target of resolveReleaseTargets({ releaseVersion: '17.7.0', packages })) { + try { + const plan = planRelease({ target, ...CTX }); + if ('tagName' in plan) plannedTags.push(plan.tagName); + } catch { + /* a package with no 17.7.0 entry plans nothing, and so releases nothing */ + } + } + const owed177 = new Set(resolveReleaseTargets({ releaseVersion: '17.7.0', packages }).map(tagNameOf)); + assert( + plannedTags.length >= 3 && plannedTags.every((t) => owed177.has(t)), + `every Release this script would create is on a tag the push step pushes (${plannedTags.length} planned for 17.7.0)`, + ); + const self = fileURLToPath(import.meta.url); + const childEnv = { ...process.env }; + delete childEnv.PUBLISHED; + delete childEnv.RELEASE_VERSION; + let printed = ''; + try { + printed = execFileSync(process.execPath, [self, '--print-tags'], { + encoding: 'utf8', + env: { ...childEnv, RELEASE_VERSION: '17.9.0' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + printed = `exit ${err?.status}: ${err?.stderr ?? ''}`; + } + assert( + JSON.stringify(printed.trim().split('\n')) === JSON.stringify(owed), + `\`--print-tags\` prints exactly that list, one per line (got ${JSON.stringify(printed.slice(0, 200))})`, + ); + let refusal = { status: 0, stderr: '' }; + try { + execFileSync(process.execPath, [self, '--print-tags'], { + encoding: 'utf8', + env: childEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + refusal = { status: err?.status ?? -1, stderr: String(err?.stderr ?? '') }; + } + assert( + refusal.status !== 0 && refusal.stderr.includes('Refusing to report success over nothing'), + `with no RELEASE_VERSION the tag list refuses instead of printing nothing (exit ${refusal.status})`, + ); + + // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── + // + // Evaluated after every battery has had its chance and BEFORE the verdict, so + // the success line below can only be printed by a run in which the set of + // batteries that registered assertions EQUALS the set declared. A set + // difference names WHICH battery stopped; a count says only that something did. + const floorFailure = (message) => { + failures.push(message); + }; + const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); + let floorBreached = false; + if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { + floorBreached = true; + floorFailure( + `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + + `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, + ); + } + for (const [name, count] of batterySeen) { + if (declaredBatteries.includes(name)) continue; + floorBreached = true; + floorFailure( + `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + + 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', + ); + } + for (const name of declaredBatteries) { + const count = batterySeen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + floorBreached = true; + floorFailure( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + + 'The verdict below would have claimed those cases hold.' + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + + `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + if (floorBreached) { + floorFailure( + 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + + 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + + 'skips) and restore it.', + ); + } + + if (failures.length) { + console.error(`✗ release-github-releases --self-test — ${failures.length} of ${assertions} assertion(s) failed\n`); + for (const f of failures) console.error(` • ${f}`); + process.exit(1); + } + console.log( + `✓ release-github-releases --self-test: ${assertions} assertions ` + + `(real packages/types/CHANGELOG.md 17.7.0 section = ${measure(typesEntry ?? '')} chars -> ${measure(big.body)}, limit ${BODY_LIMIT})`, + ); + + return SELF_TEST_VERDICT; +} + +if (isEntrypoint(import.meta.url)) { + try { + if (process.argv.includes('--print-tags')) { + printTags(); + } else if (process.argv.includes('--self-test')) { + if ((await selfTest()) !== SELF_TEST_VERDICT) { + console.error( + '\n✗ release-github-releases self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + } else { + await main({ dryRun: process.argv.includes('--dry-run') }); + } + } catch (err) { + // A stack trace in an Actions log buries the one line that matters. + console.error(`::error::${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} From ae09c39a3c392c813f14fd586d712e299de81431 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:10:27 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix(release):=20the=20publish=20lane=20push?= =?UTF-8?q?es=20the=20version's=20tags=20and=20creates=20its=20GitHub=20Re?= =?UTF-8?q?leases=20itself=20=E2=80=94=20changesets/action@v1=20finds=20no?= =?UTF-8?q?=20`New=20tag:`=20line=20under=20CLI=20v3=20(objectui#11596)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changesets/action@v1 pushes a tag and creates a Release only for the packages whose `New tag: @` line it parses from the publish script's stdout. @changesets/cli v3 (objectui#5296) prints none, so 17.6.0 and 17.7.0 reached npm with no git tag and no GitHub Release while every step stayed green. Two steps after "Verify the release reached npm", on the publish lane only (`push`, gated on the publish step's and the npm check's outcome, under `!cancelled()`): - "Push the release tags": pushes exactly the `@` tags `changeset publish` created on the runner (the list `release-github-releases.mjs --print-tags` prints), in one push, and fails naming any that is missing; - "Create GitHub Releases": runs scripts/release-github-releases.mjs with RELEASE_VERSION = needs.lane.outputs.manifest_version. changesets/action stays at @v1. The workflow's comments that said v1 creates the tags and Releases now say what happens, and the CI guide's Changeset Release section documents both steps. Claude-Session: https://claude.ai/code/session_014VGCS11YUtYAiinRcdqQwL Co-authored-by: Claude --- .github/workflows/changeset-release.yml | 172 ++++++++++++++++++++++-- content/docs/guide/ci-cd-pipeline.md | 34 ++++- 2 files changed, 190 insertions(+), 16 deletions(-) diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index 343b2ce974..10d583eb36 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -47,6 +47,12 @@ name: Changeset Release # case hasChangesets && !hasNonEmptyChangesets: -> nothing # case hasChangesets: -> runVersion (force-push PR) # +# ⚠️ "(npm + tags)" is v1's design, not what it does here any more: it pushes a +# tag (and creates a Release) only for the packages whose `New tag:` line it +# parses from the publish script's stdout, and `@changesets/cli` v3 prints +# none. So under v3 `runPublish` is npm only, and the tags and Releases come +# from two steps of this file's own after the npm check (objectui#11596). +# # Two consequences drive everything below, and they are NOT symmetric: # # - Omitting `publish:` makes publishing UNREACHABLE BY CONSTRUCTION. There is @@ -68,7 +74,9 @@ name: Changeset Release # # THE LANES # --------- -# push to main, version NOT on npm -> PUBLISH what this commit declares. +# push to main, version NOT on npm -> PUBLISH what this commit declares, +# then push its tags and create its +# GitHub Releases (objectui#11596). # Normally that is the version-PR merge # itself. It is also the retry: any # later push re-attempts a release whose @@ -633,10 +641,13 @@ jobs: # # Every `git` in this file, enumerated rather than grepped-for-absence: # `git status --porcelain` (x2), `git checkout -- .` and `git clean -fdq` - # in "Restore the pre-version tree". No `merge`, `rebase`, `pull`, - # `cherry-pick`, `am`, `apply` or `revert` anywhere in the file — each of - # those zero-hits was taken with a control term that DID hit the same - # file, because a zero-hit with no control is not a reading. + # in "Restore the pre-version tree", and — since objectui#11596 — + # `git rev-parse -q --verify` and `git push origin ` in "Push + # the release tags", which reads and pushes refs and resolves nothing. + # No `merge`, `rebase`, `pull`, `cherry-pick`, `am`, `apply` or `revert` + # anywhere in the file — each of those zero-hits was taken with a + # control term that DID hit the same file, because a zero-hit with no + # control is not a reading. # # `git checkout -- .` (below, in the restore step) takes tracked paths # from the INDEX to undo what `pnpm changeset:version` wrote. That is a @@ -740,7 +751,9 @@ jobs: # racing the tick — can make a scheduled run publish. An `if:` cannot say # that; an absent input can. # - # The `id:`s are for the log, nothing reads them. + # The refresh step's `id:` is for the log; nothing reads it. The publish + # step's IS read: the two tag/Release steps after the npm check gate on + # its outcome (objectui#11596). # # ══════════════════════════════════════════════════════════════════════ # ⛔ THE `@v1` PIN IS LOAD-BEARING. READ THIS BEFORE BUMPING IT. @@ -778,6 +791,11 @@ jobs: # DELETES EVERY PENDING CHANGESET IN THE REPOSITORY — ~161 files at # the time of writing. Re-verify it by reading the source, not by # reading release notes. + # ⚠️ Under CLI v3 even that `git.pushTag()` is never reached — it sits + # behind the `New tag:` parse, which finds nothing — so the tags are + # pushed by this file's own "Push the release tags" step. That step + # pushes tag refs alone, so the property's conclusion still holds: + # nothing on the publish lane commits (objectui#11596). # # WHAT v2 ACTUALLY CHANGES (read from its CHANGELOG, not from compiled # source; every item below was checked against the v2.0.0 entry) @@ -816,6 +834,12 @@ jobs: # have to forward it or git tags and GitHub releases stop being created # (#697 downgrades that to a warning rather than an error — i.e. it # fails QUIETLY, which is this card's whole subject matter). + # ⚠️ v1 met that same quiet failure WITHOUT a bump: its detection is + # the stdout parse for `New tag:` lines, CLI v3 prints none, and + # 17.6.0 and 17.7.0 shipped with no tag and no Release + # (objectui#11596). Tags and Releases no longer depend on the action's + # detection in either major — the two steps after the npm check make + # them — so this item now concerns only the action's OWN Releases. # # ⛔ DO NOT WRITE "we are held back by the Changesets CLI generation." # That is the plausible wrong answer. v2's #699 validates that projects use @@ -859,13 +883,16 @@ jobs: # action instead of through the job guard, and no `if:` can talk the action # out of it. A tree with nothing pending can. # - # ⛔ Nothing is committed and nothing is pushed. That is verified against - # the action's source rather than assumed: `runPublish` (`src/run.ts`) runs - # the publish script, then for each package it published pushes a TAG and - # creates a GitHub release. It never commits, and never pushes a branch. So - # this deletion cannot leave the runner, and `.changeset/` on `main` is - # untouched — those changesets are still owed to the next version PR, which - # the 6-hourly refresh lane will render as usual. + # ⛔ Nothing is committed, and no branch is pushed. That is verified + # against the action's source rather than assumed: `runPublish` + # (`src/run.ts`) runs the publish script, then for each package whose + # `New tag:` line it parses — none, under CLI v3 (objectui#11596) — pushes + # a TAG and creates a GitHub release. It never commits, and never pushes a + # branch. The one push this lane does make is the tag-ref push after the + # npm check, of tags at HEAD, which is `github.sha` because nothing + # commits. So this deletion cannot leave the runner, and `.changeset/` on + # `main` is untouched — those changesets are still owed to the next + # version PR, which the 6-hourly refresh lane will render as usual. # # This is deliberately a SUPERSET of what the action counts, and it is NOT # a second copy of the `lane` job's mirror. The mirror's job is to PREDICT @@ -989,6 +1016,9 @@ jobs: # unreadable registry is a failure too — a release nobody can confirm is not # a confirmed release. - name: Verify the release reached npm + # The `id:` is read: the two tag/Release steps below gate on this + # step's outcome (objectui#11596). + id: verify-npm if: github.event_name == 'push' env: ANCHOR_PKG: '@object-ui/core' @@ -1029,6 +1059,122 @@ jobs: echo "::error::${ANCHOR_PKG}@${EXPECTED} is STILL not on npm after a publish run that reported success. The repository declares a version the registry has never seen — this is objectui#5442's silent failure, made loud. Check the 'Publish to npm' step's log for which branch changesets/action took." exit 1 + # ══════════════════════════════════════════════════════════════════════ + # TAGS AND GITHUB RELEASES (objectui#11596) + # ══════════════════════════════════════════════════════════════════════ + # `changesets/action@v1` used to do both of these itself, and since + # objectui#5296 it does neither. v1's `runPublish` learns WHAT was + # published by parsing the publish script's stdout for + # `New tag: @` lines, and pushes a tag and creates a + # Release only for the packages it finds there. `@changesets/cli` v3 + # prints no such line — it reports through `@clack/prompts` and an output + # report — so v1 found nothing, and 17.6.0 and 17.7.0 reached npm with no + # git tag and no GitHub Release while every step above stayed green. The + # npm check above reads npm only; nothing read the tags or the Releases. + # + # `changeset publish` still CREATES the tags, on this runner, at HEAD: an + # annotated `@` tag per package it published, under the git + # identity v1's default `setupGitUser` configures before it runs the + # publish script. They just never left the runner. So the two steps below + # take over the half of `runPublish` v1 can no longer reach, and neither + # depends on its stdout parse: + # + # 1. push the tags `changeset publish` created — exactly the + # `@` set this version owes, in ONE push + # (objectstack#2191: per-tag pushes fired concurrently raced + # GitHub's ref backend), and loudly when one is missing; + # 2. create one GitHub Release per public package with + # `scripts/release-github-releases.mjs`, ported from objectstack: + # idempotent, and every body truncated to the Releases API's + # 125,000-character limit, which the 17.7.0 entries far exceed. + # + # ⛔ PUBLISH LANE ONLY. Both carry `github.event_name == 'push'`, like + # every publish-lane step in this job, and both read the outcomes of the + # publish step and of the npm check — two steps a refresh run never + # executes, so on `schedule` / `workflow_dispatch` both outcomes are + # `skipped` and neither condition can be true. A version already on npm + # never reaches this job at all (the job guard above). + # + # `!cancelled()` rather than the implicit success(), as objectstack's + # `release.yml` gates its own Release step (objectstack#4900): once the + # npm check has passed the version is public, so a failure between here + # and the Release step — the tag push, say — must not also cost the + # release its record. The two outcomes are the gate, not the job status. + # + # ⚠️ This is the only push the publish lane makes, and it pushes tag refs + # alone. Each points at HEAD, which is still `github.sha`: no step on this + # lane commits, so the clear step's runner-local deletions are in no + # commit and cannot leave the runner through a tag. Property (2) of the + # `@v1` pin above is about the ACTION's pushes; this adds tag refs to + # them and nothing else. + # + # No `git config user.*` here, deliberately. objectstack's publish step + # configures one because it runs `changeset publish` itself; here the + # action runs it, after its own `setupGitUser`, and by the time this step + # runs the tags exist. Pushing a ref creates no object, so there is + # nothing for an identity to sign. + # + # `contents: write` is what both need — a tag push and a Release create + # are both contents writes — and this job inherits it from the + # workflow-level `permissions:` block, declaring none of its own. + - name: Push the release tags + if: >- + ${{ !cancelled() && github.event_name == 'push' + && steps.changesets-publish.outcome == 'success' + && steps.verify-npm.outcome == 'success' }} + env: + RELEASE_VERSION: ${{ needs.lane.outputs.manifest_version }} + run: | + set -euo pipefail + + # The set this version owes, from the same enumeration the Release + # step below uses rather than a second copy of it in bash. + owed=$(node scripts/release-github-releases.mjs --print-tags) + + refs=() + missing=() + while IFS= read -r tag; do + if [ -z "${tag}" ]; then continue; fi + if git rev-parse -q --verify "refs/tags/${tag}" > /dev/null; then + refs+=("refs/tags/${tag}") + else + missing+=("${tag}") + fi + done <<< "${owed}" + + # ONE push for the whole set. A tag the remote already holds at the + # same object is a no-op, so this never fails on a tag it has + # already pushed. + if [ "${#refs[@]}" -gt 0 ]; then + git push origin "${refs[@]}" + fi + echo "- release tags pushed: \`${#refs[@]}\`" >> "$GITHUB_STEP_SUMMARY" + + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::changeset publish left no local tag for ${#missing[@]} package(s) this release owes: ${missing[*]}. The next step still creates their Releases, and the API then creates each missing tag itself as a LIGHTWEIGHT tag at ${GITHUB_SHA} (objectui#11596)." + exit 1 + fi + + # Ordering is load-bearing: a Release POSTed for a tag the remote does not + # have makes the API create that tag itself — lightweight, at + # `target_commitish`, which the script sets to this run's `github.sha`. + # The annotated tags therefore go first, and this step finds them there. + # + # ⛔ No `--dry-run` here: it plans and prints, calls no API, and exits 0 — + # a Release step that would report success having released nothing. + - name: Create GitHub Releases (bodies truncated to the API limit) + if: >- + ${{ !cancelled() && github.event_name == 'push' + && steps.changesets-publish.outcome == 'success' + && steps.verify-npm.outcome == 'success' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The fixed group releases every public package at one version, so + # the version alone drives the whole publishable workspace — the + # value the npm check above has just confirmed. + RELEASE_VERSION: ${{ needs.lane.outputs.manifest_version }} + run: node scripts/release-github-releases.mjs + # ══════════════════════════════════════════════════════════════════════ # POST-VERSION VALIDATION — THE ONLY GATE THE RELEASE COMMIT EVER PASSES # (objectui#5397) diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 1553d2a6f1..24b48603e9 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -2121,9 +2121,9 @@ registry cannot be read: 200 is published, 404 is not, and anything else fails t `changesets/action@v1` chooses publish-vs-version from repository state rather than from an input, so the predicate cannot reach it on its own — with changesets present it would take its version branch and publish nothing. The publish lane therefore clears the pending `.changeset/*.md` from -the **runner's working tree** before invoking it. Nothing is committed and nothing is pushed -(`runPublish` pushes tags and creates releases; it never commits), so `.changeset/` on `main` is -untouched and those changesets are still owed to the next version PR. +the **runner's working tree** before invoking it. Nothing is committed and no branch is pushed +(`runPublish` never commits), so `.changeset/` on `main` is untouched and those changesets are +still owed to the next version PR. #### The loud check @@ -2132,6 +2132,34 @@ untouched and those changesets are still owed to the next version PR. versions later. So the publish lane now reads the registry back afterwards and **fails** if the version it exists to ship is still absent. A repo/npm divergence is a failing run, not a finding. +#### Tags and GitHub Releases + +`changesets/action@v1` pushes a package's git tag and creates its GitHub Release only for the +packages whose `New tag: @` line it parses out of the publish script's stdout. Since +[#5296](https://github.com/objectstack-ai/objectui/issues/5296) moved this repository to +`@changesets/cli` v3, `changeset publish` prints no such line, so the action pushed nothing and +created nothing: **17.6.0 and 17.7.0 reached npm with no tag and no Release**, while every step +stayed green ([#11596](https://github.com/objectstack-ai/objectui/issues/11596)). `changeset +publish` still creates the tags on the runner, so the publish lane now finishes the job itself, +after the npm check: + +| Step | What it does | +|---|---| +| Push the release tags | Pushes exactly the `@` tags the release owes — the list `--print-tags` prints — in **one** push, and fails naming any tag `changeset publish` did not create. | +| Create GitHub Releases | `node scripts/release-github-releases.mjs`, ported from objectstack: one Release per public package from its CHANGELOG entry, idempotent (an existing Release is updated, never re-created), and every body **truncated to the Releases API's 125,000-character limit** with a link to the complete entry. | + +The truncation is not hypothetical here: `@object-ui/app-shell@17.5.0` has a tag but no Release, +because its entry was over that limit when the action tried to post it, and many 17.7.0 entries +are larger still. Run the script with `--dry-run` to see the planned tag → Release list and each +body's size without calling the API. + +Both steps run on the **publish lane only** — `push`, gated on the publish step and the npm check +having succeeded, so a `schedule` or `workflow_dispatch` run can never reach them — and under +`!cancelled()`, so a version that is already public still gets its record if a step between them +fails. The tags go first because a Release created for a tag the remote does not have makes the +API create that tag itself, as a lightweight tag. `scripts/__tests__/release-github-releases.test.ts` +runs the script's `--self-test` and pins both steps' lane scoping and order. + The refresh lane is invoked **without** a `publish:` script and **without** npm credentials, so it cannot publish by construction rather than by a condition — the release act in this repository stays the human merge of the version PR.