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
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm test
18 changes: 14 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# UNIPaaS/openapi

Canonical, versioned OpenAPI spec for the UNIPaaS Platform API. Source of truth for docs, SDKs, and
the future MCP server. The spec is generated in `platform-api` and published here by that repo's CI;
do not hand-edit `openapi.json`.
the future MCP server. The spec is generated in `platform-api` and published here by that repo's CI,
authenticating as the `openapi-publisher` GitHub App; do not hand-edit `openapi.json`.

## Layout

Expand All @@ -12,12 +12,22 @@ do not hand-edit `openapi.json`.
publishes from an older deploy than this. Generated on publish; do not hand-edit.
- `CHANGELOG.md` - generated diff between releases.
- `.spectral.yaml` - shared lint ruleset.
- `scripts/publish.ts` - change-detect + ordering guard, commits latest + cuts a release; called by
platform-api CI (via `tsx`).
- `scripts/publish.ts` - change-detect + ordering guard, commits latest + cuts a release; invoked by
platform-api CI as `npm run publish:spec` after a production deploy.
- `scripts/release-tag.ts` - computes the provenance tag; pure, unit-tested.

Scripts are strict TypeScript run with `tsx` (no build step); `tsc --noEmit` is the typecheck gate.

## Publishing

platform-api's `publish-openapi` CI job generates the spec on a production deploy and runs
`npm run publish:spec -- --spec <file> --sha <sha> --date <iso> --sequence <pipeline-id>`. The hub
owns the logic: it canonically compares the spec to HEAD (no-op if unchanged), drops publishes from an
older deploy than the last recorded `sequence` (so HEAD mirrors what's live; a rollback is a newer
deploy and passes), writes both formats, and cuts an idempotent release. Auth is a short-lived
installation token from the `openapi-publisher` GitHub App (App id `4020207`, key in 1Password),
minted in the producer job; releases are attributed to `openapi-publisher[bot]`.

## Versioning

Two separate concepts. `info.version` inside the spec is a hand-curated human label owned by
Expand Down
41 changes: 41 additions & 0 deletions scripts/publish-logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { canonicalJson, specsEqual, classify } from './publish-logic';

test('canonicalJson sorts object keys but preserves array order', () => {
assert.equal(canonicalJson({ b: 1, a: 2 }), canonicalJson({ a: 2, b: 1 }));
assert.notEqual(canonicalJson([1, 2]), canonicalJson([2, 1]));
});

test('specsEqual ignores key order, catches value changes', () => {
assert.ok(specsEqual({ info: { version: '1' }, a: 1 }, { a: 1, info: { version: '1' } }));
assert.ok(!specsEqual({ a: 1 }, { a: 2 }));
});

const spec = { info: { version: '1.12' }, paths: { '/x': { get: {} } } };

test('classify: a strictly older deploy is stale', () => {
assert.equal(classify({ sequence: 99, lastSequence: 100, currentSpec: spec, incomingSpec: spec }), 'stale');
});

test('classify: equal sequence is NOT stale, so a re-run can self-heal', () => {
assert.equal(classify({ sequence: 100, lastSequence: 100, currentSpec: spec, incomingSpec: spec }), 'unchanged');
});

test('classify: a newer deploy with identical content is unchanged (no release churn)', () => {
assert.equal(classify({ sequence: 200, lastSequence: 100, currentSpec: spec, incomingSpec: spec }), 'unchanged');
});

test('classify: changed content publishes even on a newer deploy', () => {
const changed = { info: { version: '1.12' }, paths: { '/y': { get: {} } } };
assert.equal(classify({ sequence: 200, lastSequence: 100, currentSpec: spec, incomingSpec: changed }), 'changed');
});

test('classify: no current spec (first publish) publishes', () => {
assert.equal(classify({ sequence: 1, lastSequence: -1, currentSpec: null, incomingSpec: spec }), 'changed');
});

