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