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

# Reusable cross-repository schema-drift gate. Consumer repos call it so that a
# forked or drifted canonical wasmagent.dev schema fails CI automatically:
#
# jobs:
# schema-drift:
# uses: WasmAgent/wasmagent-protocol/.github/workflows/schema-drift.yml@v0.1.6
#
# The implementation lives only in this repo (single source of truth). It fails
# when the calling repo:
# - vendors a *.schema.json that re-declares a canonical $id but differs from
# the pinned package version,
# - re-declares a canonical $id without depending on @wasmagent/protocol /
# wasmagent-protocol, or
# - ships a competing schemas/index.json listing canonical schema ids.
on:
workflow_call:
inputs:
package-version:
description: 'Pinned @wasmagent/protocol / wasmagent-protocol version to check against (default: latest).'
required: false
type: string
default: ''
python-version:
description: 'Python version for the drift CLI.'
required: false
type: string
default: '3.x'
node-version:
description: 'Node version for the drift CLI.'
required: false
type: string
default: '20'
workflow_dispatch:

jobs:
drift-python:
name: Drift gate (Python)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Install the canonical package + drift CLI
run: |
python -m pip install --upgrade pip
if [ -n "${{ inputs.package-version }}" ]; then
python -m pip install "wasmagent-protocol[validate]==${{ inputs.package-version }}"
else
python -m pip install "wasmagent-protocol[validate]"
fi
- name: Detect schema drift + competing registries
run: wasmagent-protocol check --scan --root .

drift-js:
name: Drift gate (JS)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Install the canonical package
run: |
if [ -n "${{ inputs.package-version }}" ]; then
npm install --no-save "@wasmagent/protocol@${{ inputs.package-version }}"
else
npm install --no-save "@wasmagent/protocol"
fi
- name: Detect schema drift + competing registries
run: node ./node_modules/@wasmagent/protocol/bin/cli.js check --scan --root .
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,40 @@ aep = get_schema("aep-record") # parsed dict
path = schema_path("aep-record") # pathlib.Path to the .json file
```

## Preventing cross-repo drift

Downstream repos must not keep local copies of these schemas — but "must not"
is now also **enforced in CI**, not just written down. This repo ships a reusable
drift gate.

### CLI

Both packages expose `wasmagent-protocol check`. It fails non-zero when a
vendored schema differs from the canonical version, when a canonical `$id` is
re-declared without depending on the package, or when a competing
`schemas/index.json` is shipped.

```bash
# compare one vendored file against the pinned canonical version
wasmagent-protocol check path/to/aep-record.schema.json --id aep-record

# scan a whole repo for drift and competing registries
wasmagent-protocol check --scan --root .
```

### Reusable GitHub workflow

Consumer repos call the shared gate with one job:

```yaml
jobs:
schema-drift:
uses: WasmAgent/wasmagent-protocol/.github/workflows/schema-drift.yml@v0.1.6
```

A PR in any consumer that forks or drifts a canonical schema now fails CI
automatically. See [`docs/CONTRACT-CHANGE-PROCESS.md`](docs/CONTRACT-CHANGE-PROCESS.md).

## Versioning & stability

- Each schema carries a `version` string (see the registry).
Expand All @@ -97,15 +131,19 @@ maintainer and exit-condition policy.

```bash
# validate every schema is well-formed and every fixture conforms
python3 -m pip install jsonschema
python3 -m pip install -e ".[dev]"
python3 tests/conformance.py

# run the drift gate against this repo (auto-detects the canonical source)
python3 -m wasmagent_protocol check --scan --root .
```

## Releases

Published to npm and PyPI from CI via OIDC trusted publishing on `v*` tags — no
tokens stored. See [`docs/CONTRACT-CHANGE-PROCESS.md`](docs/CONTRACT-CHANGE-PROCESS.md).

- **0.1.6** — cross-repo schema-drift gate: `wasmagent-protocol check` CLI (npm + PyPI) and the reusable `.github/workflows/schema-drift.yml` workflow.
- **0.1.5** — first successful npm OIDC publish (trusted publisher now registered on npmjs).
- **0.1.4** — npm OIDC groundwork; trusted publisher was not yet saved on npmjs.
- **0.1.3** — npm OIDC attempt: dropped registry-url (ENEEDAUTH); PyPI only.
Expand Down
97 changes: 97 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// @wasmagent/protocol — cross-repo schema drift gate CLI.
//
// wasmagent-protocol check path/to/aep-record.schema.json --id aep-record
// wasmagent-protocol check --scan --root .
//
// Exits non-zero on any drift or violation. See index.js for the library API.

import { resolve } from 'node:path';
import {
checkFile,
scan,
hasDrift,
} from '../index.js';

function format(f) {
const level = f.ok ? 'OK' : 'ERROR';
return `${level.padEnd(5)} [${f.code}] ${f.path}: ${f.message}`;
}

function parseArgs(argv) {
const out = { command: null, path: null, schemaId: null, root: '.', scan: false, allowCanonicalSource: false, help: false, version: false };
const args = argv.slice(2);
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '-h' || a === '--help') out.help = true;
else if (a === '-v' || a === '--version') out.version = true;
else if (a === 'check') out.command = 'check';
else if (a === '--scan') out.scan = true;
else if (a === '--allow-canonical-source') out.allowCanonicalSource = true;
else if (a === '--id') out.schemaId = args[++i];
else if (a === '--root') out.root = args[++i];
else if (a.startsWith('--')) {
console.error(`error: unknown option ${a}`);
process.exit(2);
} else if (out.command === 'check' && out.path === null) out.path = a;
else {
console.error(`error: unexpected argument ${a}`);
process.exit(2);
}
}
return out;
}

function printHelp() {
console.log(`@wasmagent/protocol — cross-repo schema drift gate

