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
13 changes: 12 additions & 1 deletion .github/workflows/verify-release-publication.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ on:
type: boolean
default: false

check_client_canaries:
description: Verify isolated source CLI startup and self-update against staging
type: boolean
default: false

permissions:
contents: read

Expand All @@ -29,6 +34,9 @@ jobs:
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Install canary dependencies without lifecycle scripts
if: inputs.check_client_canaries
run: npm ci --ignore-scripts
- name: Verify AWS CLI
run: aws --version
- name: Check controlled publisher regressions
Expand All @@ -40,8 +48,11 @@ jobs:
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: 'true'
CHECK_PUBLIC_API: ${{ inputs.check_public_api }}
CHECK_CLIENT_CANARIES: ${{ inputs.check_client_canaries }}
run: |
if [ "$CHECK_PUBLIC_API" = "true" ]; then
if [ "$CHECK_CLIENT_CANARIES" = "true" ]; then
node scripts/verify-release-publication.mjs --staging --client-canaries
elif [ "$CHECK_PUBLIC_API" = "true" ]; then
node scripts/verify-release-publication.mjs --staging --public-api
else
node scripts/verify-release-publication.mjs --staging
Expand Down
110 changes: 110 additions & 0 deletions docs/release/client-canaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Staging release client acceptance

