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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ jobs:
# Twice: once readable in the log, once as annotations on the pull request's diff.
"$RUNNER_TEMP/oasdiff" breaking "$RUNNER_TEMP/base-openapi.yaml" api/openapi.yaml --flatten-allof --err-ignore "$accepted" --warn-ignore "$accepted" || true
"$RUNNER_TEMP/oasdiff" breaking "$RUNNER_TEMP/base-openapi.yaml" api/openapi.yaml --flatten-allof --fail-on WARN --format githubactions --err-ignore "$accepted" --warn-ignore "$accepted"
# The live channels (api/asyncapi.yaml). The differences come from the AsyncAPI CLI, pinned in
# api/asyncapi-tools with its own lockfile so only this job installs it; what counts as breaking
# is decided in api/asyncapi-diff.ts, whose tests run here against real edits to the file.
- name: Install the AsyncAPI CLI
run: bun install --cwd api/asyncapi-tools --frozen-lockfile
- name: The live channels' breaking-change check works
run: bun test api/asyncapi-diff.test.ts
- name: No breaking change to the live channels against the base branch
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
git fetch --no-tags --depth=1 origin "$BASE_REF"
if ! git cat-file -e FETCH_HEAD:api/asyncapi.yaml 2>/dev/null; then
echo "::notice::$BASE_REF has no api/asyncapi.yaml yet, so this pull request cannot break it."
exit 0
fi
git show FETCH_HEAD:api/asyncapi.yaml > "$RUNNER_TEMP/base-asyncapi.yaml"
bun run api:live:diff "$RUNNER_TEMP/base-asyncapi.yaml" api/asyncapi.yaml

mobile:
name: native types
Expand Down
10 changes: 10 additions & 0 deletions api/accepted-live-breaking-changes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Accepted breaking changes to the live channels

CI fails a pull request on any change to `api/asyncapi.yaml` that could break a client (see
`api/asyncapi-diff.ts` for what counts). A line here lets exactly one such change through: it is
matched by the change's action and its path in the file, so any other change still fails. Add a line
only for a change that takes nothing from a client, and say why on the line: the date, the change in
backticks as the check prints it, and the reason.

Once the change is on the base branch, the check no longer reports it and its line matches nothing.

169 changes: 169 additions & 0 deletions api/asyncapi-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { expect, test } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { acceptedChanges, differences, failing } from "./asyncapi-diff";

/**
* The breaking-change check, held to real changes to the real file: each test edits a copy of
* api/asyncapi.yaml the way a pull request would, runs the AsyncAPI CLI's diff on the two, and says
* whether the check must fail. Needs the CLI (bun install --cwd api/asyncapi-tools); without it the
* tests are skipped, and CI's API job, which installs it, runs them.
*/

const SPEC = join(import.meta.dir, "asyncapi.yaml");
const installed = existsSync(
join(import.meta.dir, "asyncapi-tools/node_modules/.bin/asyncapi"),
);

/** The changes that fail, after one edit to the file. */
function failuresAfter(from: string, to: string, accepted = ""): string[] {
const original = readFileSync(SPEC, "utf8");
expect(original).toContain(from);
const head = join(mkdtempSync(join(tmpdir(), "asyncapi-diff-")), "head.yaml");
writeFileSync(head, original.replace(from, to));
return failing(differences(SPEC, head), acceptedChanges(accepted)).map(
(change) => `${change.action} ${change.path}`,
);
}

const slow = { timeout: 120_000 };

test.skipIf(!installed)(
"the file against itself breaks nothing",
() => {
expect(failuresAfter("asyncapi: 3.1.0", "asyncapi: 3.1.0")).toEqual([]);
},
slow,
);

test.skipIf(!installed)(
"moving a socket's address fails",
() => {
expect(
failuresAfter(
"address: /api/channels/events",
"address: /api/channels/happenings",
),
).toEqual(["edit /channels/legacyConversationEvents/address"]);
},
slow,
);