Usage:
wasmagent-protocol check <path> --id <schema-id>
Compare one vendored *.schema.json to the canonical version.

wasmagent-protocol check --scan [--root <dir>] [--allow-canonical-source]
Scan a repo for drifted canonical schemas, re-declared canonical $ids
without a package dependency, and competing schemas/index.json registries.

Exits non-zero on any drift or violation.`);
}

function main() {
const args = parseArgs(process.argv);
if (args.help || args.command === null) {
printHelp();
return args.command === null ? 1 : 0;
}
if (args.version) {
console.log('@wasmagent/protocol drift gate');
return 0;
}
if (args.command !== 'check') {
console.error(`error: unknown command ${args.command}`);
return 2;
}

if (args.path) {
if (!args.schemaId) {
console.error('error: --id is required when checking an explicit path');
return 2;
}
const finding = checkFile(args.path, args.schemaId);
console.log(format(finding));
return finding.ok ? 0 : 1;
}

const root = resolve(args.root);
const findings = scan(root, { allowCanonicalSource: args.allowCanonicalSource });
for (const f of findings) console.log(format(f));
if (hasDrift(findings)) {
const errors = findings.filter((f) => !f.ok);
console.error(`\n${errors.length} drift/violation(s) found under ${root}`);
return 1;
}
const checked = findings.filter((f) => f.code === 'match').length;
console.log(`no drift detected under ${root} (${checked} canonical schema file(s) verified)`);
return 0;
}

process.exit(main());
39 changes: 39 additions & 0 deletions docs/CONTRACT-CHANGE-PROCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,42 @@ If unsure, treat it as breaking.
**No consumer repo keeps a local copy of a schema in this repo.** If you find a
copied schema JSON in a consumer repo, that is a bug: delete it and depend on
the package. Drift between copies is exactly the failure this repo prevents.

## Enforcing it in CI: the schema-drift gate

The one rule is now machine-enforced, not just documented. This repo ships a
reusable drift gate that any consumer can wire into CI so a forked schema fails
to merge automatically.

- **CLI** — `@wasmagent/protocol` (npm) and `wasmagent-protocol` (PyPI) both
expose `wasmagent-protocol check`:

```bash
# compare one vendored file against the pinned canonical version
wasmagent-protocol check path/to/aep-record.schema.json --id aep-record

# scan the whole repo for drift, re-declared canonical $ids with no package
# dependency, and competing schemas/index.json registries
wasmagent-protocol check --scan --root .
```

It exits non-zero on any drift or violation, so it drops straight into CI.
`wasmagent-protocol` auto-detects when it is running inside this repo (the
canonical source) and relaxes the source-only checks there.

- **Reusable workflow** — `.github/workflows/schema-drift.yml` is a
`workflow_call` gate consumer repos invoke with one line:

```yaml
jobs:
schema-drift:
uses: WasmAgent/wasmagent-protocol/.github/workflows/schema-drift.yml@v0.1.6
```

It installs the pinned package and runs the scan. A PR that forks or drifts a
canonical schema, re-declares a canonical `$id` without depending on the
package, or ships a competing `schemas/index.json` fails CI.

Consumer repos that vendor a canonical schema should instead delete the copy and
`npm install @wasmagent/protocol` / `pip install wasmagent-protocol`. See
[README.md](../README.md) for consumption.
39 changes: 39 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,42 @@ export const schemas: Record<string, unknown>;
* (e.g. "aep-record", "constraint-ir"). Throws on unknown id.
*/
export function getSchema(id: string): unknown;

// ---------------------------------------------------------------------------
// Cross-repo schema drift detection.
// ---------------------------------------------------------------------------

/** Canonical host prefix for every registered schema $id. */
export const canonicalHost: string;

/** A single drift-scan result. `ok === false` blocks CI. */
export interface DriftFinding {
ok: boolean;
code: string;
path: string;
message: string;
}

/** Canonical, order-independent serialization of a schema document. */
export function normalizeSchema(input: string | unknown): string;

/** The set of canonical $id URIs for every registered schema. */
export function canonicalIds(): Set<string>;

/** Map a canonical $id back to its registry id, or null. */
export function schemaIdForCanonical(canonicalId: string): string | null;

/** Compare a vendored schema file to the canonical schema for `schemaId`. */
export function checkFile(filePath: string, schemaId: string): DriftFinding;

/** True if `root` is the wasmagent-protocol repo itself (best-effort). */
export function isCanonicalSource(root: string): boolean;

/** True if `root` declares a dependency on the protocol package (best-effort). */
export function dependsOnPackage(root: string): boolean;

/** Scan `root` for drift, re-declared canonical ids, and competing registries. */
export function scan(root: string, options?: { allowCanonicalSource?: boolean }): DriftFinding[];

/** True if any finding in the list is an error. */
export function hasDrift(findings: DriftFinding[]): boolean;
Loading
Loading