From 2653390d4070820f301a05856c5d42b64ef47604 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Wed, 10 Jun 2026 21:18:10 +0300 Subject: [PATCH 1/3] feat: self-healing idempotent publish with tested decision logic Extract ordering/change-detection into publish-logic.ts (classify/specsEqual/ canonicalJson), unit-tested. Fix the self-heal gap: ordering uses strict '<' and an equal-sequence re-run is no longer skipped, so a run that committed but failed to create the release heals on retry instead of being dropped as stale. Unchanged content with an existing release stays a no-op (no churn). --- scripts/publish-logic.test.ts | 41 ++++++++++ scripts/publish-logic.ts | 57 +++++++++++++ scripts/publish.ts | 147 ++++++++++++---------------------- 3 files changed, 151 insertions(+), 94 deletions(-) create mode 100644 scripts/publish-logic.test.ts create mode 100644 scripts/publish-logic.ts diff --git a/scripts/publish-logic.test.ts b/scripts/publish-logic.test.ts new file mode 100644 index 0000000..a31031a --- /dev/null +++ b/scripts/publish-logic.test.ts @@ -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'); +}); diff --git a/scripts/publish-logic.ts b/scripts/publish-logic.ts new file mode 100644 index 0000000..0b9ce32 --- /dev/null +++ b/scripts/publish-logic.ts @@ -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; + return Object.keys(obj) + .sort() + .reduce>((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'; +}; diff --git a/scripts/publish.ts b/scripts/publish.ts index cb27807..43363b7 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -1,14 +1,17 @@ // Usage: npm run publish:spec -- --spec --sha --date --sequence -// 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}`); @@ -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; - return Object.keys(obj) - .sort() - .reduce>((acc, key) => { - acc[key] = sortKeys(obj[key]); - return acc; - }, {}); - } - return v; - }; - return JSON.stringify(sortKeys(value)); +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 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'); @@ -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(specPath); +const current = fs.existsSync('openapi.json') ? readJson('openapi.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 }); -// 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}`); From d66dae0a00c25d3f35d9e7030da0fa8afb34dd69 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Wed, 10 Jun 2026 21:18:10 +0300 Subject: [PATCH 2/3] ci: typecheck + tests on push and PRs --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aad6b5c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 From c31d80d2464f76e82c6631f586645d6706be6ab8 Mon Sep 17 00:00:00 2001 From: Dean Harel Date: Wed, 10 Jun 2026 22:09:14 +0300 Subject: [PATCH 3/3] docs: document openapi-publisher App auth and publishing flow in AGENTS.md --- AGENTS.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a299473..fef9527 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 --sha --date --sequence `. 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