From cbe65c56a2d8e367c9c3350ecb372cb8c826d018 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Thu, 11 Jun 2026 00:59:15 +0300 Subject: [PATCH 1/2] refactor(publish): release via the GitHub REST API instead of the gh CLI Create the release and upload both spec assets through api.github.com using the installation token the producer's CI already mints, so the publish job needs no gh binary (and no checksum-verified tarball install). GITHUB_API_URL is overridable for GitHub Enterprise and tests. main() exits explicitly once settled so fetch's keep-alive socket pool can't keep the process alive. --- scripts/publish.ts | 160 +++++++++++++++++++++++++++------------------ 1 file changed, 97 insertions(+), 63 deletions(-) diff --git a/scripts/publish.ts b/scripts/publish.ts index 986b8a7..aa79483 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -2,8 +2,8 @@ // The hub's publish entrypoint. Producers stay dumb: they generate a spec and hand it over; ordering, // change-detection, idempotency, the tag scheme, and release self-heal all live here. `--sequence` is // a monotonic deploy key (the producer's CI pipeline id) so HEAD mirrors the most recent production deploy. -// Run inside a clone of this repo with gh authenticated (contents:write). Decision logic is in -// publish-logic.ts (unit-tested); this file is the I/O around it. +// Run inside a clone of this repo with GH_TOKEN set (a contents:write token) and git configured to push. +// Decision logic is in publish-logic.ts (unit-tested); this file is the I/O around it. import fs from 'node:fs'; import { execFileSync } from 'node:child_process'; import { dump as toYaml } from 'js-yaml'; @@ -16,6 +16,7 @@ const SPEC_YAML = 'spec/openapi.yaml'; const PROVENANCE_FILE = 'spec/provenance.json'; const CHANGELOG_FILE = 'CHANGELOG.md'; const CHANGELOG_HEADER = '# Changelog'; +const GITHUB_API = process.env.GITHUB_API_URL ?? 'https://api.github.com'; // override for GitHub Enterprise / tests function arg(name: string): string { const i = process.argv.indexOf(`--${name}`); @@ -29,26 +30,48 @@ const run = (cmd: string, args: string[]): void => { }; const capture = (cmd: string, args: string[]): string => execFileSync(cmd, args).toString(); -// True if a release for this tag already exists. `gh release view` exits non-zero when it does not. -const releaseExists = (tag: string): boolean => { - try { - execFileSync('gh', ['release', 'view', tag, '--repo', REPO], { stdio: 'ignore' }); - return true; - } catch { - return false; - } +// Releases go through the GitHub REST API with the installation token the producer's CI already mints, +// so the job needs no `gh` binary. `fetch` is global on the Node runtime this script targets. +const ghHeaders = (): Record => { + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; + if (!token) throw new Error('GH_TOKEN (or GITHUB_TOKEN) must be set to publish releases'); + return { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28' }; +}; + +// True if a release for this tag already exists (the tags endpoint 404s when it does not). +const releaseExists = async (tag: string): Promise => { + const res = await fetch(`${GITHUB_API}/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`, { headers: ghHeaders() }); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`GitHub API ${res.status} checking release ${tag}: ${await res.text()}`); + return true; +}; + +const uploadAsset = async (uploadUrl: string, path: string): Promise => { + const name = path.split('/').pop() as string; + // upload_url is an RFC 6570 template ending in `{?name,label}`; strip it and pass name explicitly. + const url = `${uploadUrl.replace(/\{.*\}$/, '')}?name=${encodeURIComponent(name)}`; + const res = await fetch(url, { method: 'POST', headers: { ...ghHeaders(), 'content-type': 'application/octet-stream' }, body: fs.readFileSync(path) }); + if (!res.ok) throw new Error(`GitHub API ${res.status} uploading asset ${name}: ${await res.text()}`); }; const readJson = (path: string): T => JSON.parse(fs.readFileSync(path, 'utf8')) as T; const notes = (version: string, sha: string, isoDate: string): string => [`Spec version \`${version}\`.`, `From source commit ${sha.slice(0, 7)} on ${isoDate.slice(0, 10)}.`].join('\n'); // Create the release from the committed assets. Idempotent: skip if the tag is already released. -const ensureRelease = (tag: string, body: string): void => { - if (releaseExists(tag)) { +const ensureRelease = async (tag: string, body: string): Promise => { + if (await releaseExists(tag)) { console.log(`release ${tag} already exists; nothing to do`); return; } - run('gh', ['release', 'create', tag, SPEC_JSON, SPEC_YAML, '--repo', REPO, '--title', tag, '--notes', body]); + const res = await fetch(`${GITHUB_API}/repos/${REPO}/releases`, { + method: 'POST', + headers: { ...ghHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ tag_name: tag, name: tag, body }), + }); + if (!res.ok) throw new Error(`GitHub API ${res.status} creating release ${tag}: ${await res.text()}`); + const { upload_url: uploadUrl } = (await res.json()) as { upload_url: string }; + await uploadAsset(uploadUrl, SPEC_JSON); + await uploadAsset(uploadUrl, SPEC_YAML); console.log(`released ${tag}`); }; @@ -60,63 +83,74 @@ if (!Number.isInteger(sequence) || sequence < 0) { throw new Error(`--sequence must be a non-negative integer, got ${arg('sequence')}`); } -const incoming = readJson(specPath); -const current = fs.existsSync(SPEC_JSON) ? readJson(SPEC_JSON) : null; -const provenance = fs.existsSync(PROVENANCE_FILE) ? readJson(PROVENANCE_FILE) : null; -const lastSequence = provenance ? provenance.sequence : -1; +async function main(): Promise { + const incoming = readJson(specPath); + const current = fs.existsSync(SPEC_JSON) ? readJson(SPEC_JSON) : null; + const provenance = fs.existsSync(PROVENANCE_FILE) ? readJson(PROVENANCE_FILE) : null; + const lastSequence = provenance ? provenance.sequence : -1; -const stage = classify({ sequence, lastSequence, currentSpec: current, incomingSpec: incoming }); + const stage = classify({ sequence, lastSequence, currentSpec: current, incomingSpec: incoming }); -if (stage === 'stale') { - console.log(`stale deploy (sequence ${sequence} < last published ${lastSequence}); skipping`); - process.exit(0); -} + if (stage === 'stale') { + console.log(`stale deploy (sequence ${sequence} < last published ${lastSequence}); skipping`); + return; + } + + if (stage === 'changed') { + const tag = releaseTag({ infoVersion: incoming.info.version, isoDate, sha }); + // Build a changelog entry by diffing the previous published spec (still on disk) against the + // incoming one, before the overwrite. Best effort: if oasdiff is unavailable or errors, fall back + // to a neutral note rather than fail the publish. + let diffBody = 'Initial published spec.'; + if (fs.existsSync(SPEC_JSON)) { + try { + diffBody = + capture('oasdiff', ['changelog', SPEC_JSON, specPath, '-f', 'markdown']).trim() || + 'No API-surface changes detected.'; + } catch { + diffBody = 'No API-surface changes detected.'; + } + } + const entry = `## ${tag} (${isoDate.slice(0, 10)})\n\n${diffBody}\n`; + const existingLog = fs.existsSync(CHANGELOG_FILE) ? fs.readFileSync(CHANGELOG_FILE, 'utf8') : `${CHANGELOG_HEADER}\n`; + const priorEntries = existingLog.startsWith(CHANGELOG_HEADER) + ? existingLog.slice(CHANGELOG_HEADER.length).replace(/^\n+/, '') + : existingLog; + fs.writeFileSync(CHANGELOG_FILE, `${CHANGELOG_HEADER}\n\n${entry}\n${priorEntries}`); -if (stage === 'changed') { - const tag = releaseTag({ infoVersion: incoming.info.version, isoDate, sha }); - // Build a changelog entry by diffing the previous published spec (still on disk) against the - // incoming one, before the overwrite. Best effort: if oasdiff is unavailable or errors, fall back - // to a neutral note rather than fail the publish. - let diffBody = 'Initial published spec.'; - if (fs.existsSync(SPEC_JSON)) { - try { - diffBody = - capture('oasdiff', ['changelog', SPEC_JSON, specPath, '-f', 'markdown']).trim() || - 'No API-surface changes detected.'; - } catch { - diffBody = 'No API-surface changes detected.'; + // Write both formats: JSON for tooling, YAML for human-readable diffs and language-agnostic codegen. + fs.copyFileSync(specPath, SPEC_JSON); + fs.writeFileSync(SPEC_YAML, toYaml(incoming, { lineWidth: -1, noRefs: true })); + fs.writeFileSync(PROVENANCE_FILE, `${JSON.stringify({ sequence, sha, timestamp: isoDate, tag } satisfies Provenance, null, 2)}\n`); + run('git', ['add', SPEC_JSON, SPEC_YAML, PROVENANCE_FILE, CHANGELOG_FILE]); + if (capture('git', ['status', '--porcelain']).trim()) { + run('git', ['commit', '-m', `chore: publish spec ${tag}`]); + run('git', ['push', 'origin', 'HEAD']); + } else { + console.log('working tree clean; skipping commit'); } + // Ensure the release exists after the commit/push, so a retry whose commit landed but whose release + // failed still creates it. Release notes carry the changelog diff. + await ensureRelease(tag, `${notes(incoming.info.version, sha, isoDate)}\n\n${diffBody}`); + return; } - const entry = `## ${tag} (${isoDate.slice(0, 10)})\n\n${diffBody}\n`; - const existingLog = fs.existsSync(CHANGELOG_FILE) ? fs.readFileSync(CHANGELOG_FILE, 'utf8') : `${CHANGELOG_HEADER}\n`; - const priorEntries = existingLog.startsWith(CHANGELOG_HEADER) - ? existingLog.slice(CHANGELOG_HEADER.length).replace(/^\n+/, '') - : existingLog; - fs.writeFileSync(CHANGELOG_FILE, `${CHANGELOG_HEADER}\n\n${entry}\n${priorEntries}`); - // Write both formats: JSON for tooling, YAML for human-readable diffs and language-agnostic codegen. - fs.copyFileSync(specPath, SPEC_JSON); - fs.writeFileSync(SPEC_YAML, toYaml(incoming, { lineWidth: -1, noRefs: true })); - fs.writeFileSync(PROVENANCE_FILE, `${JSON.stringify({ sequence, sha, timestamp: isoDate, tag } satisfies Provenance, null, 2)}\n`); - run('git', ['add', SPEC_JSON, SPEC_YAML, PROVENANCE_FILE, CHANGELOG_FILE]); - if (capture('git', ['status', '--porcelain']).trim()) { - run('git', ['commit', '-m', `chore: publish spec ${tag}`]); - run('git', ['push', 'origin', 'HEAD']); + // stage === 'unchanged': HEAD content is already current. The only thing that can still need doing is + // healing a release that a prior run committed but failed to create. + if (provenance && !(await releaseExists(provenance.tag))) { + const version = (current as OpenApiSpec | null)?.info?.version ?? incoming.info.version; + console.log(`spec unchanged but release ${provenance.tag} is missing; healing`); + await ensureRelease(provenance.tag, notes(version, provenance.sha, provenance.timestamp)); } else { - console.log('working tree clean; skipping commit'); + console.log('spec unchanged and release present; nothing to do'); } - // Ensure the release exists after the commit/push, so a retry whose commit landed but whose release - // failed still creates it. Release notes carry the changelog diff. - ensureRelease(tag, `${notes(incoming.info.version, sha, isoDate)}\n\n${diffBody}`); - process.exit(0); } -// stage === 'unchanged': HEAD content is already current. The only thing that can still need doing is -// healing a release that a prior run committed but failed to create. -if (provenance && !releaseExists(provenance.tag)) { - const version = (current as OpenApiSpec | null)?.info?.version ?? incoming.info.version; - console.log(`spec unchanged but release ${provenance.tag} is missing; healing`); - ensureRelease(provenance.tag, notes(version, provenance.sha, provenance.timestamp)); -} else { - console.log('spec unchanged and release present; nothing to do'); -} +// Exit explicitly: fetch's keep-alive socket pool would otherwise keep the event loop alive after main(). +main().then( + () => process.exit(0), + (err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }, +); From 98b3673cf0cb92e3436aa8c85a3d35a559cc06d8 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Thu, 11 Jun 2026 00:59:15 +0300 Subject: [PATCH 2/2] docs(readme): trim to essentials and link the OpenAPI Specification --- README.md | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 5e1ce81..ccd4e46 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,21 @@ # UNIPaaS OpenAPI -The canonical, versioned OpenAPI spec for the UNIPaaS Platform API. This repo is the source of -truth that downstream consumers (the developer docs, generated SDKs, the OpenAPI->MCP server) pull -from. The spec is not authored here: it is generated from the UNIPaaS Platform API and published to -this repo automatically. Do not hand-edit it. +The canonical, versioned OpenAPI spec for the UNIPaaS Platform API, in both JSON and YAML. It is the +source of truth that downstream consumers (the developer docs, generated SDKs, the OpenAPI->MCP +server) pull from. -## What is here - -- `spec/openapi.json` / `spec/openapi.yaml` - the latest published spec in both formats (HEAD always - serves current). JSON for tooling, YAML for human-readable diffs and language-agnostic codegen. -- Releases - each publish cuts a GitHub Release with the immutable `openapi.json` and `openapi.yaml` - assets, tagged `v+.` so every spec traces back to the source - commit that built it. -- `CHANGELOG.md` - human-readable diff between releases (generated). +The spec is generated from the UNIPaaS Platform API and published here automatically; it is not +authored here, so do not hand-edit it. It conforms to the +[OpenAPI Specification v3.0](https://spec.openapis.org/oas/v3.0.0.html). ## Consuming the spec -Pin a specific version: - - gh release download v1.12+20260610.abc1234 --repo UNIPaaS/openapi --pattern openapi.json - -Or always-latest from HEAD: +Always-latest from HEAD: curl -fsSL https://raw.githubusercontent.com/UNIPaaS/openapi/main/spec/openapi.json -o openapi.json -This repo is public, so reads need no authentication. +Or pin a release (each publish cuts one, tagged `v+.`): -## Versioning + gh release download v1.12+20260610.abc1234 --repo UNIPaaS/openapi --pattern openapi.json -`info.version` inside the spec is a curated human label. The release tag -`v+.` is the provenance identity and the thing consumers pin: it is -monotonic and traces each published spec back to the exact source commit that produced it. +The repo is public, so reads need no authentication. See `CHANGELOG.md` for the diff between releases.