test('classify: a rollback (newer deploy, older content) publishes so HEAD mirrors live', () => {
const older = { info: { version: '1.11' }, paths: { '/x': { get: {} } } };
assert.equal(classify({ sequence: 300, lastSequence: 100, currentSpec: spec, incomingSpec: older }), 'changed');
});
57 changes: 57 additions & 0 deletions scripts/publish-logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Pure decision logic for the publish flow, extracted from publish.ts so the guards (ordering and
// change-detection) are unit-testable without git/gh side effects.

export interface OpenApiSpec {
info: { version: string };
}

// Provenance of the last publish, committed alongside the spec. `sequence` is the ordering key and
// `tag` is the release the current HEAD content belongs to (used to self-heal a missing release).
export interface Provenance {
sequence: number;
sha: string;
timestamp: string;
tag: string;
}

// Deterministic JSON with object keys sorted recursively (arrays keep their order), so two specs
// compare equal regardless of incidental key ordering. The input is always parsed JSON, so there are
// no exotic value types to handle.
export const canonicalJson = (value: unknown): string => {
const sortKeys = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortKeys);
if (v !== null && typeof v === 'object') {
const obj = v as Record<string, unknown>;
return Object.keys(obj)
.sort()
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = sortKeys(obj[key]);
return acc;
}, {});
}
return v;
};
return JSON.stringify(sortKeys(value));
};

export const specsEqual = (a: unknown, b: unknown): boolean => canonicalJson(a) === canonicalJson(b);

export type Stage = 'stale' | 'changed' | 'unchanged';

// Classify a publish attempt from the ordering key and content alone (no I/O):
// - stale: this deploy is strictly older than the last published one -> drop, so HEAD keeps mirroring
// the most recent deploy (a rollback is a newer deploy and is not stale).
// - changed: the spec differs from HEAD (or HEAD has none) -> publish it.
// - unchanged: identical to HEAD -> nothing to publish; only a missing release may need healing.
// Equal sequence is deliberately NOT stale, so a re-run of the same deploy can still heal a release
// that a prior attempt committed but failed to create.
export const classify = (input: {
sequence: number;
lastSequence: number;
currentSpec: unknown | null;
incomingSpec: unknown;
}): Stage => {
if (input.sequence < input.lastSequence) return 'stale';
if (input.currentSpec === null || !specsEqual(input.currentSpec, input.incomingSpec)) return 'changed';
return 'unchanged';
};
147 changes: 53 additions & 94 deletions scripts/publish.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
// Usage: npm run publish:spec -- --spec <path> --sha <platform-api-sha> --date <iso8601> --sequence <int>
// The hub's publish entrypoint. Given a freshly generated spec, decide whether it differs from what
// HEAD already serves; if so, commit it as latest, push, and cut a provenance-tagged GitHub Release.
// Change-detection, ordering, idempotency, and the tag scheme all live here so producers stay dumb
// (they just generate a spec and hand it over). `--sequence` is a monotonic deploy key (GitLab's
// CI_PIPELINE_ID) so HEAD mirrors the most recent production deploy, never an older one that lands
// late. Run inside a clone of this repo with gh authenticated (contents:write).
// 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 (GitLab'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.
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dump as toYaml } from 'js-yaml';
import { releaseTag } from './release-tag';
import { classify, type OpenApiSpec, type Provenance } from './publish-logic';

const REPO = 'UNIPaaS/openapi';
const PROVENANCE_FILE = 'provenance.json';

function arg(name: string): string {
const i = process.argv.indexOf(`--${name}`);
Expand All @@ -23,51 +26,26 @@ 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 = (releaseTag: string): boolean => {
const releaseExists = (tag: string): boolean => {
try {
execFileSync('gh', ['release', 'view', releaseTag, '--repo', 'UNIPaaS/openapi'], { stdio: 'ignore' });
execFileSync('gh', ['release', 'view', tag, '--repo', REPO], { stdio: 'ignore' });
return true;
} catch {
return false;
}
};

interface OpenApiSpec {
info: { version: string };
}

// Provenance of the last publish, committed alongside the spec. Its `sequence` is the ordering key:
// a publish from an older deploy than the one recorded here is stale and rejected.
interface Provenance {
sequence: number;
sha: string;
timestamp: string;
tag: string;
}
const PROVENANCE_FILE = 'provenance.json';
const lastPublishedSequence = (): number => {
if (!fs.existsSync(PROVENANCE_FILE)) return -1;
return (JSON.parse(fs.readFileSync(PROVENANCE_FILE, 'utf8')) as Provenance).sequence;
};

// Deterministic JSON with object keys sorted recursively (arrays keep their order), so two specs
// compare equal regardless of incidental key ordering. The input is always parsed JSON, so there are
// no exotic value types to handle.
const canonicalJson = (value: unknown): string => {
const sortKeys = (v: unknown): unknown => {
if (Array.isArray(v)) return v.map(sortKeys);
if (v !== null && typeof v === 'object') {
const obj = v as Record<string, unknown>;
return Object.keys(obj)
.sort()
.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = sortKeys(obj[key]);
return acc;
}, {});
}
return v;
};
return JSON.stringify(sortKeys(value));
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 platform-api ${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)) {
console.log(`release ${tag} already exists; nothing to do`);
return;
}
run('gh', ['release', 'create', tag, 'openapi.json', 'openapi.yaml', '--repo', REPO, '--title', tag, '--notes', body]);
console.log(`released ${tag}`);
};

