mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
faba302437
* 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.
89 lines
3.8 KiB
TypeScript
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"));
|
|
}
|
|
});
|
|
});
|