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
35 changes: 16 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,44 @@
# 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,
authenticating as the `openapi-publisher` GitHub App; do not hand-edit `openapi.json`.
the future MCP server. The spec is generated upstream from the UNIPaaS Platform API and published
here automatically by the producer's CI; do not hand-edit the spec.

## Layout

- `openapi.json` / `openapi.yaml` - latest published spec, both formats (HEAD = current); the
producer hands over JSON, `publish.ts` derives the YAML. Do not hand-edit either.
- `provenance.json` - the last publish's deploy `sequence`/`sha`/`tag`; the ordering guard drops
- `spec/openapi.json` / `spec/openapi.yaml` - latest published spec, both formats (HEAD = current);
the producer hands over JSON, `publish.ts` derives the YAML. Do not hand-edit either.
- `spec/provenance.json` - the last publish's deploy `sequence`/`sha`/`tag`; the ordering guard drops
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; invoked by
platform-api CI as `npm run publish:spec` after a production deploy.
the producer's 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]`.
The producer's CI generates the spec on a production deploy and hands it to
`npm run publish:spec -- --spec <file> --sha <sha> --date <iso> --sequence <deploy-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 is live; a rollback is a newer
deploy and passes), writes both formats plus provenance, and cuts an idempotent release. The producer
just generates and hands over; it holds no publishing logic.

## Versioning

Two separate concepts. `info.version` inside the spec is a hand-curated human label owned by
platform-api. The release tag `v<info.version>+<YYYYMMDD>.<shortsha>` is the provenance identity and
the thing consumers pin.
Two separate concepts. `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.

## Gates / commands

- `npm install` - install the dev toolchain (tsx, typescript, spectral).
- `npm run typecheck` - strict type-check (`tsc --noEmit`).
- `npm test` - run the unit tests (`tsx --test`).
- `npm run lint:spec` - lint `openapi.json` against `.spectral.yaml`.
- `npm run lint:spec` - lint `spec/openapi.json` against `.spectral.yaml`.

## Conventions

Conventional Commits, no attribution trailers. SSH remotes only
(`git@github.com-unipaas:UNIPaaS/openapi.git`).
Conventional Commits, no attribution trailers. SSH remotes only (`git@github.com:UNIPaaS/openapi.git`).
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@

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. It is not where the spec is authored: the spec is generated from `platform-api` and published
here by that repo's CI.
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.

## What is here

- `openapi.json` / `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.
- `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 platform-api commit that
built it.
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).
- `.spectral.yaml` - the lint ruleset shared by producer and consumers.

Expand All @@ -24,11 +23,12 @@ Pin a specific version:

Or always-latest from HEAD:

curl -fsSL https://raw.githubusercontent.com/UNIPaaS/openapi/main/openapi.json -o openapi.json
curl -fsSL https://raw.githubusercontent.com/UNIPaaS/openapi/main/spec/openapi.json -o openapi.json

This repo is public, so reads need no authentication.

## How it is published
## Versioning

`platform-api` CI generates the spec on a successful production deploy, compares it to the last
release, and on a real change runs `scripts/publish.ts` here to commit latest and cut a release.
`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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"scripts": {
"test": "tsx --test scripts/*.test.ts",
"typecheck": "tsc --noEmit",
"lint:spec": "spectral lint openapi.json --ruleset .spectral.yaml",
"lint:spec": "spectral lint spec/openapi.json --ruleset .spectral.yaml",
"publish:spec": "tsx scripts/publish.ts"
},
"dependencies": {
Expand Down
20 changes: 11 additions & 9 deletions scripts/publish.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Usage: npm run publish:spec -- --spec <path> --sha <platform-api-sha> --date <iso8601> --sequence <int>
// Usage: npm run publish:spec -- --spec <path> --sha <source-commit-sha> --date <iso8601> --sequence <int>
// 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.
// 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.
import fs from 'node:fs';
Expand All @@ -11,7 +11,9 @@ import { releaseTag } from './release-tag';
import { classify, type OpenApiSpec, type Provenance } from './publish-logic';

const REPO = 'UNIPaaS/openapi';
const PROVENANCE_FILE = 'provenance.json';
const SPEC_JSON = 'spec/openapi.json';
const SPEC_YAML = 'spec/openapi.yaml';
const PROVENANCE_FILE = 'spec/provenance.json';

function arg(name: string): string {
const i = process.argv.indexOf(`--${name}`);
Expand All @@ -37,14 +39,14 @@ const releaseExists = (tag: string): boolean => {

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');
[`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)) {
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]);
run('gh', ['release', 'create', tag, SPEC_JSON, SPEC_YAML, '--repo', REPO, '--title', tag, '--notes', body]);
console.log(`released ${tag}`);
};

Expand All @@ -57,7 +59,7 @@ if (!Number.isInteger(sequence) || sequence < 0) {
}

const incoming = readJson<OpenApiSpec>(specPath);
const current = fs.existsSync('openapi.json') ? readJson<unknown>('openapi.json') : null;
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;

Expand All @@ -71,10 +73,10 @@ if (stage === 'stale') {
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.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', 'openapi.json', 'openapi.yaml', PROVENANCE_FILE]);
run('git', ['add', SPEC_JSON, SPEC_YAML, PROVENANCE_FILE]);
if (capture('git', ['status', '--porcelain']).trim()) {
run('git', ['commit', '-m', `chore: publish spec ${tag}`]);
run('git', ['push', 'origin', 'HEAD']);
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading