mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
477 lines
20 KiB
TypeScript
477 lines
20 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
import {
|
|
WHATS_NEW_DOCUMENT_PATH,
|
|
checkWhatsNewDocument,
|
|
} from "../../../scripts/check-whats-new-document.ts";
|
|
import {
|
|
WHATS_NEW_WORKFLOW_PATH,
|
|
findWhatsNewWorkflowViolations,
|
|
parseWorkflowJobs,
|
|
workflowJobNames,
|
|
} from "../../../scripts/check-whats-new-publish-workflow.ts";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
|
|
// The daemon resolves a malformed What's New document to "no highlight"
|
|
// instead of an error, so every failure mode below is invisible at publish
|
|
// time: the upload succeeds and the card simply never appears. These cases
|
|
// exist to prove the guard can actually see each of them — a guard that only
|
|
// ever runs against the good document is an untested guard.
|
|
async function documentRoot(document: string): Promise<string> {
|
|
const root = await mkdtemp(path.join(tmpdir(), "whats-new-guard-"));
|
|
await mkdir(path.join(root, path.dirname(WHATS_NEW_DOCUMENT_PATH)), { recursive: true });
|
|
await writeFile(path.join(root, WHATS_NEW_DOCUMENT_PATH), document, "utf8");
|
|
return root;
|
|
}
|
|
|
|
const validDocument = {
|
|
id: "1.2.3",
|
|
title: "Headline",
|
|
body: "First bullet.\nSecond bullet.",
|
|
imageUrl: "https://whatsnew.open-design.ai/cover.webp",
|
|
linkUrl: "https://open-design.ai/release/",
|
|
locales: {
|
|
"zh-CN": { title: "标题", body: "第一条。\n第二条。", linkUrl: "https://open-design.ai/zh/release/" },
|
|
},
|
|
};
|
|
|
|
async function check(document: unknown): Promise<boolean> {
|
|
return checkWhatsNewDocument(await documentRoot(JSON.stringify(document, null, 2)));
|
|
}
|
|
|
|
describe("what's new document guard", () => {
|
|
let consoleError: ReturnType<typeof vi.spyOn>;
|
|
let consoleLog: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleError.mockRestore();
|
|
consoleLog.mockRestore();
|
|
});
|
|
|
|
test("the repository document is publishable", async () => {
|
|
await expect(checkWhatsNewDocument(repoRoot)).resolves.toBe(true);
|
|
});
|
|
|
|
test("a well-formed document passes", async () => {
|
|
await expect(check(validDocument)).resolves.toBe(true);
|
|
});
|
|
|
|
test("accepts configurable base and localized CTA labels", async () => {
|
|
await expect(check({
|
|
...validDocument, ctaLabel: "View Arena",
|
|
locales: { "zh-CN": { ctaLabel: "查看评测站" } },
|
|
})).resolves.toBe(true);
|
|
});
|
|
|
|
test.each(["", " ", 42, null])("rejects invalid CTA labels (%j) before publishing", async (ctaLabel) => {
|
|
await expect(check({ ...validDocument, ctaLabel })).resolves.toBe(false);
|
|
await expect(check({
|
|
...validDocument, locales: { "zh-CN": { title: "标题", ctaLabel } },
|
|
})).resolves.toBe(false);
|
|
});
|
|
|
|
test("the empty retirement document passes", async () => {
|
|
await expect(check({})).resolves.toBe(true);
|
|
});
|
|
|
|
test("invalid JSON fails", async () => {
|
|
await expect(checkWhatsNewDocument(await documentRoot("{ not json }"))).resolves.toBe(false);
|
|
});
|
|
|
|
test("a missing document fails", async () => {
|
|
await expect(checkWhatsNewDocument(await mkdtemp(path.join(tmpdir(), "whats-new-guard-")))).resolves.toBe(false);
|
|
});
|
|
|
|
test.each(["id", "title", "body"] as const)("a missing %s fails", async (field) => {
|
|
const { [field]: _dropped, ...rest } = validDocument;
|
|
await expect(check(rest)).resolves.toBe(false);
|
|
});
|
|
|
|
test("an empty required string fails", async () => {
|
|
await expect(check({ ...validDocument, title: " " })).resolves.toBe(false);
|
|
});
|
|
|
|
test("a misspelled field fails instead of being silently ignored", async () => {
|
|
const { imageUrl: cover, ...rest } = validDocument;
|
|
await expect(check({ ...rest, imageURL: cover })).resolves.toBe(false);
|
|
});
|
|
|
|
test("a non-https imageUrl fails", async () => {
|
|
await expect(check({ ...validDocument, imageUrl: "http://whatsnew.open-design.ai/cover.webp" })).resolves.toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a non-https locale linkUrl fails", async () => {
|
|
await expect(
|
|
check({
|
|
...validDocument,
|
|
locales: { "zh-CN": { ...validDocument.locales["zh-CN"], linkUrl: "open-design.ai/zh/" } },
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
test("a locale whose overrides are all invalid fails", async () => {
|
|
await expect(check({ ...validDocument, locales: { "zh-CN": { title: "" } } })).resolves.toBe(false);
|
|
});
|
|
|
|
test("a blank bullet line fails", async () => {
|
|
await expect(check({ ...validDocument, body: "First bullet.\n\nSecond bullet." })).resolves.toBe(false);
|
|
});
|
|
|
|
test("the check is registered in the public guard entrypoint", () => {
|
|
const names = execFileSync("pnpm", ["--silent", "guard", "--list-checks"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
})
|
|
.trim()
|
|
.split("\n");
|
|
expect(names).toContain("what's new document");
|
|
});
|
|
});
|
|
|
|
describe("what's new publisher", () => {
|
|
// The publisher's own gate: a green upload means nothing unless the object
|
|
// is read back from the origin the daemon fetches, and that read-back is
|
|
// only evidence if a wrong body turns it red. The self-check asserts both
|
|
// halves against a local origin.
|
|
test("the read-back gate and the JSON gate can both fail", () => {
|
|
const result = spawnSync("python3", [path.join(repoRoot, ".github/scripts/publish_whats_new.py"), "self-check"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.stderr ?? "").toBe("");
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain("self-check passed");
|
|
});
|
|
|
|
// A malformed document must be rejected before anything else happens, so a
|
|
// bad edit cannot reach the bucket even if the credentials are present.
|
|
test("an invalid document is rejected before credentials are considered", async () => {
|
|
const root = await documentRoot("{ not json }");
|
|
const result = spawnSync("python3", [path.join(repoRoot, ".github/scripts/publish_whats_new.py")], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
WHATS_NEW_DOCUMENT: WHATS_NEW_DOCUMENT_PATH,
|
|
WHATS_NEW_DRY_RUN: "false",
|
|
WHATS_NEW_STORAGE_ENDPOINT: "",
|
|
WHATS_NEW_STORAGE_BUCKET: "",
|
|
WHATS_NEW_STORAGE_ACCESS_KEY_ID: "",
|
|
WHATS_NEW_STORAGE_SECRET_ACCESS_KEY: "",
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("not valid JSON");
|
|
});
|
|
});
|
|
|
|
describe("what's new publish branch policy", () => {
|
|
async function runGate(jobName: string, stepName: string, ref: string, dryRun = false) {
|
|
const workflow = await readFile(path.join(repoRoot, WHATS_NEW_WORKFLOW_PATH), "utf8");
|
|
const job = parseWorkflowJobs(workflow)?.jobs.get(jobName) as {
|
|
steps: Array<{ name: string; run?: string }>;
|
|
};
|
|
const script = job.steps.find((step) => step.name === stepName)?.run;
|
|
expect(script).toBeTypeOf("string");
|
|
const scratch = await mkdtemp(path.join(tmpdir(), "whats-new-ref-"));
|
|
try {
|
|
const output = path.join(scratch, "output");
|
|
const result = spawnSync("bash", ["-c", script!.replaceAll("${{ github.sha }}", "a".repeat(40))], {
|
|
encoding: "utf8",
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
REPOSITORY: "nexu-io/open-design",
|
|
EVENT_NAME: "workflow_dispatch",
|
|
REF: ref,
|
|
DRY_RUN: String(dryRun),
|
|
GITHUB_OUTPUT: output,
|
|
},
|
|
});
|
|
return {
|
|
...result,
|
|
mode: jobName === "validate" && result.status === 0 ? await readFile(output, "utf8") : "",
|
|
};
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test.each(["refs/heads/main", "refs/heads/release/v0.22.0", "refs/heads/release/v12.3.45"])(
|
|
"%s passes both real-publish gates",
|
|
async (ref) => {
|
|
const mode = await runGate("validate", "Resolve run mode", ref);
|
|
expect(mode.status, mode.stderr).toBe(0);
|
|
expect(mode.mode).toBe("publish=true\n");
|
|
const publish = await runGate("publish", "Assert reviewed ref", ref);
|
|
expect(publish.status, publish.stderr).toBe(0);
|
|
},
|
|
);
|
|
|
|
test.each([
|
|
"refs/heads/feature/whats-new",
|
|
"refs/tags/release/v0.22.0",
|
|
"refs/heads/release/v0.22.0-copy",
|
|
"refs/heads/release/v0.22",
|
|
"refs/heads/release/v01.22.0",
|
|
"refs/heads/release/v0.22.0/extra",
|
|
])("%s is rejected before a real publish", async (ref) => {
|
|
for (const [job, step] of [["validate", "Resolve run mode"], ["publish", "Assert reviewed ref"]]) {
|
|
const result = await runGate(job!, step!, ref);
|
|
expect(result.status, result.stdout).toBe(1);
|
|
expect(result.stderr).toContain(ref);
|
|
}
|
|
});
|
|
|
|
test("an arbitrary branch can still preview without enabling publish", async () => {
|
|
const result = await runGate("validate", "Resolve run mode", "refs/heads/feature/whats-new", true);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.mode).toBe("publish=false\n");
|
|
});
|
|
});
|
|
|
|
// The card reaches every installed client the moment it is published, so
|
|
// "published" must imply "reviewed". Nothing written inside the workflow file
|
|
// can enforce that on its own: `workflow_dispatch` runs the workflow from the
|
|
// ref it is dispatched against, so every check in it is editable by whoever
|
|
// triggers it. The control is the `whats-new-publish` environment — main and
|
|
// trusted release branches, R2 credentials as secrets on it — and that control
|
|
// only holds while the workflow keeps a specific shape: the job that can reach the
|
|
// credentials is the job that declares the environment, and it cannot start
|
|
// unless validation succeeded.
|
|
//
|
|
// Moving `environment:` or a `secrets.*` expression onto the job that runs
|
|
// from arbitrary refs reopens the hole with no visible symptom — the workflow
|
|
// still publishes correctly from `main`. Each case below mutates the real
|
|
// workflow into exactly one of those regressions and requires the checker to
|
|
// name it, so the assertions cannot pass by never looking at anything.
|
|
describe("what's new publish workflow trust boundary", () => {
|
|
let workflow: string;
|
|
|
|
beforeEach(async () => {
|
|
workflow = await readFile(path.join(repoRoot, WHATS_NEW_WORKFLOW_PATH), "utf8");
|
|
});
|
|
|
|
/**
|
|
* Apply one textual regression. Asserting the edit landed is the point: a
|
|
* mutation that silently no-ops would leave the "this goes red" case green
|
|
* for the wrong reason.
|
|
*/
|
|
function mutate(source: string, find: string, replace: string): string {
|
|
expect(source).toContain(find);
|
|
const mutated = source.replace(find, replace);
|
|
expect(mutated).not.toBe(source);
|
|
return mutated;
|
|
}
|
|
|
|
test("the shipped workflow satisfies the boundary", () => {
|
|
expect(findWhatsNewWorkflowViolations(workflow)).toEqual([]);
|
|
});
|
|
|
|
test("the parser sees the two real jobs, so the assertions are not vacuous", () => {
|
|
expect(workflowJobNames(workflow)).toEqual(["validate", "publish"]);
|
|
const parsed = parseWorkflowJobs(workflow);
|
|
expect(parsed).not.toBeNull();
|
|
expect(JSON.stringify(parsed?.jobs.get("validate"))).toContain("scripts/check-whats-new-document.ts");
|
|
expect(JSON.stringify(parsed?.jobs.get("publish"))).toContain("publish_whats_new.py");
|
|
});
|
|
|
|
// The regression the reviewer named: the environment expression drifts off
|
|
// the credential-bearing job and onto the one that any ref can dispatch.
|
|
test("moving the environment onto the arbitrary-ref job is rejected", () => {
|
|
const lifted = mutate(workflow, " environment: whats-new-publish\n", "");
|
|
const onValidate = mutate(
|
|
lifted,
|
|
" validate:\n name: validate document\n",
|
|
" validate:\n name: validate document\n environment: whats-new-publish\n",
|
|
);
|
|
const reported = findWhatsNewWorkflowViolations(onValidate).join("\n");
|
|
expect(reported).toContain("job `validate` declares environment `whats-new-publish`");
|
|
expect(reported).toContain("job `publish` declares environment (none)");
|
|
});
|
|
|
|
test("a mistyped environment name is rejected", () => {
|
|
const mutated = mutate(workflow, "environment: whats-new-publish", "environment: whats-new-publish-2");
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain("it must be exactly `whats-new-publish`");
|
|
});
|
|
|
|
test("dropping the environment entirely is rejected", () => {
|
|
const mutated = mutate(workflow, " environment: whats-new-publish\n", "");
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain("declares environment (none)");
|
|
});
|
|
|
|
test("unhooking publish from validate is rejected", () => {
|
|
const mutated = mutate(workflow, " needs: validate\n", "");
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain("must declare `needs: validate`");
|
|
});
|
|
|
|
test("publishing past a failed validate is rejected", () => {
|
|
const mutated = mutate(
|
|
workflow,
|
|
" if: needs.validate.outputs.publish == 'true'",
|
|
" if: always() && needs.validate.outputs.publish == 'true'",
|
|
);
|
|
const reported = findWhatsNewWorkflowViolations(mutated).join("\n");
|
|
expect(reported).toContain("uses `always()`");
|
|
});
|
|
|
|
test("a dry run that can still publish is rejected", () => {
|
|
const mutated = mutate(workflow, " if: needs.validate.outputs.publish == 'true'\n", "");
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain(
|
|
"must be gated on `if: needs.validate.outputs.publish == 'true'`",
|
|
);
|
|
});
|
|
|
|
test.each(["CLOUDFLARE_R2_WHATS_NEW_AK", "CLOUDFLARE_R2_WHATS_NEW_BUCKET"] as const)(
|
|
"reading %s from the arbitrary-ref job is rejected",
|
|
(secret) => {
|
|
const mutated = mutate(
|
|
workflow,
|
|
" - name: Setup Node.js",
|
|
` - name: Leak\n env:\n STOLEN: \${{ secrets.${secret} }}\n run: env\n\n - name: Setup Node.js`,
|
|
);
|
|
const reported = findWhatsNewWorkflowViolations(mutated).join("\n");
|
|
expect(reported).toContain("job `validate` reads the `secrets` context");
|
|
expect(reported).toContain(secret);
|
|
},
|
|
);
|
|
|
|
/** A step that exfiltrates whatever `expression` resolves to. */
|
|
function leakStep(expression: string): string {
|
|
return [" - name: Leak", " env:", ` STOLEN: ${expression}`, " run: env", ""].join(
|
|
"\n",
|
|
);
|
|
}
|
|
|
|
/** Splice a step into `validate`, the job any ref can dispatch. */
|
|
function intoValidate(source: string, step: string): string {
|
|
return mutate(source, " - name: Setup Node.js", `${step}\n - name: Setup Node.js`);
|
|
}
|
|
|
|
// GitHub's expression language reaches a secret in more shapes than
|
|
// `secrets.NAME`, and a detector that enumerates shapes is only ever as good
|
|
// as the list someone thought of. Each of these was verified to slip past
|
|
// dot-notation matching, returning an empty violation list — in the guard
|
|
// that exists specifically to stop a secret reaching the arbitrary-ref job.
|
|
test.each([
|
|
["single-quoted bracket", "${{ secrets['UNRELATED_SECRET'] }}"],
|
|
["double-quoted bracket", '${{ secrets["UNRELATED_SECRET"] }}'],
|
|
["bracket with inner whitespace", "${{ secrets [ 'UNRELATED_SECRET' ] }}"],
|
|
["dot with surrounding whitespace", "${{ secrets . UNRELATED_SECRET }}"],
|
|
["the whole context serialized", "${{ toJSON(secrets) }}"],
|
|
["the whole context bare", "${{ secrets }}"],
|
|
["the whole context round-tripped", "${{ fromJSON(toJSON(secrets)).UNRELATED_SECRET }}"],
|
|
["an expression folded across lines", "${{\n secrets.UNRELATED_SECRET\n }}"],
|
|
] as const)("a secret reaching the arbitrary-ref job as %s is rejected", (_shape, expression) => {
|
|
const mutated = intoValidate(workflow, leakStep(expression));
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain("job `validate` reads");
|
|
});
|
|
|
|
// The one shape no raw-text scanner can see: a double-quoted YAML scalar
|
|
// with a backslash line continuation. The source never contains the string
|
|
// `secrets`; the parsed value does. Only reading the document as YAML — not
|
|
// as text — closes this.
|
|
test("a secret hidden by a YAML line continuation is rejected", () => {
|
|
const hidden = [' STOLEN: "${{ sec\\', " rets.UNRELATED_SECRET }}\""].join("\n");
|
|
const step = [" - name: Leak", " env:", hidden, " run: env", ""].join("\n");
|
|
const mutated = intoValidate(workflow, step);
|
|
// Precondition: the giveaway substring is genuinely absent from the source,
|
|
// so a passing assertion below cannot be text matching by accident.
|
|
expect(mutated).not.toContain("secrets.UNRELATED_SECRET");
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain("job `validate` reads");
|
|
});
|
|
|
|
test.each([
|
|
["a run: block", " - name: Leak\n run: echo ${{ secrets['UNRELATED_SECRET'] }}\n"],
|
|
[
|
|
"a with: input",
|
|
" - name: Leak\n uses: actions/github-script@v8\n with:\n github-token: ${{ secrets['UNRELATED_SECRET'] }}\n",
|
|
],
|
|
] as const)("a secret reaching the arbitrary-ref job through %s is rejected", (_where, step) => {
|
|
const mutated = intoValidate(workflow, step);
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain("job `validate` reads");
|
|
});
|
|
|
|
// `secrets:` as a job key passes credentials into a called workflow without
|
|
// any `${{ }}` expression at all, so expression scanning alone cannot see it.
|
|
test("passing secrets into a called workflow is rejected", () => {
|
|
// No `${{ }}` anywhere, so expression scanning alone cannot see this one.
|
|
const mutated = mutate(
|
|
workflow,
|
|
" validate:\n name: validate document\n",
|
|
" validate:\n name: validate document\n secrets:\n PASSED: literal\n",
|
|
);
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain("job `validate` declares a `secrets:` key");
|
|
});
|
|
|
|
// A document this guard cannot understand must fail, never pass quietly:
|
|
// "no violations found" and "nothing was examined" have to be distinct.
|
|
test.each([
|
|
["unparseable YAML", (source: string) => `${source}\n : : :\n`],
|
|
["a jobs mapping written in flow style", () => "name: x\njobs: {validate: {runs-on: a}, publish: {runs-on: b}}\n"],
|
|
["a renamed publish job", (source: string) => source.replace(" publish:\n", " upload:\n")],
|
|
] as const)("%s fails closed", (_shape, transform) => {
|
|
expect(findWhatsNewWorkflowViolations(transform(workflow)).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("any secret at all on the arbitrary-ref job is rejected, not just the R2 four", () => {
|
|
const mutated = mutate(
|
|
workflow,
|
|
" - name: Setup Node.js",
|
|
" - name: Leak\n env:\n STOLEN: ${{ secrets.GITHUB_TOKEN }}\n run: env\n\n - name: Setup Node.js",
|
|
);
|
|
const reported = findWhatsNewWorkflowViolations(mutated).join("\n");
|
|
expect(reported).toContain("job `validate` reads the `secrets` context");
|
|
expect(reported).toContain("GITHUB_TOKEN");
|
|
});
|
|
|
|
test("dropping one of the four credential bindings is rejected", () => {
|
|
const mutated = mutate(
|
|
workflow,
|
|
" WHATS_NEW_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_WHATS_NEW_BUCKET }}\n",
|
|
"",
|
|
);
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain(
|
|
"no longer reads `secrets.CLOUDFLARE_R2_WHATS_NEW_BUCKET`",
|
|
);
|
|
});
|
|
|
|
test("handing the whole secret store to a called workflow is rejected", () => {
|
|
const mutated = mutate(
|
|
workflow,
|
|
" validate:\n name: validate document\n",
|
|
" validate:\n name: validate document\n secrets: inherit\n",
|
|
);
|
|
expect(findWhatsNewWorkflowViolations(mutated).join("\n")).toContain("job `validate` declares a `secrets:` key");
|
|
});
|
|
|
|
// The boundary has to be checked by something that actually runs. An edit to
|
|
// `.github/workflows/whats-new-publish.yml` selects neither
|
|
// `web_tests_required` nor `ui_p0_validation_required`, so the `e2e_vitest`
|
|
// workload holding this file does NOT run for it. `pnpm guard` does, from
|
|
// the unconditional preflight job — which is why the assertions above live
|
|
// in a guard check rather than only here.
|
|
test("the check is registered in the public guard entrypoint", () => {
|
|
const names = execFileSync("pnpm", ["--silent", "guard", "--list-checks"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
})
|
|
.trim()
|
|
.split("\n");
|
|
expect(names).toContain("what's new publish workflow");
|
|
});
|
|
});
|