test.skipIf(!installed)(
"taking a message off a channel fails",
() => {
const original = readFileSync(SPEC, "utf8");
const inChannel =
' screenText:\n $ref: "#/components/messages/ScreenText"\n';
const inOperation =
' - $ref: "#/channels/legacyBotScreen/messages/screenText"\n';
expect(original).toContain(inChannel);
expect(original).toContain(inOperation);
const head = join(
mkdtempSync(join(tmpdir(), "asyncapi-diff-")),
"head.yaml",
);
writeFileSync(
head,
original.replace(inChannel, "").replace(inOperation, ""),
);
const failed = failing(differences(SPEC, head)).map(
(change) => `${change.action} ${change.path}`,
);
expect(failed).toContain(
"remove /channels/legacyBotScreen/messages/screenText",
);
},
slow,
);

test.skipIf(!installed)(
"making a payload member required fails",
() => {
expect(
failuresAfter(
" - lastMessageAgentId\n properties:",
" - lastMessageAgentId\n - summary\n properties:",
),
).toEqual(["add /components/schemas/ConversationActivity/required/5"]);
},
slow,
);

test.skipIf(!installed)(
"changing a payload member's type fails",
() => {
expect(
failuresAfter(
" confirmFrames:\n type: boolean",
" confirmFrames:\n type: string",
),
).toEqual([
"edit /components/schemas/ScreenHello/properties/confirmFrames/type",
]);
},
slow,
);

test.skipIf(!installed)(
"removing a payload member fails",
() => {
expect(
failuresAfter(
" fatal:\n type: boolean\n description: True when connecting again will not help.\n",
"",
),
).toEqual(["remove /components/schemas/TerminalError/properties/fatal"]);
},
slow,
);

test.skipIf(!installed)(
"a new optional payload member passes",
() => {
expect(
failuresAfter(
" fatal:\n type: boolean\n",
" code:\n type: string\n fatal:\n type: boolean\n",
),
).toEqual([]);
},
slow,
);

test.skipIf(!installed)(
"rewording a description passes",
() => {
expect(
failuresAfter(
"description: The conversation.",
"description: The conversation it is about.",
),
).toEqual([]);
},
slow,
);

test.skipIf(!installed)(
"an accepted change passes, and only that change",
() => {
const accepted =
"- 2026-11-14 `edit /components/schemas/ScreenHello/properties/confirmFrames/type` for the test.";
expect(
failuresAfter(
" confirmFrames:\n type: boolean",
" confirmFrames:\n type: string",
accepted,
),
).toEqual([]);
expect(
failuresAfter(
"address: /api/channels/events",
"address: /api/channels/happenings",
accepted,
),
).toEqual(["edit /channels/legacyConversationEvents/address"]);
},
slow,
);
155 changes: 155 additions & 0 deletions api/asyncapi-diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* The breaking-change check for api/asyncapi.yaml: `bun api/asyncapi-diff.ts <base.yaml> <head.yaml>`.
*
* The differences come from the AsyncAPI CLI's `diff` (pinned in api/asyncapi-tools), which wraps
* the AsyncAPI Initiative's diff library. That library is still 0.x, and what it knows is the
* AsyncAPI document's own structure: it calls a changed channel address, a removed channel or a
* removed message breaking, and calls every change inside a message's payload schema
* "unclassified", because a JSON Schema can be any depth and its rules are written per path.
*
* So this file relies on the library for one thing, the list of what changed and where, and decides
* what a payload change means itself, strictly: a change is let through only when it is known to
* take nothing from a client (new things, a new optional member, wording). Anything else fails,
* unless api/accepted-live-breaking-changes.md lists it with its reason.
*/
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

export type Change = {
action: "add" | "remove" | "edit";
path: string;
type: "breaking" | "non-breaking" | "unclassified";
};

/** Wording: changing it changes no frame. */
const WORDING = new Set(["description", "summary", "title", "examples"]);

/**
* Whether a path is a second copy of a change. The diff works on the document with every $ref
* followed, so one change to a schema is reported where the schema is defined and again under every
* message, channel and operation that uses it. The definition is judged; the copies are not.
*/
function isCopy(path: string): boolean {
return (
/^\/components\/messages\/[^/]+\/payload\/./.test(path) ||
/^\/channels\/[^/]+\/messages\/[^/]+\/./.test(path) ||
/^\/operations\/[^/]+\/messages\/[^/]+\/./.test(path) ||
/^\/operations\/[^/]+\/channel\/./.test(path) ||
/\/x-parser-[^/]+$/.test(path)
);
}

