From b0d3cb756dcd07af76430f7687d5296480c6107e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:53:30 +0000 Subject: [PATCH 1/3] ci(release): build GitHub Releases ourselves, with bodies that fit the 125k limit (#4900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changesets/action's `createGithubReleases` posts each package's raw CHANGELOG section as the Release body. @objectstack/spec's section for a single v17 RC is 342,893 characters against the API's 125,000 limit, so the POST 422'd — inside runPublish, i.e. after `changeset publish` had fully succeeded but BEFORE the action set its `published` output. The step went red, `published` stayed false, and the docker job gated on it was skipped: a published npm version with no runtime image. The section only grows, so this failed identically every release in the window. Measured against the live API: @objectstack/spec has NO Release for 17.0.0-rc.0, rc.1 or rc.2 (all 404 by tag), while 16.0.0 and 16.1.0 — 62,886 and 1,523 characters — have theirs, each carrying the ADR-0087 D4 spec-changes.json asset. That asset uploads ONTO the spec Release, so D4 has been silently unmounted for the whole v17 RC window too, not just the Release. `createGithubReleases: false`, and scripts/release-github-releases.mjs does the job instead. It is faithful to what the action produced — same tag, name, prerelease rule, and a direct port of the action's own getChangelogEntry for the body, which reproduces the real @objectstack/cli@17.0.0-rc.2 release body byte for byte (73,993 chars) — plus the three properties it lacked: - Bounded. An over-limit body is cut on a line boundary, any code fence the cut opened is closed so the notice renders as markdown rather than inside a code block, no surrogate pair is split, and both ends carry a link to the complete entry in CHANGELOG.md at the release commit. Cost is measured in UTF-16 code units, which is >= the code-point count for every string, so it can only over-estimate against whichever definition of "character" the API applies (the failing section is 342,893 characters but 359,636 UTF-8 bytes; the API quoted the former). - Idempotent. Looks the release up by tag and PATCHes when it exists, POSTs when it does not. rc.2 left ~69 of 70 releases created, so recovering over a partial set is the normal case, not the exception. - Isolated per package. The action ran the set through one Promise.all, so the first rejection abandoned the rest. This runs sequentially, collects failures and still exits non-zero, so one bad changelog can no longer cost @objectstack/spec its Release — and D4 its mount point. Turning createGithubReleases off also disables the action's per-tag `git push`, which lives in the same block. That is a bonus: scripts/release-publish.sh already pushes every tag in one atomic `git push origin --tags` precisely because those concurrent per-tag pushes raced GitHub's ref backend (#2191). Both publish paths are covered. The recovery step (#4901) now reports `npm-published` separately from `published` — the former means "packages went out and owe Releases", the latter "the docker job must build" — and emits its version unconditionally, since an npm repair whose image happens to exist still owes its Releases. release-spec-changes.sh takes that version as a fallback, so D4 mounts on the recovery path as well, which it never could before. `pnpm check:release-body` runs the script's --self-test in lint.yml: 49 assertions over the real code path, fed the REAL oversized section out of packages/spec/CHANGELOG.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- .github/workflows/lint.yml | 17 + .github/workflows/release.yml | 80 ++- package.json | 1 + scripts/release-github-releases.mjs | 1025 +++++++++++++++++++++++++++ scripts/release-spec-changes.sh | 20 +- 5 files changed, 1130 insertions(+), 13 deletions(-) create mode 100644 scripts/release-github-releases.mjs diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8bf899fe2b..591cee535f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -260,6 +260,23 @@ jobs: - name: Release-notes drift guard run: pnpm check:release-notes + # Release-body limit guard (#4900). The GitHub Releases API rejects any + # body over 125,000 characters, and @objectstack/spec's changelog section + # for one v17 RC is ~343,000 — so the release step 422'd after npm had + # already published, took `published` down with it, and silently lost the + # runtime image. scripts/release-github-releases.mjs now builds those + # bodies; this runs its --self-test, which feeds the REAL oversized + # section out of packages/spec/CHANGELOG.md through the real code path and + # asserts the result fits, still links the full entry, closes any code + # fence the cut opened, and splits no surrogate pair. It also covers the + # properties the failure taught us to want: every package in the fixed + # group gets a release, a re-run updates instead of 422-ing on + # `already_exists`, and one package's rejection no longer abandons the + # rest — @objectstack/spec keeps its release, which is where ADR-0087 D4's + # spec-changes.json is attached. + - name: Release-body limit guard + run: pnpm check:release-body + # #3825 Node-version drift guard: a runtime pin is 18 separate string # literals across .github/workflows, so a split is invisible until someone # greps for it. One did open — every PR gate sat on Node 20 (EOL diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97eaff8d15..90955e7a44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,6 +158,25 @@ jobs: version: pnpm run version commit: 'chore: version packages' title: 'chore: version packages' + # GitHub Releases are created by the step below instead (#4900). The + # action posts each package's raw CHANGELOG section as the Release + # body, and @objectstack/spec's section for a single v17 RC is ~343k + # characters against the API's 125,000 limit — so the POST 422'd, + # INSIDE runPublish and therefore BEFORE `published` was set. npm had + # already published; the step went red anyway, `published` stayed + # false, and the docker job was skipped. The section only grows, so it + # failed identically every release in the window: spec has no Release + # for 17.0.0-rc.0/rc.1/rc.2 (all 404), while 16.0.0 and 16.1.0 — 62,886 + # and 1,523 characters — have theirs, with the ADR-0087 D4 + # spec-changes.json asset that uploads onto it. + # + # NOTE this also disables the action's own per-tag `git push` — those + # calls live inside the same `if (createGithubReleases)` block in + # runPublish. That is a bonus, not a loss: scripts/release-publish.sh + # already pushes every tag in ONE atomic `git push origin --tags` + # precisely because the action's concurrent per-tag pushes raced + # GitHub's ref backend (#2191). The workaround's own cause is now gone. + createGithubReleases: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -194,6 +213,10 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | version=$(node -p "require('./packages/cli/package.json').version") + # Emitted unconditionally: the two facts this step can establish — + # "npm needed repairing" and "the image is missing" — are independent, + # and both consumers need the version regardless of which fired. + echo "version=$version" >> "$GITHUB_OUTPUT" # ── npm ───────────────────────────────────────────────────────────── if npm view "@objectstack/cli@$version" version >/dev/null 2>&1; then @@ -208,7 +231,12 @@ jobs: echo "::error::publish ran but @objectstack/cli@$version is still not on npm" exit 1 fi - echo "::warning::Recovered npm packages and git tags. The GitHub Releases and the ADR-0087 D4 spec-changes attachment were NOT created — those only exist on the Changesets action's own publish path. Create them by hand if this release needs them." + echo "::warning::Recovered npm packages and git tags. The GitHub Releases and the ADR-0087 D4 spec-changes attachment are created by the steps below, which follow this recovery path too (#4900)." + # Distinct from `published` below, which means "the docker job must + # build". This one means "a publish happened here", which is what + # the Release/D4 steps key off — an npm repair whose image happens to + # exist still owes its GitHub Releases. + echo "npm-published=true" >> "$GITHUB_OUTPUT" fi # ── runtime image ─────────────────────────────────────────────────── @@ -228,20 +256,56 @@ jobs: fi echo "::warning::No ghcr image for $version (or the registry could not be probed) — requesting the Docker job." - { - echo "published=true" - echo "version=$version" - } >> "$GITHUB_OUTPUT" + echo "published=true" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Releases (bodies truncated to the API limit) + id: github-releases + # Replaces the Changesets action's own createGithubReleases (#4900). + # Same tag, name, prerelease rule and changelog-entry body — the body + # extractor is a direct port of the action's getChangelogEntry, verified + # to reproduce the real @objectstack/cli@17.0.0-rc.2 release body exactly + # — plus the three properties it lacked: bodies truncated to fit the + # 125,000-character limit with a link to the full CHANGELOG entry, + # idempotent create-or-update so a re-run over a partially-created set + # cannot 422, and per-package isolation so one bad package no longer + # abandons the rest of the fixed group (the action ran them under a + # single Promise.all). + # + # `!cancelled()` for the same reason the recovery step above carries it: + # a bare `if:` is implicitly wrapped in success(), which would skip this + # for exactly the failures it exists to survive. + # + # Runs on BOTH publish paths. `npm-published` — not `published`, which + # means "the docker job must build" — is the recovery path's signal that + # packages went out and therefore owe Releases. + if: ${{ !cancelled() && (steps.changesets.outputs.published == 'true' || steps.recover-publish.outputs.npm-published == 'true') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Authoritative when the action published. Empty on the recovery path, + # where RELEASE_VERSION drives the whole publishable workspace instead + # (the Changesets `fixed` group bumps every public package in lockstep, + # which scripts/check-changeset-fixed.mjs gates). + PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }} + RELEASE_VERSION: ${{ steps.recover-publish.outputs.version }} + run: node scripts/release-github-releases.mjs - name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4) # Rebuilds the change manifest with the api-surface diff against the # previously PUBLISHED spec (reusing the ADR-0059 §3 gate artifact) and - # uploads it to the @objectstack/spec release the changesets action - # just created. The npm artifact carries the registry-derived copy. - if: steps.changesets.outputs.published == 'true' + # uploads it to the @objectstack/spec release the step above created. + # The npm artifact carries the registry-derived copy. + # + # This is the D4 mount point, and it is why #4900 could not be answered + # by simply turning createGithubReleases off: `gh release upload` needs a + # Release to upload ONTO. Measured while fixing this — spec has no + # Release for 17.0.0-rc.0/rc.1/rc.2, so D4 has in fact been unmounted for + # the whole v17 RC window; 16.0.0 and 16.1.0 both carry the asset. + # Ordering is load-bearing: this step must follow the one above. + if: ${{ !cancelled() && (steps.changesets.outputs.published == 'true' || steps.recover-publish.outputs.npm-published == 'true') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }} + RELEASE_VERSION: ${{ steps.recover-publish.outputs.version }} run: bash scripts/release-spec-changes.sh - name: Extract published @objectstack/cli version diff --git a/package.json b/package.json index 61b65f7b99..1c719c099f 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "check:objectui-changeset": "node scripts/objectui-changeset-digest.mjs --self-test && node scripts/objectui-range.mjs --self-test", "check:objectui-pin-fresh": "node scripts/check-objectui-pin-fresh.mjs --self-test && node scripts/check-objectui-pin-fresh.mjs", "check:release-notes": "node scripts/check-release-notes.mjs", + "check:release-body": "node scripts/release-github-releases.mjs --self-test", "check:node-version": "node scripts/check-node-version.mjs", "check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs", "check:type-check-coverage": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs", diff --git a/scripts/release-github-releases.mjs b/scripts/release-github-releases.mjs new file mode 100644 index 0000000000..f46bb6d483 --- /dev/null +++ b/scripts/release-github-releases.mjs @@ -0,0 +1,1025 @@ +#!/usr/bin/env node +/** + * 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 (#4900) + * + * `changesets/action` with `createGithubReleases: true` posts each package's + * raw CHANGELOG section as the Release body. `@objectstack/spec`'s section for + * a single v17 RC is ~343,000 characters — 2.7x the API's limit — so the POST + * came back: + * + * HttpError: Validation Failed: + * {"resource":"Release","code":"custom","field":"body", + * "message":"body is too long (maximum is 125000 characters)"} + * + * That throw happens INSIDE the action's `runPublish`: after `changeset + * publish` has fully succeeded, but BEFORE the action sets its `published` + * output. So the step went red, `published` stayed false, and the `docker` job + * gated on it was skipped — a published npm version with no runtime image. + * + * The section only grows, so every release in the window failed the same way. + * Measured against the live API while writing this: `@objectstack/spec` has NO + * GitHub Release for 17.0.0-rc.0, rc.1 or rc.2 (all 404 on the by-tag + * endpoint), while 16.0.0 and 16.1.0 — whose sections are 62,886 and 1,523 + * characters — have both a Release and their ADR-0087 D4 `spec-changes.json` + * asset. That asset is uploaded ONTO the spec Release, so D4 has been silently + * unmounted for the whole v17 RC window as well, not just the Release itself. + * + * The fix: the action stops creating Releases (`createGithubReleases: false`) + * and this script does it, truncating any body that would be rejected and + * pointing at the complete entry in CHANGELOG.md. + * + * ## 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. Verified against the real + * `@objectstack/cli@17.0.0-rc.2` release body (73,993 characters, exact match). + * + * Beyond that it adds the three 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 (rc.2 left ~69 of 70 releases created). + * - **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 + * bad changelog can no longer cost @objectstack/spec its release (and D4 + * its mount point). + * + * Sequential is also deliberate for the writes: #2191 is this repo's standing + * lesson that bursts of concurrent ref-creating requests race GitHub's backend. + * + * Run: + * node scripts/release-github-releases.mjs # from release.yml + * node scripts/release-github-releases.mjs --self-test # verify the logic + * node scripts/release-github-releases.mjs --dry-run # plan + sizes only + * + * Env: + * PUBLISHED changesets/action `publishedPackages` JSON. Primary input. + * RELEASE_VERSION fallback: the recovery path's version. Every publishable + * workspace package is released at this version (the + * Changesets `fixed` group bumps in lockstep). + * GITHUB_TOKEN repo-scoped token with `contents: write`. + * GITHUB_REPOSITORY / GITHUB_SHA / GITHUB_API_URL / GITHUB_SERVER_URL + * standard Actions context. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +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 #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 — the failing spec section is 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. `@objectstack/spec@17.0.0-rc.2` + * @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 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Minimal pnpm-workspace.yaml `packages:` reader. Same approach as + * scripts/check-changeset-fixed.mjs — no YAML dependency. + * + * @param {string} root + * @returns {string[]} + */ +function readWorkspacePatterns(root) { + const text = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); + const patterns = []; + let inPackages = false; + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/#.*$/, '').replace(/\s+$/, ''); + if (!line.trim()) continue; + if (/^packages\s*:\s*$/.test(line)) { + inPackages = true; + continue; + } + if (!inPackages) continue; + const m = /^\s+-\s+["']?([^"'\s]+)["']?\s*$/.exec(line); + if (m) { + patterns.push(m[1]); + continue; + } + if (/^\S/.test(line)) inPackages = false; + } + return patterns; +} + +/** + * Expand a `packages/*`-style pattern (single `*` per segment). + * + * @param {string} root + * @param {string} pattern + * @returns {string[]} + */ +function expandPattern(root, pattern) { + let dirs = [root]; + for (const seg of pattern.split('/')) { + const next = []; + for (const dir of dirs) { + if (seg === '*') { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.')) next.push(join(dir, entry.name)); + } + } else { + const candidate = join(dir, seg); + try { + if (statSync(candidate).isDirectory()) next.push(candidate); + } catch { + /* missing — skip */ + } + } + } + dirs = next; + } + return dirs; +} + +/** + * Every non-private workspace package, by name. + * + * @param {string} [root] + * @returns {Map} + */ +export function listWorkspacePackages(root = REPO_ROOT) { + /** @type {Map} */ + const byName = new Map(); + for (const pattern of readWorkspacePatterns(root)) { + for (const dir of expandPattern(root, pattern)) { + let pkg; + try { + pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + } catch { + continue; + } + if (!pkg.name || pkg.private === true) continue; + if (byName.has(pkg.name)) continue; + byName.set(pkg.name, { name: pkg.name, version: pkg.version, dir }); + } + } + return byName; +} + +/** + * Decide which `@` releases this run owes. + * + * `PUBLISHED` (the action's own `publishedPackages`) is authoritative when + * present. `RELEASE_VERSION` is the recovery path's input: that path runs + * `changeset publish` itself and produces no such JSON, so 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 = `${target.name}@${target.version}`; + 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 +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 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 readError = async (res) => { + let detail = ''; + try { + detail = (await res.text()).slice(0, 500); + } catch { + /* body already consumed or unreadable */ + } + return `${res.status} ${res.statusText || ''} ${detail}`.trim(); + }; + + 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: `%40objectstack%2Ftypes%4017.0.0-rc.2` 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 + */ + 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) throw new Error(`POST release ${tagName} 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(); + }, + }; +} + +/** + * Create or update every release in `plans`, sequentially, isolating failures. + * + * @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] + * @returns {Promise<{ created: string[]; updated: string[]; failed: { tagName: string; error: string }[] }>} + */ +export async function publishReleases({ client, plans, targetCommitish, log = console.log }) { + const created = []; + const updated = []; + 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})`); + } else { + await client.create({ ...plan, targetCommitish }); + created.push(plan.tagName); + 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, failed }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Entry point +// ───────────────────────────────────────────────────────────────────────────── + +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 packages = listWorkspacePackages(); + const targets = resolveReleaseTargets({ + publishedJson: process.env.PUBLISHED, + releaseVersion: process.env.RELEASE_VERSION, + packages, + }); + + if (targets.length === 0) { + console.log('No published packages reported — nothing to release.'); + return; + } + + /** @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: `${target.name}@${target.version}`, error }); + console.log(`::error::cannot build a release body for ${target.name}@${target.version}: ${error}`); + } + } + + if (dryRun) { + for (const plan of plans) { + console.log( + `${plan.tagName}\t${measure(plan.body)} chars${plan.truncated ? `\t(truncated from ${plan.originalLength})` : ''}`, + ); + } + console.log(`\n${plans.length} release(s) planned, ${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, failed } = await publishReleases({ + client, + plans, + targetCommitish: ref, + }); + + const truncatedCount = plans.filter((p) => p.truncated).length; + console.log( + `\n${created.length} created, ${updated.length} updated, ${failed.length + planFailures.length} failed ` + + `(${truncatedCount} body/bodies truncated to fit the ${BODY_LIMIT}-character limit).`, + ); + + const allFailures = [...planFailures, ...failed]; + if (allFailures.length) { + console.error(`::error::${allFailures.length} GitHub Release(s) could not be published.`); + 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; + }, + }; +} + +/** + * Records every call and answers from a set of pre-existing releases. + * + * @param {object} opts + * @param {Record} [opts.existing] tag -> release id + * @param {Set} [opts.failCreateFor] + */ +function stubFetch({ existing = {}, failCreateFor = new Set() } = {}) { + /** @type {{ method: string; url: string; body: any }[]} */ + const calls = []; + const impl = async (url, init = {}) => { + const method = init.method ?? 'GET'; + const body = init.body ? JSON.parse(init.body) : undefined; + calls.push({ method, url: String(url), body }); + + if (method === 'GET') { + const m = /\/releases\/tags\/(.+)$/.exec(String(url)); + const tag = decodeURIComponent(m[1]); + return tag in existing ? stubResponse(200, { id: existing[tag] }) : stubResponse(404, { message: 'Not Found' }); + } + if (method === 'POST') { + if (failCreateFor.has(body.tag_name)) { + return stubResponse(422, { message: 'Validation Failed', errors: [{ field: 'body' }] }); + } + return stubResponse(201, { id: 999 }); + } + return stubResponse(200, { id: body?.id ?? 1 }); + }; + return { impl, calls }; +} + +async function selfTest() { + /** @type {string[]} */ + const failures = []; + let assertions = 0; + const assert = (cond, msg) => { + assertions += 1; + if (!cond) failures.push(msg); + }; + + const CTX = { + serverUrl: 'https://github.com', + repository: 'objectstack-ai/objectstack', + ref: 'deadbeef', + }; + const specChangelog = readFileSync(join(REPO_ROOT, 'packages/spec/CHANGELOG.md'), 'utf8'); + + // ── 1. The real #4900 repro: spec's 17.0.0-rc.2 section ──────────────────── + const rc2 = getChangelogEntry(specChangelog, '17.0.0-rc.2'); + assert(rc2 !== null, 'the 17.0.0-rc.2 entry is found in packages/spec/CHANGELOG.md'); + assert( + rc2 !== null && measure(rc2) > 340_000, + `#4900's section is still the oversized repro (measured ${rc2 === null ? 'n/a' : measure(rc2)}, expected >340,000)`, + ); + + const specLabel = 'packages/spec/CHANGELOG.md'; + const specHref = `${CTX.serverUrl}/${CTX.repository}/blob/${CTX.ref}/${specLabel}#1700-rc2`; + const big = buildReleaseBody({ + entry: rc2 ?? '', + tagName: '@objectstack/spec@17.0.0-rc.2', + changelogLabel: specLabel, + changelogHref: specHref, + }); + 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(specHref), '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(`${specLabel}\`](${specHref}).`), + '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: specLabel, + changelogHref: specHref, + 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 ───────────────────────────────────── + const astral = '🚀'.repeat(5_000); + const astralBuilt = buildReleaseBody({ + entry: astral, + tagName: 'pkg@1.0.0', + changelogLabel: specLabel, + changelogHref: specHref, + 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. + 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('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 ────────────────────── + const packages = listWorkspacePackages(); + assert(packages.has('@objectstack/spec'), 'the workspace scan finds @objectstack/spec'); + const fromPublished = resolveReleaseTargets({ + publishedJson: JSON.stringify([ + { name: '@objectstack/spec', version: '17.0.0-rc.2' }, + { name: '@objectstack/cli', version: '17.0.0-rc.2' }, + ]), + 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.0.0-rc.9', packages }); + assert( + fromVersion.length === packages.size && fromVersion.length > 50, + `the recovery path covers the whole publishable workspace (got ${fromVersion.length})`, + ); + assert( + fromVersion.every((t) => t.version === '17.0.0-rc.9'), + 'the recovery path releases every package at the one fixed-group version', + ); + assert(resolveReleaseTargets({ packages }).length === 0, 'no input means no releases, not a crash'); + assert( + resolveReleaseTargets({ publishedJson: '[]', releaseVersion: '1.2.3', packages }).length === packages.size, + 'an empty PUBLISHED array falls through to the recovery version', + ); + + // ── 7. Planning is per package, and a missing entry is loud ──────────────── + const specPlan = planRelease({ + target: { name: '@objectstack/spec', version: '17.0.0-rc.2', dir: join(REPO_ROOT, 'packages/spec') }, + ...CTX, + }); + assert('tagName' in specPlan && specPlan.tagName === '@objectstack/spec@17.0.0-rc.2', 'the tag is `@`'); + assert('prerelease' in specPlan && specPlan.prerelease === true, 'an rc version is marked prerelease'); + assert('truncated' in specPlan && specPlan.truncated === true, 'the spec plan truncates'); + assert( + 'body' in specPlan && measure(specPlan.body) <= BODY_LIMIT, + 'the planned spec body is within the API limit end to end', + ); + const gaPlan = planRelease({ + target: { name: '@objectstack/spec', version: '16.1.0', dir: join(REPO_ROOT, 'packages/spec') }, + ...CTX, + }); + assert('prerelease' in gaPlan && gaPlan.prerelease === false, 'a GA version is not marked prerelease'); + let threw = false; + try { + planRelease({ + target: { name: '@objectstack/spec', version: '99.99.99', dir: join(REPO_ROOT, 'packages/spec') }, + ...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 ─ + const plans = ['@objectstack/spec', '@objectstack/cli', '@objectstack/runtime'].map((name) => { + const plan = planRelease({ target: { name, version: '17.0.0-rc.2', 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: { '@objectstack/cli@17.0.0-rc.2': 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] === '@objectstack/cli@17.0.0-rc.2', + '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 ─────────────────────────────────────────────────── + 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 ────────────────── + const partial = stubFetch({ failCreateFor: new Set(['@objectstack/cli@17.0.0-rc.2']) }); + 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('@objectstack/spec@17.0.0-rc.2'), + '@objectstack/spec still gets its release — the ADR-0087 D4 mount point survives a sibling failure', + ); + assert( + partialResult.failed[0].error.includes('422'), + 'the failure carries the API status through to the log', + ); + + 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/spec/CHANGELOG.md 17.0.0-rc.2 section = ${measure(rc2 ?? '')} chars -> ${measure(big.body)}, limit ${BODY_LIMIT})`, + ); +} + +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + try { + if (process.argv.includes('--self-test')) { + await selfTest(); + } 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); + } +} diff --git a/scripts/release-spec-changes.sh b/scripts/release-spec-changes.sh index 1cdbb98888..d9f52415ef 100755 --- a/scripts/release-spec-changes.sh +++ b/scripts/release-spec-changes.sh @@ -2,16 +2,26 @@ # ADR-0087 D4 — build the release `spec-changes.json` (the registry projection # joined with the api-surface diff against the PREVIOUSLY PUBLISHED spec — the # ADR-0059 §3 gate artifact, reused instead of discarded) and attach it to the -# `@objectstack/spec@` GitHub Release created by the changesets action. +# `@objectstack/spec@` GitHub Release. +# +# The Release itself is created by scripts/release-github-releases.mjs, which +# must run BEFORE this script — `gh release upload` needs something to upload +# onto (#4900). # # Inputs (env): -# PUBLISHED — the changesets action's `publishedPackages` JSON array -# GH_TOKEN — token for `gh release upload` +# PUBLISHED — the changesets action's `publishedPackages` JSON array +# RELEASE_VERSION — fallback for the recovery publish path, which produces no +# such JSON; the fixed group releases every package at one +# version, so spec's version is that version +# GH_TOKEN — token for `gh release upload` set -euo pipefail -new_version=$(jq -r '.[] | select(.name=="@objectstack/spec") | .version' <<<"${PUBLISHED}") +new_version=$(jq -r '.[] | select(.name=="@objectstack/spec") | .version' <<<"${PUBLISHED:-[]}") if [ -z "${new_version}" ] || [ "${new_version}" = "null" ]; then - echo "::error::@objectstack/spec missing from publishedPackages — cannot attach spec-changes.json" + new_version="${RELEASE_VERSION:-}" +fi +if [ -z "${new_version}" ]; then + echo "::error::@objectstack/spec version unknown (neither publishedPackages nor RELEASE_VERSION) — cannot attach spec-changes.json" exit 1 fi From 1f50271cc4d52de446016c6e430656922be86d77 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:59:21 +0000 Subject: [PATCH 2/3] chore(changeset): declare that #4900's release-machinery fix releases nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty frontmatter — the repo's sanctioned "this PR releases nothing" declaration, on par with the skip-changeset label (both are named in the Check Changeset gate). The PR changes only .github/workflows/, root scripts/ and one check: entry in the root (private) package.json, so nothing reaches a published package; a non-empty changeset would bump all 69 packages of the fixed group in lockstep and burn an extra rc for no shipped product code. The body records the one caveat that matters here: an empty changeset is the exact input #4898 showed can jam a release, which is now bounded rather than silent by the recovery step (#4899, made reachable by #4901) — and this PR is what extends the GitHub Releases and the ADR-0087 D4 spec-changes.json attachment onto that recovery path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- .../release-github-release-body-limit.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .changeset/release-github-release-body-limit.md diff --git a/.changeset/release-github-release-body-limit.md b/.changeset/release-github-release-body-limit.md new file mode 100644 index 0000000000..e516b307b8 --- /dev/null +++ b/.changeset/release-github-release-body-limit.md @@ -0,0 +1,44 @@ +--- +--- + +ci(release): build GitHub Releases ourselves, with bodies that fit the 125k limit (#4900) + +**Deliberately empty frontmatter — this PR releases nothing.** It changes only +`.github/workflows/release.yml`, `.github/workflows/lint.yml`, root `scripts/` +and one `check:` script entry in the root `package.json`. Not a byte of it +reaches any published package: the root manifest is private, and `scripts/` at +the repo root is release tooling, never packaged (`check:published-files` is the +gate that keeps `/scripts/**` out of the npm artifacts, and this is one +level above even that). A non-empty changeset here would bump all 69 packages of +the Changesets `fixed` group in lockstep and burn an extra `rc` on a change that +ships no product code. + +The empty-frontmatter form is the repo's sanctioned "this PR releases nothing" +declaration, on par with the `skip-changeset` label, per `Check Changeset` in +`.github/workflows/pr-automation.yml`. This PR carries the label as well; the +file exists so the declaration is a durable record in the repo rather than a +label anyone can remove later. + +One caveat worth stating where the next reader will find it, because this PR is +about the release machinery: an empty changeset is the exact input #4898 showed +can jam a release — `changesets/action` enters its publish branch only with ZERO +pending changesets, and an empty one still counts as pending. That is now +bounded rather than silent. The recovery step #4899 added, made reachable and +given the right invariant by #4901, catches precisely that case (repo version +absent from npm → publish; image absent → request the Docker job), and this PR +extends the GitHub Releases and the ADR-0087 D4 `spec-changes.json` attachment to +that same recovery path — which they never covered before. So if this changeset +is ever the only pending one when a version bump lands, the release is repaired +and reported, not lost. + +What the change itself does: `changesets/action`'s `createGithubReleases` posted +each package's raw CHANGELOG section as the Release body, and +`@objectstack/spec`'s section for one v17 RC is 342,893 characters against the +API's 125,000 limit. The 422 fired inside `runPublish` — after `changeset +publish` had fully succeeded but before the action set its `published` output — +so the step went red, `published` stayed false, and the `docker` job gated on it +was skipped: a published npm version with no runtime image. The action now has +`createGithubReleases: false` and `scripts/release-github-releases.mjs` creates +the Releases instead, truncating an over-limit body to fit and linking the +complete entry in `CHANGELOG.md`, idempotently (PATCH when the release exists), +and per package rather than under one `Promise.all`. From cb8fe9f3596c29409596e876cbbc1944d2a4674e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 16:02:55 +0000 Subject: [PATCH 3/3] Revert "chore(changeset): declare that #4900's release-machinery fix releases nothing" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 1f50271, keeping the `skip-changeset` label as this PR's only "releases nothing" declaration. The empty changeset was redundant with the label — Check Changeset exempts a labelled PR at the job level, and the earlier red run predated the label (the PR was created at 15:54:54, the label applied at ~15:56, so that run's event payload carried no labels at all). Any subsequent synchronize event re-evaluates the job `if:` against current labels. Redundancy is not free when the redundant copy is a known-dangerous shape. An empty changeset is exactly the input #4898 showed can jam a release: changesets/action reaches its publish branch only with ZERO pending changesets, and an empty one still counts as pending. The argument that this is now bounded rests on the recovery step (#4899/#4901) — which is a path THIS PR modifies. A PR whose whole purpose is repairing the release machinery should not plant a known-hazardous input and then lean on the very mechanism it is changing to catch it. One declaration, via the label, and no hazard. The gate text that recommends an empty changeset as the way out is tracked separately as #5292. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015W6nhsDrz6zWQc8je12a1t --- .../release-github-release-body-limit.md | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 .changeset/release-github-release-body-limit.md diff --git a/.changeset/release-github-release-body-limit.md b/.changeset/release-github-release-body-limit.md deleted file mode 100644 index e516b307b8..0000000000 --- a/.changeset/release-github-release-body-limit.md +++ /dev/null @@ -1,44 +0,0 @@ ---- ---- - -ci(release): build GitHub Releases ourselves, with bodies that fit the 125k limit (#4900) - -**Deliberately empty frontmatter — this PR releases nothing.** It changes only -`.github/workflows/release.yml`, `.github/workflows/lint.yml`, root `scripts/` -and one `check:` script entry in the root `package.json`. Not a byte of it -reaches any published package: the root manifest is private, and `scripts/` at -the repo root is release tooling, never packaged (`check:published-files` is the -gate that keeps `/scripts/**` out of the npm artifacts, and this is one -level above even that). A non-empty changeset here would bump all 69 packages of -the Changesets `fixed` group in lockstep and burn an extra `rc` on a change that -ships no product code. - -The empty-frontmatter form is the repo's sanctioned "this PR releases nothing" -declaration, on par with the `skip-changeset` label, per `Check Changeset` in -`.github/workflows/pr-automation.yml`. This PR carries the label as well; the -file exists so the declaration is a durable record in the repo rather than a -label anyone can remove later. - -One caveat worth stating where the next reader will find it, because this PR is -about the release machinery: an empty changeset is the exact input #4898 showed -can jam a release — `changesets/action` enters its publish branch only with ZERO -pending changesets, and an empty one still counts as pending. That is now -bounded rather than silent. The recovery step #4899 added, made reachable and -given the right invariant by #4901, catches precisely that case (repo version -absent from npm → publish; image absent → request the Docker job), and this PR -extends the GitHub Releases and the ADR-0087 D4 `spec-changes.json` attachment to -that same recovery path — which they never covered before. So if this changeset -is ever the only pending one when a version bump lands, the release is repaired -and reported, not lost. - -What the change itself does: `changesets/action`'s `createGithubReleases` posted -each package's raw CHANGELOG section as the Release body, and -`@objectstack/spec`'s section for one v17 RC is 342,893 characters against the -API's 125,000 limit. The 422 fired inside `runPublish` — after `changeset -publish` had fully succeeded but before the action set its `published` output — -so the step went red, `published` stayed false, and the `docker` job gated on it -was skipped: a published npm version with no runtime image. The action now has -`createGithubReleases: false` and `scripts/release-github-releases.mjs` creates -the Releases instead, truncating an over-limit body to fit and linking the -complete entry in `CHANGELOG.md`, idempotently (PATCH when the release exists), -and per package rather than under one `Promise.all`.