Files
nexu-io__open-design/tools/release/tests/prerelease-fallback-notice-cli.test.ts
lefarcen faba302437 feat(release): alert through the other Feishu bot when the prerelease card goes silent (#7873)
* feat(release): alert through the other Feishu bot when the prerelease card goes silent

The progressive card is now the only prerelease notification, and every way it
breaks is silent: the pipeline stays green, the packages ship, and the channel
hears nothing. The watcher catches Feishu failures on purpose — one hiccup must
not end a two-hour watch — so expired application-bot credentials look exactly
like a healthy run.

Adds a last-resort notice delivered through the custom-bot webhook, which is a
different bot with a different secret, so a credential failure on the card side
cannot take the alert down with it. It fires only when the card did not reach
the group: the watcher now reports `card_delivered` for a card whose FINAL state
is in the chat, and the gate reads that rather than the job result.

Two callers, mutually exclusive so a release can never draw both:
release-prerelease-card.yml's `fallback_notice` job covers a watcher that died
or never delivered; steps at the end of release-prerelease.yml's
`dispatch_validation` cover a card that was never dispatched. The latter are
steps, not a job, because nothing may appear in a `needs:` on the dispatchers.

* fix(release): keep the fallback notice reachable past the dispatch it reports

The two steps that actually deliver the notice carried no status function, so
GitHub's implicit success() skipped them whenever anything earlier in the job
had failed. `dispatch-validation.sh` exits non-zero when it cannot dispatch the
card on any ref — which marks the job failed — so on the one path the
dispatch-side fallback exists for, it was silent. This PR was written to close
the implicit-success() trap at the job level and walked into its step-level twin.

Both consumers now carry `always() && steps.notice.outcome == 'success'`: the
status function so a failed dispatch still reaches them, and the outcome check
so neither a crashed nor a skipped composer can hand feishu-notice.ts an empty
body. The composer writes `alert` last, after every field a poster reads, so
`alert=true` in an append-only $GITHUB_OUTPUT means the whole notice is on disk.

Adds the step-level reachability rule as a spec beside the existing job-level
one, and puts the dispatched prerelease lanes into that job-level sweep —
release-prerelease-card.yml was outside it, so `fallback_notice`, whose `if`
hand-checks `needs.card.result`, was uncovered by the rule it has to obey.
2026-09-08 08:24:00 +00:00

89 lines
3.8 KiB
TypeScript

import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// The composer hands its verdict to the workflow through $GITHUB_OUTPUT, and
// the workflow branches on one key. Both properties below are about that file,
// so they are exercised through the real script rather than the pure module.
const releaseRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const composerScript = join(releaseRoot, "src", "notifications", "prerelease-fallback-notice.ts");
async function compose(outputFile: string, env: Record<string, string>): Promise<number> {
await writeFile(outputFile, "");
return await new Promise<number>((resolve, reject) => {
const child = spawn(process.execPath, ["--experimental-strip-types", composerScript], {
env: {
...process.env,
CHANNEL_LABEL: "Prerelease",
GITHUB_OUTPUT: outputFile,
ORIGIN_RUN_URL: "https://github.com/nexu-io/open-design/actions/runs/111",
// No public origin and a declared pipeline state: the composer then
// needs no network at all, so this test cannot reach out.
PIPELINE_PROGRESS: "finished",
VERSION: "0.21.1-prerelease.3",
...env,
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.resume();
child.stderr.resume();
child.on("error", reject);
child.on("close", (code) => resolve(code ?? -1));
});
}
/** Keys in the order the script appended them, ignoring heredoc bodies. */
function keyOrder(raw: string): string[] {
const keys: string[] = [];
const lines = raw.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const match = /^([a-z_]+)<<(od-fallback-[0-9a-f-]+)$/.exec(lines[index] ?? "");
if (match?.[1] == null || match[2] == null) continue;
keys.push(match[1]);
while (index < lines.length && lines[index + 1] !== match[2]) index += 1;
}
return keys;
}
describe("prerelease fallback composer CLI", () => {
let workdir = "";
beforeEach(async () => {
workdir = await mkdtemp(join(tmpdir(), "od-fallback-cli-"));
});
afterEach(async () => {
await rm(workdir, { force: true, recursive: true });
});
it("emits no notice payload at all on a healthy release", async () => {
const outputFile = join(workdir, "healthy.txt");
expect(await compose(outputFile, { STAGE: "watch", CARD_JOB_RESULT: "success", CARD_DELIVERED: "true" })).toBe(0);
const raw = await readFile(outputFile, "utf8");
expect(keyOrder(raw)).toEqual(["reason", "alert"]);
expect(raw).toContain("false");
expect(raw).not.toContain("进度卡片失联");
});
it("writes `alert` only after every field a poster reads", async () => {
// The workflow branches on `alert`, and $GITHUB_OUTPUT is append-only. If
// `alert` went first, a crash between two appends would leave `alert=true`
// beside an empty body and feishu-notice.ts would die on a required env
// instead of delivering. Written last, `alert=true` means the whole notice
// is on disk — which is what makes the workflow's gate safe.
const outputFile = join(workdir, "silent.txt");
expect(await compose(outputFile, { STAGE: "watch", CARD_JOB_RESULT: "success", CARD_DELIVERED: "" })).toBe(0);
const order = keyOrder(await readFile(outputFile, "utf8"));
expect(order).toContain("alert");
expect(order).toContain("body");
expect(order.indexOf("alert")).toBe(order.length - 1);
for (const key of ["reason", "title", "template", "body", "run_url"]) {
expect(order.indexOf(key), `${key} must be written before alert`).toBeLessThan(order.indexOf("alert"));
}
});
});