/**
* Whether a change is to wording only, on a schema, message, channel or operation. A payload member
* that happens to be called "description" is a member, not wording.
*/
function isWording(path: string): boolean {
const segments = path.split("/").slice(1);
const last = segments[segments.length - 1] ?? "";
const parent = segments[segments.length - 2] ?? "";
return (
(WORDING.has(last) && parent !== "properties") ||
segments.includes("examples")
);
}

/** An addition known to take nothing from a client. */
function isSafeAddition(change: Change): boolean {
if (change.action !== "add") return false;
// Something new: a channel, an operation, a message, a server, a component.
if (/^\/(channels|operations|servers)\/[^/]+$/.test(change.path)) return true;
if (/^\/channels\/[^/]+\/messages\/[^/]+$/.test(change.path)) return true;
if (/^\/operations\/[^/]+\/messages\/[^/]+$/.test(change.path)) return true;
if (/^\/components\/[^/]+\/[^/]+$/.test(change.path)) return true;
// A new member of a payload. If it is also made required, that is its own change, under
// `required`, and is not let through here.
return /^\/components\/schemas\/.+\/properties\/[^/]+$/.test(change.path);
}

/** The lines of the accepted file: each names one change as `action path`. */
export function acceptedChanges(markdown: string): Set<string> {
const accepted = new Set<string>();
for (const line of markdown.split("\n")) {
if (!line.startsWith("- ")) continue;
const named = /`(add|remove|edit) (\/[^`]+)`/.exec(line);
if (named) accepted.add(`${named[1]} ${named[2]}`);
}
return accepted;
}

/** The changes that fail the check. */
export function failing(
changes: readonly Change[],
accepted: ReadonlySet<string> = new Set(),
): Change[] {
return changes.filter((change) => {
if (isCopy(change.path)) return false;
if (accepted.has(`${change.action} ${change.path}`)) return false;
if (isWording(change.path)) return false;
if (change.type === "breaking") return true;
if (change.type === "non-breaking") return false;
return !isSafeAddition(change);
});
}

/** Every difference between two files, as the AsyncAPI CLI reports them. */
export function differences(base: string, head: string): Change[] {
const cli = join(
import.meta.dir,
"asyncapi-tools/node_modules/.bin/asyncapi",
);
if (!existsSync(cli)) {
throw new Error(
"The AsyncAPI CLI is not installed. Run: bun install --cwd api/asyncapi-tools --frozen-lockfile",
);
}
const run = spawnSync(
cli,
["diff", base, head, "--type", "all", "--format", "json", "--no-error"],
{
encoding: "utf8",
maxBuffer: 256 * 1024 * 1024,
env: { ...process.env, ASYNCAPI_ANALYTICS: "false", CI: "true" },
},
);
const start = run.stdout.indexOf("{");
if (run.status !== 0 || start < 0) {
throw new Error(
`asyncapi diff failed (exit ${run.status}):\n${run.stdout}\n${run.stderr}`,
);
}
const report = JSON.parse(run.stdout.slice(start)) as { changes: Change[] };
return report.changes;
}

if (import.meta.main) {
const [base, head] = process.argv.slice(2);
if (!base || !head) {
console.error("usage: bun api/asyncapi-diff.ts <base.yaml> <head.yaml>");
process.exit(2);
}
const accepted = acceptedChanges(
readFileSync(
join(import.meta.dir, "accepted-live-breaking-changes.md"),
"utf8",
),
);
const failed = failing(differences(base, head), accepted);
if (failed.length === 0) {
console.log("No change to api/asyncapi.yaml breaks a client.");
process.exit(0);
}
for (const change of failed) {
console.error(`${change.type}: \`${change.action} ${change.path}\``);
if (process.env.GITHUB_ACTIONS) {
console.error(
`::error file=api/asyncapi.yaml::${change.type} change: ${change.action} ${change.path}`,
);
}
}
console.error(
`\n${failed.length} change(s) to api/asyncapi.yaml could break a client. If one takes nothing from a client, list it with its reason in api/accepted-live-breaking-changes.md.`,
);
process.exit(1);
}
Loading
Loading