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. 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); + } +}