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
102 changes: 102 additions & 0 deletions docs/release/contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Stable npm release contract

This contract governs `releases/v1/latest.json`, `releases/v1/releases/<version>.json`, and unauthenticated `GET https://api.diffex.ai/api/latest-version`.
The CLI monorepo publishes records after checking every public workspace package at its exact version and confirming tarball availability.
The public API Worker validates the stored record and projects the public response without querying npm per request.
The marker means npm-verified stable release availability at publication time.
The release workflow publishes the GitHub Release after the announcement, so the marker alone does not guarantee publicly available binary assets or their upgrade path.
Integrity metadata is validated structurally; this contract does not claim that the publisher downloads tarballs and recomputes their digests.

## Stored record

Records are JSON objects with exactly these required fields.
Unknown fields are rejected at the root and within packages; schema changes require coordinated publisher and consumer updates.
Records have no top-level `packageName`, `channel`, or `note`.

| Field | Requirement |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `schemaVersion` | JSON number `1`. |
| `version` | Canonical stable `major.minor.patch` string, each component `0` or a nonzero decimal digit followed by decimal digits. |
| `sourceCommit` | Full Git SHA-1 as exactly 40 lowercase hexadecimal characters. |
| `publishedAt` | Real UTC calendar timestamp in the publisher's `YYYY-MM-DDTHH:mm:ss.sssZ` format, round-tripping through `Date.toISOString()`. |
| `packages` | Nonempty array of package objects with unique names, including exactly one `@diffexai/diffex`. |

Versions exclude prereleases, build metadata, leading `v`, leading zeroes, surrounding whitespace, and trailing newlines.
Components are decimal integers without a JavaScript safe-integer ceiling; the publisher compares them with `BigInt`.
Consumers must reject unsupported numeric ranges rather than rounding them or constructing a different installation target.

Each package has exactly `name`, `version`, `tarball`, and `integrity`.
Names use lowercase npm-style names, optionally scoped, matching `(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*` in full, with at most 214 characters.
Every package version equals the record version, including the canonical CLI package.
The Worker does not hardcode the workspace package inventory; verifying completeness against the source checkout belongs to release CI.
Package order has no meaning.

Tarballs are canonical absolute HTTPS URLs that round-trip through `URL.href`, without embedded username, password, or fragment.
Integrity is one `sha256`, `sha384`, or `sha512` SRI digest with canonical padded base64 encoding and the corresponding 32, 48, or 64 decoded bytes.
Empty values, malformed encodings, unsupported algorithms, and multiple digests are rejected.
Tarball URLs, integrity, commit, timestamp, and workspace inventory stay out of the public latest-version response.

The publisher validates new records before writing either key and validates existing records before using them for repeat publication or latest-pointer comparisons.
Existing malformed records fail closed and are not treated as missing.
Publication still requires npm verification, immutable versioned records, and conditional latest-pointer writes that cannot regress the version.

## HTTP behavior

| Condition | Status | JSON body | Additional header |
| ------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------- | ----------------- |
| Valid stored release | `200` | `{"version":"<verified version>","packageName":"@diffexai/diffex"}` | None |
| Latest object absent | `404` | `{"error":"no_published_release"}` | None |
| Invalid JSON, invalid record, read/body failure, or inaccessible storage | `503` | `{"error":"release_unavailable"}` | None |
| Method other than `GET` | `405` | `{"error":"method_not_allowed"}` | `Allow: GET` |

All responses use `Content-Type: application/json; charset=utf-8` and `Cache-Control: no-store`.
The HTTP runtime suppresses the body for `HEAD`, while preserving the `405` status and headers.
Method rejection takes precedence over release availability and should happen before storage access.
Only absence of the fixed latest object means no published release; JSON `null`, denied access, malformed content, and a missing binding mean unavailable.
Internal exception messages must never enter responses.
The endpoint has no caller-dependent version filtering, authentication, cookies, or credential selection and ignores query parameters.
Equal, older, and newer callers receive the same verified release.
There is no npm-tag, Pi, binary URL, or other fallback authority.
Unsupported paths, including trailing-slash and suffix variants, remain `404 {"error":"not_found"}` at the Worker routing boundary.

## Ownership and conformance

The cloud implementation lives in `services/public-api/src/release-contract.ts` and `latest-version-response.ts`.
The CLI counterpart lives in `packages/coding-agent/src/utils/release-contract.ts` and is used by the publisher.
The cloud fixture corpus is `services/public-api/fixtures/release-contract.json`; its CLI copy is `scripts/fixtures/release-contract.json`.
This document is mirrored as cloud `docs/release-contract.md` and CLI `docs/release/contract.md`.
The fixtures are synthetic examples, never proof of an actual published version.

When either side changes, update both copies of this document and the fixture corpus, run both repositories' contract tests and required quality gates, and include companion PR links.
From the cloud root, compare the two checkouts with:

```sh
node scripts/check-release-contract.mjs /path/to/diffex-new
```

Run cloud contract tests from `services/public-api`:

```sh
npm run test -- tests/unit/release-contract.test.ts
npm run check
```

Run publisher tests from the CLI root and consumer fixture tests from `packages/coding-agent`:

```sh
node --test scripts/release-contract.test.mjs scripts/publish-release-announcement.test.mjs
node ../../node_modules/vitest/dist/cli.js --run test/release-contract.test.ts
```

The contract corpus covers required fields and types, stable version exclusions, package identity and uniqueness, lockstep versions, timestamp, commit, URL, integrity, unknown fields, public projection, and all HTTP outcomes.
Command-level publisher tests validate actual emitted records against the corpus rules with controlled npm responses and intercepted storage operations.
All temporary publisher fixtures are removed after testing, and these checks need no staging or production credentials.

## Integration status

Record validation and response projection are defined and tested locally.
The existing Worker shell continues returning unavailable until R2 reads and lookup outcome mapping are connected.
The live CLI checker still uses its previous authority and parsing behavior; the strict consumer parser is available for the separately planned endpoint migration.
Its existing update-note behavior has not been changed by defining a response with no note field.
There is no deployed R2-to-HTTP-to-CLI E2E path in this change, and fixture tests do not claim deployed routing or live installation readiness.
The landing application and authenticated Render `/v1` contracts require no change for this contract definition.
4 changes: 4 additions & 0 deletions docs/release/publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ If enforced read-only storage access is required, configure a separate read-only

## Publication invariants

The [stable release contract](contract.md) specifies the exact stored schema and public HTTP projection.
The publisher now enforces that schema for both new and existing records.
The consumer fixture suite prepares strict parsing for the future endpoint migration.

Before any write, the publisher inventories every non-private workspace package and requires its version to match the requested canonical stable semver.
It checks exact npm package identity and version, integrity metadata, and tarball availability for all packages.
Requests have a 30-second timeout, npm propagation retries have a ten-minute budget, and storage commands have a one-minute timeout.
Expand Down
103 changes: 103 additions & 0 deletions packages/coding-agent/src/utils/release-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Kept in agreement with the CLI publisher through the shared contract fixtures.
export const RELEASE_PACKAGE_NAME = "@diffexai/diffex";

export interface ReleasePackage {
name: string;
version: string;
tarball: string;
integrity: string;
}

export interface ReleaseRecord {
schemaVersion: 1;
version: string;
sourceCommit: string;
publishedAt: string;
packages: ReleasePackage[];
}

export interface LatestVersion {
version: string;
packageName: typeof RELEASE_PACKAGE_NAME;
}

function matches(value: unknown, pattern: RegExp): value is string {
return typeof value === "string" && pattern.exec(value)?.[0] === value;
}

export function isStableVersion(value: unknown): value is string {
return matches(value, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
}

function hasFields(value: unknown, fields: string[]): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length === fields.length &&
fields.every((field) => Object.hasOwn(value, field))
);
}

function isTarball(value: unknown): value is string {
if (typeof value !== "string" || value.trim() !== value) return false;
try {
const url = new URL(value);
return url.protocol === "https:" && url.href === value && !url.username && !url.password && !url.hash;
} catch {
return false;
}
}

function isIntegrity(value: unknown): value is string {
if (typeof value !== "string") return false;
const match = /^(sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/.exec(value);
if (!match || match[0] !== value) return false;
try {
const decoded = atob(match[2]);
return decoded.length === Number(match[1].slice(3)) / 8 && btoa(decoded) === match[2];
} catch {
return false;
}
}

export function isReleaseRecord(value: unknown): value is ReleaseRecord {
if (
!hasFields(value, ["schemaVersion", "version", "sourceCommit", "publishedAt", "packages"]) ||
value.schemaVersion !== 1 ||
!isStableVersion(value.version) ||
!matches(value.sourceCommit, /^[a-f0-9]{40}$/) ||
!matches(value.publishedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) ||
!Number.isFinite(Date.parse(value.publishedAt)) ||
new Date(value.publishedAt).toISOString() !== value.publishedAt ||
!Array.isArray(value.packages) ||
value.packages.length === 0
) {
return false;
}

const names = new Set<string>();
for (const pkg of value.packages) {
if (
!hasFields(pkg, ["name", "version", "tarball", "integrity"]) ||
!matches(pkg.name, /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/) ||
pkg.name.length > 214 ||
names.has(pkg.name) ||
pkg.version !== value.version ||
!isTarball(pkg.tarball) ||
!isIntegrity(pkg.integrity)
) {
return false;
}
names.add(pkg.name);
}
return names.has(RELEASE_PACKAGE_NAME);
}

export function isLatestVersion(value: unknown): value is LatestVersion {
return (
hasFields(value, ["version", "packageName"]) &&
isStableVersion(value.version) &&
value.packageName === RELEASE_PACKAGE_NAME
);
}
13 changes: 13 additions & 0 deletions packages/coding-agent/test/release-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { isLatestVersion } from "../src/utils/release-contract.ts";

const fixtures = JSON.parse(
readFileSync(new URL("../../../scripts/fixtures/release-contract.json", import.meta.url), "utf8"),
) as { responses: { name: string; status: number; body: unknown; valid: boolean }[] };

describe("Diffex latest-version consumer contract", () => {
it.each(fixtures.responses)("validates $name", (fixture) => {
expect(fixture.status === 200 && isLatestVersion(fixture.body)).toBe(fixture.valid);
});
});
Loading