const specPath = arg('spec');
Expand All @@ -78,62 +56,43 @@ if (!Number.isInteger(sequence) || sequence < 0) {
throw new Error(`--sequence must be a non-negative integer, got ${arg('sequence')}`);
}

const incoming = JSON.parse(fs.readFileSync(specPath, 'utf8')) as OpenApiSpec;
const tag = releaseTag({ infoVersion: incoming.info.version, isoDate, sha });
const incoming = readJson<OpenApiSpec>(specPath);
const current = fs.existsSync('openapi.json') ? readJson<unknown>('openapi.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 });

// Ordering guard: HEAD mirrors the most recent production deploy. A publish from an older deploy than
// the last one recorded (a late-landing concurrent pipeline, or a re-run of an old pipeline) is stale
// and dropped. A rollback is a newer deploy, so it passes and the spec follows live.
const published = lastPublishedSequence();
if (sequence <= published) {
console.log(`stale deploy (sequence ${sequence} <= last published ${published}); skipping`);
if (stage === 'stale') {
console.log(`stale deploy (sequence ${sequence} < last published ${lastSequence}); skipping`);
process.exit(0);
}

// If the incoming spec is identical to what HEAD already serves, there is nothing to publish.
if (fs.existsSync('openapi.json')) {
const current = JSON.parse(fs.readFileSync('openapi.json', 'utf8')) as unknown;
if (canonicalJson(current) === canonicalJson(incoming)) {
console.log('spec unchanged since last publish; nothing to do');
process.exit(0);
if (stage === 'changed') {
const tag = releaseTag({ infoVersion: incoming.info.version, isoDate, sha });
// Write both formats: JSON for tooling, YAML for human-readable diffs and language-agnostic codegen.
fs.copyFileSync(specPath, 'openapi.json');
fs.writeFileSync('openapi.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', 'openapi.json', 'openapi.yaml', PROVENANCE_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.
ensureRelease(tag, notes(incoming.info.version, sha, isoDate));
process.exit(0);
}

// Write both formats: JSON for tooling, YAML for human-readable diffs and language-agnostic codegen.
fs.copyFileSync(specPath, 'openapi.json');
fs.writeFileSync('openapi.yaml', toYaml(incoming, { lineWidth: -1, noRefs: true }));
// Record this deploy's provenance; the sequence advances only on an actual publish.
const provenance: Provenance = { sequence, sha, timestamp: isoDate, tag };
fs.writeFileSync(PROVENANCE_FILE, `${JSON.stringify(provenance, null, 2)}\n`);
run('git', ['add', 'openapi.json', 'openapi.yaml', PROVENANCE_FILE]);
const dirty = capture('git', ['status', '--porcelain']).trim();
if (dirty) {
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 && !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('working tree clean; skipping commit');
}
// After the commit/push, so a retry that committed but failed to release still creates the release,
// while a re-run of an already-published tag is a no-op.
if (releaseExists(tag)) {
console.log(`release ${tag} already exists; nothing to publish`);
process.exit(0);
console.log('spec unchanged and release present; nothing to do');
}
const notes = [
`Spec version \`${incoming.info.version}\`.`,
`From platform-api ${sha.slice(0, 7)} on ${isoDate.slice(0, 10)}.`,
].join('\n');
run('gh', [
'release',
'create',
tag,
'openapi.json',
'openapi.yaml',
'--repo',
'UNIPaaS/openapi',
'--title',
tag,
'--notes',
notes,
]);
console.log(`published ${tag}`);
Loading