Verified on 2026-09-10 with Node 22.19.0 and AWS CLI 1.46.1.
All 21 source CLI canaries passed against real R2 and the deployed staging Worker.
The CLI source is based on `9b6f2dcab` from PR #204, with the reusable canary harness added on `feat/staging-version-canaries`.
The cloud source and regression checks use `01c1205` from Diffex Cloud PR #145.
The [cloud staging record](https://github.com/diffexai/diffex-cloud/blob/feat/serve-verified-releases/docs/public-api-staging.md) documents the existing deployment; this run made no Worker deployment.

## Executed command and boundary

From the CLI repository root, with the dedicated staging credentials supplied through the environment:

```sh
node scripts/verify-release-publication.mjs --staging --client-canaries
```

The real publisher verifies controlled npm responses for all 11 public workspace packages and writes only the private `diffex-artifacts-staging` bucket in account `6b24afa849f8254ab5e6e2d1956239cc`.
It created synthetic versions `0.1789086098745.0` and `0.1789086098745.1`; these are test fixtures, not published npm releases.
Every client requested `GET https://api.diffex.ai/api/latest-version` with manual redirect handling.
Only the test preload remapped that exact URL to `https://diffex-public-api-staging.yibai14651.workers.dev/api/latest-version`.
Actual sockets were restricted to that hostname on port 443.
Requests were anonymous, with no cookies or R2 credentials supplied to the clients.
Every response had a JSON content type and `Cache-Control: no-store`.

Manual commands ran the source CLI with `update --self`, adding `--force` where listed.
Separate temporary manifests modeled installed versions below, equal to, and above the staging record.
Interactive clients ran with `--no-session --no-extensions --no-skills` in a real 100x30 PTY, waited for the staging response and welcome screen, and exited through `/exit` without submitting a model prompt.
The harness removed every temporary home, project, credential directory, event record, and simulated installation prefix.

## Observed behavior

| Canary | Version requests | HTTP | Exit | Install attempts |
| --- | --- | --- | --- | --- |
| missing release | 1 | 404 | 1 | 0 |
| forced missing release | 1 | 404 | 1 | 0 |
| interactive missing release | 1 | 404 | 0 | 0 |
| newer release | 1 | 200 | 0 | 1 |
| equal release | 1 | 200 | 0 | 0 |
| older release | 1 | 200 | 0 | 0 |
| forced equal release | 1 | 200 | 0 | 1 |
| forced older release | 1 | 200 | 0 | 1 |
| manual automatic opt-out | 1 | 200 | 0 | 1 |
| manual offline | 0 | none | 1 | 0 |
| interactive newer release | 1 | 200 | 0 | 0 |
| interactive offline | 0 | none | 0 | 0 |
| interactive automatic opt-out | 0 | none | 0 | 0 |
| forced invalid JSON | 3 | 503 | 1 | 0 |
| interactive unavailable | 1 | 503 | 0 | 0 |
| forced wrong schema | 3 | 503 | 1 | 0 |
| forced prerelease semver | 3 | 503 | 1 | 0 |
| forced missing canonical package | 3 | 503 | 1 | 0 |
| forced duplicate canonical package | 3 | 503 | 1 | 0 |
| forced package version mismatch | 3 | 503 | 1 | 0 |
| forced foreign package | 3 | 503 | 1 | 0 |

Missing releases printed `No published Diffex release is available yet.` and exited 1 after one request.
Invalid stored records produced HTTP 503 and `Diffex update service is unavailable. Try again later.`, with three manual requests and no package commands even under force.
Interactive failures stayed quiet and made at most one version request.
Equal and older releases printed `already up to date` unless forced.
Offline mode suppressed all version requests, while automatic opt-out still allowed manual update.

All four intercepted install attempts matched:

```sh
npm --prefix <isolated-prefix> install -g --ignore-scripts --min-release-age=0 @diffexai/diffex@0.1789086098745.0
```

No package-manager process executed, no uninstall was attempted, and no installed software changed.
The preload intercepted the separate `https://pi.dev/api/report-install` call during non-offline startup and returned a controlled failure without forwarding it.
There was no Pi version-check traffic or any Pi socket connection.
Install telemetry, model catalogs, and Radius remain separate migration work.

## Regression and artifact checks

The focused checker, source CLI, package-command, and consumer-contract suites passed all 155 tests before the harness extraction.
The changed source CLI test file passed all 32 cases after extraction, and the final full isolated `./test.sh` run passed.
That full run includes the publisher and shared contract corpus, all changed tests, and default tests with live E2E disabled.
The checker tests cover invalid HTTP JSON, unsupported semver, exact identity validation, timeouts, HTTP errors, all supported redirect statuses without forwarding, retry limits, offline mode, and automatic opt-out.
The stored-record corpus covers wrong schemas, missing and duplicate packages, and identity/version mismatches.

The CLI `npm run check`, workflow validation with actionlint 1.7.12, and Cloud-to-CLI contract document and fixture comparisons passed.
Cloud root `npm run check` passed 546 unit and 23 integration tests and its typecheck, lint, formatting, database, build, and audit checks.
The isolated Worker `npm run check` passed 335 tests, typecheck, lint, formatting, staging deployment dry run, and audit.

The initial local artifact scan found the historical Pi endpoint in `packages/coding-agent/dist/utils/version-check.js`.
With explicit owner approval, `npm run build` from `packages/coding-agent` refreshed the local artifact without publishing or modifying generated files manually.
The final scan returned no matches:

```sh
rg -n 'pi\.(dev|codes)/api/latest-version' packages/coding-agent/src packages/coding-agent/dist --glob '!*.map'
```

Both `src/utils/version-check.ts` and rebuilt `dist/utils/version-check.js` contain the canonical Diffex URL.
Generated build output remains untracked and is not included in the PR.

## Cleanup and limits

The run verified byte-preserving repeated publication, failed npm verification before and after publication, conditional advancement, older-announcement rejection, and interrupted-publication recovery.
Cleanup verified these exact keys absent before releasing the lock:

- `releases/v1/latest.json`
- `releases/v1/releases/0.1789086098745.0.json`
- `releases/v1/releases/0.1789086098745.1.json`
- `verification/release-publication.lock`

The final staging latest response was HTTP 404 `no_published_release`, and readiness was HTTP 200 with `release: "missing"`.
No production object, route, DNS record, Worker, Render service, landing application, npm package, or GitHub Release was changed.
The opt-in workflow input becomes dispatchable after the workflow reaches the default branch; this evidence comes from running the checked-in command locally.
These are source CLI canaries with intercepted installation, not acceptance of an installed published client or public binary distribution.
Production activation and real installation remain separately authorized release work.
27 changes: 26 additions & 1 deletion docs/release/publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ If cleanup fails, the lock remains for investigation; inspect its recorded keys
Do not empty the bucket or delete another run's records.

`.github/workflows/verify-release-publication.yml` exposes the same verification as a manual-only workflow using the staging environment.
It requires no dependency installation and is excluded from default PR CI.
Publisher-only verification requires no dependency installation and is excluded from default PR CI.
The optional client canaries install the locked dependencies with lifecycle scripts disabled.
Dispatch it from `main` once the workflow is merged; adding a workflow on a pull request does not make it dispatchable from the default branch.

## Verification evidence
Expand Down Expand Up @@ -145,3 +146,27 @@ Both release keys, the latest pointer, and the shared lock were confirmed absent
The production latest record remained absent.
The [cloud staging acceptance record](https://github.com/diffexai/diffex-cloud/blob/feat/serve-verified-releases/docs/public-api-staging.md) records Worker deployment identities, the separate missing-binding failure and recovery test, and unchanged Render routing.
This proves publisher-to-R2-to-HTTP behavior; the installed CLI authority migration and canary remain separate work.

## Source CLI staging canaries

Use the staging-only publication credentials and an AWS CLI supporting conditional writes:

```sh
node scripts/verify-release-publication.mjs --staging --client-canaries
```

The manual workflow exposes this as `check_client_canaries`, disabled by default, and automatically includes the HTTP checks.
The command uses the same exclusive staging lock, ownership checks, conditional writes, and verified cleanup as publication verification.
It adds manual and interactive source CLI clients while each controlled record is present in real R2.
Each client gets an isolated home, project, credentials directory, and simulated npm prefix.
The test-only preload maps the exact production version URL to the fixed staging HTTPS endpoint and rejects redirects.
It permits sockets only to that staging hostname on port 443, intercepts every package-manager subprocess, and never passes storage credentials into the client.
The production client has no endpoint override.

Canaries cover missing releases, newer/equal/older releases, normal and forced updates, manual retries, offline mode, automatic opt-out, and quiet interactive startup and `/exit` in a real 100x30 terminal.
Malformed JSON, wrong schema, prerelease versions, missing or duplicate canonical packages, package-version mismatches, and foreign identities must produce an unavailable response and no install even with force.
Every successful install attempt must match the complete expected npm argument list, pinned to `@diffexai/diffex@<validated-version>` without uninstall.
The JSON evidence captures requested and actual URLs, response status and public body, socket destination, intercepted install command, and verified local cleanup.
Install telemetry is intercepted separately without network traffic; its Pi dependency remains separate migration work.
The source canary does not establish published-package installation or binary acceptance.
See [the client canary acceptance record](client-canaries.md) for executed results and remaining limits.
156 changes: 156 additions & 0 deletions packages/coding-agent/test/fixtures/version-update-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { stripVTControlCharacters } from "node:util";

const packageDir = resolve(import.meta.dirname, "../..");
const repositoryDir = resolve(packageDir, "../..");
const packageJson = readFileSync(join(packageDir, "package.json"), "utf8");
export const installed = JSON.parse(packageJson) as { name: string; version: string };
export const endpoint = "https://api.diffex.ai/api/latest-version";

export interface RecordedEvent {
type: string;
status?: number;
body?: unknown;
cacheControl?: string;
contentType?: string;
host?: string;
port?: number;
url?: string;
method?: string;
userAgent?: string;
redirect?: string;
command?: string;
args?: string[];
}

export async function reproduce(options: {
staging?: boolean;
installedVersion?: string;
interactive?: boolean;
force?: boolean;
offline?: boolean;
skip?: boolean;
failure?: boolean;
packageName?: string;
version?: string;
response?: string;
status?: number;
redirect?: string;
}) {
const root = mkdtempSync(join(tmpdir(), "diffex-version-repro-"));
const agentDir = join(root, "agent");
const recordPath = join(root, "events.jsonl");
const homeDir = join(root, "home");
const cwd = join(root, "project");
for (const dir of [agentDir, homeDir, cwd]) mkdirSync(dir);
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark", packages: [] }));
writeFileSync(recordPath, "");
const env: NodeJS.ProcessEnv = {
PATH: process.env.PATH,
HOME: homeDir,
USERPROFILE: homeDir,
TMPDIR: root,
XDG_CONFIG_HOME: join(homeDir, ".config"),
XDG_CACHE_HOME: join(homeDir, ".cache"),
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
TSX_TSCONFIG_PATH: join(repositoryDir, "tsconfig.json"),
DIFFEX_CODING_AGENT_DIR: agentDir,
DIFFEX_NO_LOCAL_LLM: "1",
DIFFEX_REPRO_RECORD: recordPath,
DIFFEX_REPRO_STAGING: options.staging ? "1" : "",
DIFFEX_REPRO_VERSION: options.version ?? (options.force ? installed.version : "9999.0.0"),
DIFFEX_REPRO_PACKAGE: options.packageName ?? "@foreign-fixture/untrusted-cli",
DIFFEX_REPRO_RESPONSE: options.response,
DIFFEX_REPRO_STATUS: String(options.status ?? 200),
DIFFEX_REPRO_REDIRECT: options.redirect,
DIFFEX_REPRO_FAILURE: options.failure ? "1" : "",
DIFFEX_OFFLINE: options.offline ? "1" : "",
DIFFEX_SKIP_VERSION_CHECK: options.skip ? "1" : "",
};
if (!options.interactive) {
const fakePackageDir = join(root, "lib", "node_modules", ...installed.name.split("/"));
mkdirSync(fakePackageDir, { recursive: true });
writeFileSync(
join(fakePackageDir, "package.json"),
JSON.stringify({ ...installed, version: options.installedVersion ?? installed.version }),
);
env.DIFFEX_PACKAGE_DIR = fakePackageDir;
}
const nodeArgs = [
`--import=${import.meta.resolve("tsx")}`,
`--import=${join(import.meta.dirname, "version-update-guard.mjs")}`,
join(packageDir, "src/cli.ts"),
...(options.interactive ? ["--no-session", "--no-extensions", "--no-skills"] : ["update", "--self"]),
...(options.force ? ["--force"] : []),
];
const child = options.interactive
? spawn("python3", [join(import.meta.dirname, "version-update-terminal.py"), process.execPath, ...nodeArgs], {
cwd,
env,
stdio: "pipe",
})
: spawn(process.execPath, nodeArgs, { cwd, env, stdio: "pipe" });
let output = "";
let quitSent = false;
let deadlineReached = false;
const completion = new Promise<number | null>((resolveExit, reject) => {
child.once("error", reject);
child.once("close", resolveExit);
});
const sendExit = () => {
const awaitingResponse =
options.staging &&
!options.offline &&
!options.skip &&
!readFileSync(recordPath, "utf8").includes('"type":"staging-response"');
if (
options.interactive &&
!awaitingResponse &&
!quitSent &&
stripVTControlCharacters(output).includes("Diffex v")
) {
quitSent = true;
child.stdin.write("/exit\r");
}
};
const exitPoll = setInterval(sendExit, 50);
const capture = (chunk: Buffer) => {
output += chunk.toString();
sendExit();
};
child.stdout.on("data", capture);
child.stderr.on("data", capture);
const timeout = setTimeout(() => {
deadlineReached = true;
child.kill("SIGTERM");
}, 20_000);
try {
const code = await completion;
const events = readFileSync(recordPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as RecordedEvent);
assert.equal(deadlineReached, false, stripVTControlCharacters(output));
assert.deepEqual(
events.filter((event) => event.type === "blocked-network" || event.type === "blocked-process"),
[],
output,
);
return { code, events, output: stripVTControlCharacters(output), quitSent, prefix: root };
} finally {
clearTimeout(timeout);
clearInterval(exitPoll);
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGTERM");
await completion.catch(() => {});
}
rmSync(root, { recursive: true, force: true });
assert.equal(existsSync(root), false);
}
}
30 changes: 29 additions & 1 deletion packages/coding-agent/test/fixtures/version-update-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ import { appendFileSync } from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { Socket } from "node:net";
import { basename, dirname, join } from "node:path";
import { Agent, fetch as requestStaging } from "undici";
import "../../src/core/http-dispatcher.ts";

const recordPath = process.env.DIFFEX_REPRO_RECORD;
if (!recordPath) throw new Error("The reproduction requires an isolated event record");
const record = (event) => appendFileSync(recordPath, `${JSON.stringify(event)}\n`);

const staging = process.env.DIFFEX_REPRO_STAGING === "1";
const stagingUrl = "https://diffex-public-api-staging.yibai14651.workers.dev/api/latest-version";
const stagingHost = new URL(stagingUrl).hostname;
const dispatcher = staging ? new Agent() : undefined;

globalThis.fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
const headers = new Headers(init?.headers);
Expand All @@ -22,6 +28,22 @@ globalThis.fetch = async (input, init) => {
redirect: init?.redirect,
});
if (url !== "https://api.diffex.ai/api/latest-version") return new Response(null, { status: 503 });
if (staging) {
if ((init?.method ?? "GET") !== "GET" || init?.redirect !== "manual") {
throw new Error("Unexpected staging request options");
}
const response = await requestStaging(stagingUrl, { ...init, dispatcher, redirect: "manual" });
const body = await response.text();
record({
type: "staging-response",
url: stagingUrl,
status: response.status,
body,
cacheControl: response.headers.get("cache-control"),
contentType: response.headers.get("content-type"),
});
return new Response(body, { status: response.status, headers: response.headers });
}
if (process.env.DIFFEX_REPRO_FAILURE === "1") throw new Error("controlled network failure");
if (process.env.DIFFEX_REPRO_RESPONSE) {
return new Response(process.env.DIFFEX_REPRO_RESPONSE, {
Expand All @@ -39,7 +61,13 @@ globalThis.fetch = async (input, init) => {
};

// Prevent alternate HTTP clients from reaching the network too.
Socket.prototype.connect = function () {
const originalConnect = Socket.prototype.connect;
Socket.prototype.connect = function (...args) {
const options = Array.isArray(args[0]) ? args[0][0] : args[0];
if (staging && options?.host === stagingHost && Number(options.port) === 443) {
record({ type: "staging-connect", host: stagingHost, port: 443 });
return originalConnect.apply(this, args);
}
record({ type: "blocked-network" });
throw new Error("Network disabled by the version-update reproduction");
};
Expand Down
Loading
Loading