Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 10 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -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<info.version>+<YYYYMMDD>.<shortsha>` 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<info.version>+<YYYYMMDD>.<shortsha>`):

## 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<info.version>+<YYYYMMDD>.<shortsha>` 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.
160 changes: 97 additions & 63 deletions scripts/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}`);
Expand All @@ -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<string, string> => {
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<boolean> => {
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<void> => {
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 = <T>(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<void> => {
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}`);
};

Expand All @@ -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<OpenApiSpec>(specPath);
const current = fs.existsSync(SPEC_JSON) ? readJson<unknown>(SPEC_JSON) : null;
const provenance = fs.existsSync(PROVENANCE_FILE) ? readJson<Provenance>(PROVENANCE_FILE) : null;
const lastSequence = provenance ? provenance.sequence : -1;
async function main(): Promise<void> {
const incoming = readJson<OpenApiSpec>(specPath);
const current = fs.existsSync(SPEC_JSON) ? readJson<unknown>(SPEC_JSON) : null;
const provenance = fs.existsSync(PROVENANCE_FILE) ? readJson<Provenance>(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);
},
);
Loading