@intentius/chant 0.124.32 → 0.124.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ci-jobs.d.ts +4 -0
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +9 -2
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +18 -3
- package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
- package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle-health.js +150 -0
- package/dist/cli/handlers/lifecycle.d.ts +26 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.js +1 -1
- package/dist/cli/handlers/pr.d.ts +2 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +124 -2
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +16 -1
- package/dist/cli/handlers/release.d.ts +16 -1
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +83 -11
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +81 -21
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +23 -1
- package/dist/cli/registry.d.ts +18 -4
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-inputs.d.ts +91 -0
- package/dist/components/deploy-inputs.d.ts.map +1 -0
- package/dist/components/deploy-inputs.js +184 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +9 -0
- package/dist/components/gate-plan.d.ts +8 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +6 -0
- package/dist/components/pr-pipeline.d.ts +33 -10
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +42 -11
- package/dist/components/pr-run.d.ts +1 -1
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/release-compile.d.ts +37 -2
- package/dist/components/release-compile.d.ts.map +1 -1
- package/dist/components/release-compile.js +64 -8
- package/dist/components/release-promote.d.ts +17 -3
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +79 -18
- package/dist/components/verbs/bom-writer.d.ts +1 -1
- package/dist/components/verbs/bom-writer.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
- package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
- package/dist/components/wave-plan.d.ts +10 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +32 -4
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +32 -7
- package/dist/health.d.ts +110 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +105 -0
- package/dist/health.schema.json +191 -0
- package/dist/lexicon.d.ts +27 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/merge-record.d.ts +104 -0
- package/dist/lifecycle/merge-record.d.ts.map +1 -0
- package/dist/lifecycle/merge-record.js +102 -0
- package/dist/lifecycle/publish-attachments.d.ts +8 -5
- package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
- package/dist/lifecycle/publish-attachments.js +27 -13
- package/dist/lifecycle/publish-npm.d.ts +3 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +4 -3
- package/dist/lifecycle/release-at.d.ts +40 -0
- package/dist/lifecycle/release-at.d.ts.map +1 -0
- package/dist/lifecycle/release-at.js +83 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +23 -12
- package/dist/lifecycle/release-publish.d.ts +28 -12
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +29 -17
- package/dist/lifecycle/release-store.d.ts +25 -2
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +40 -13
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +1 -0
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +4 -2
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +3 -0
- package/dist/op/activities/shell.d.ts +38 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +89 -3
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +7 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +14 -3
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +15 -5
- package/dist/pr-report-url.d.ts +2 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/runner-start.d.ts +179 -0
- package/dist/runner-start.d.ts.map +1 -0
- package/dist/runner-start.js +277 -0
- package/dist/wave-runtime.mjs +235 -228
- package/dist/workspace/check.schema.json +70 -2
- package/dist/workspace/checks/security.d.ts +35 -0
- package/dist/workspace/checks/security.d.ts.map +1 -0
- package/dist/workspace/checks/security.js +305 -0
- package/dist/workspace/checks.d.ts +2 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +5 -0
- package/dist/workspace/ci-builds.d.ts +163 -0
- package/dist/workspace/ci-builds.d.ts.map +1 -0
- package/dist/workspace/ci-builds.js +315 -0
- package/dist/workspace/ci-green-cli.d.ts +2 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +25 -5
- package/dist/workspace/ci-green-forge.d.ts +12 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +12 -0
- package/dist/workspace/ci-green-workflow.d.ts +55 -1
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +128 -1
- package/dist/workspace/ci-green.d.ts +89 -4
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +310 -23
- package/dist/workspace/ci-last-green.schema.json +49 -0
- package/dist/workspace/ci-list.schema.json +35 -1
- package/dist/workspace/ci-run-index.d.ts +1 -1
- package/dist/workspace/ci-run-index.d.ts.map +1 -1
- package/dist/workspace/ci-run-index.schema.json +1 -1
- package/dist/workspace/ci-run.d.ts +12 -6
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +72 -16
- package/dist/workspace/ci-runner-start.d.ts +77 -0
- package/dist/workspace/ci-runner-start.d.ts.map +1 -0
- package/dist/workspace/ci-runner-start.js +185 -0
- package/dist/workspace/ci-serve.d.ts +76 -7
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +153 -19
- package/dist/workspace/declaration.d.ts +17 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +3 -0
- package/dist/workspace/declaration.schema.json +23 -0
- package/dist/workspace/ls.d.ts +2 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -1
- package/dist/workspace/ls.schema.json +2 -1
- package/dist/workspace/member-pipeline.d.ts +1 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +2 -0
- package/dist/workspace/merge-record.schema.json +49 -0
- package/dist/workspace/pr-report.schema.json +3 -3
- package/dist/workspace/reason-codes.d.ts +17 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +18 -0
- package/dist/workspace/release-candidates.d.ts +129 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -0
- package/dist/workspace/release-candidates.js +233 -0
- package/dist/workspace/release-candidates.schema.json +353 -0
- package/dist/workspace/security.d.ts +40 -0
- package/dist/workspace/security.d.ts.map +1 -0
- package/dist/workspace/security.js +99 -0
- package/package.json +8 -1
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
- package/src/ci-jobs.ts +11 -2
- package/src/cli/handlers/build.test.ts +36 -0
- package/src/cli/handlers/build.ts +18 -3
- package/src/cli/handlers/lifecycle-health.test.ts +123 -0
- package/src/cli/handlers/lifecycle-health.ts +157 -0
- package/src/cli/handlers/lifecycle.ts +1 -1
- package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
- package/src/cli/handlers/pr-record.test.ts +201 -0
- package/src/cli/handlers/pr.test.ts +22 -0
- package/src/cli/handlers/pr.ts +127 -4
- package/src/cli/handlers/promote.test.ts +17 -0
- package/src/cli/handlers/promote.ts +16 -1
- package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
- package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
- package/src/cli/handlers/release.e2e.test.ts +19 -28
- package/src/cli/handlers/release.ts +81 -11
- package/src/cli/main.ts +81 -21
- package/src/cli/parse-args.ts +17 -1
- package/src/cli/registry.ts +18 -4
- package/src/components/deploy-inputs.test.ts +108 -0
- package/src/components/deploy-inputs.ts +225 -0
- package/src/components/driver.ts +11 -0
- package/src/components/gate-plan.ts +9 -0
- package/src/components/pr-pipeline.ts +56 -15
- package/src/components/pr-run.ts +1 -1
- package/src/components/release-compile.test.ts +34 -0
- package/src/components/release-compile.ts +89 -8
- package/src/components/release-promote.test.ts +55 -2
- package/src/components/release-promote.ts +93 -23
- package/src/components/verbs/bom-writer.ts +1 -1
- package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
- package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
- package/src/components/wave-plan.ts +32 -4
- package/src/graph-detail.test.ts +57 -0
- package/src/graph-detail.ts +33 -7
- package/src/health.schema.json +191 -0
- package/src/health.test.ts +62 -0
- package/src/health.ts +178 -0
- package/src/lexicon.ts +41 -1
- package/src/lifecycle/merge-record.test.ts +111 -0
- package/src/lifecycle/merge-record.ts +154 -0
- package/src/lifecycle/publish-attachments.ts +25 -13
- package/src/lifecycle/publish-npm.ts +4 -3
- package/src/lifecycle/publish-targets.test.ts +34 -4
- package/src/lifecycle/release-at.test.ts +48 -0
- package/src/lifecycle/release-at.ts +100 -0
- package/src/lifecycle/release-cut.test.ts +41 -0
- package/src/lifecycle/release-cut.ts +22 -11
- package/src/lifecycle/release-publish.test.ts +43 -13
- package/src/lifecycle/release-publish.ts +38 -18
- package/src/lifecycle/release-store.ts +57 -13
- package/src/lifecycle/run-ledger.test.ts +13 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
- package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
- package/src/op/activities/activity-contracts.ts +3 -0
- package/src/op/activities/shell-container.e2e.test.ts +67 -0
- package/src/op/activities/shell.test.ts +44 -1
- package/src/op/activities/shell.ts +110 -3
- package/src/op/builders.ts +7 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/step-console.test.ts +4 -2
- package/src/pr-loop.test.ts +29 -0
- package/src/pr-loop.ts +26 -7
- package/src/pr-report-url.ts +2 -2
- package/src/runner-start-bundle.test.ts +29 -0
- package/src/runner-start.test.ts +179 -0
- package/src/runner-start.ts +329 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +70 -2
- package/src/workspace/checks/security.test.ts +138 -0
- package/src/workspace/checks/security.ts +305 -0
- package/src/workspace/checks.ts +5 -0
- package/src/workspace/ci-builds.e2e.test.ts +208 -0
- package/src/workspace/ci-builds.test.ts +174 -0
- package/src/workspace/ci-builds.ts +385 -0
- package/src/workspace/ci-green-builds.test.ts +180 -0
- package/src/workspace/ci-green-cli.test.ts +54 -0
- package/src/workspace/ci-green-cli.ts +26 -5
- package/src/workspace/ci-green-forge.ts +24 -0
- package/src/workspace/ci-green-workflow.test.ts +84 -1
- package/src/workspace/ci-green-workflow.ts +139 -2
- package/src/workspace/ci-green.test.ts +5 -2
- package/src/workspace/ci-green.ts +359 -27
- package/src/workspace/ci-last-green.schema.json +49 -0
- package/src/workspace/ci-list.schema.json +35 -1
- package/src/workspace/ci-run-index.schema.json +1 -1
- package/src/workspace/ci-run-index.ts +1 -1
- package/src/workspace/ci-run.test.ts +24 -7
- package/src/workspace/ci-run.ts +78 -16
- package/src/workspace/ci-runner-start.test.ts +151 -0
- package/src/workspace/ci-runner-start.ts +193 -0
- package/src/workspace/ci-serve-builds.test.ts +86 -0
- package/src/workspace/ci-serve.test.ts +59 -0
- package/src/workspace/ci-serve.ts +177 -22
- package/src/workspace/declaration.schema.json +23 -0
- package/src/workspace/declaration.test.ts +17 -0
- package/src/workspace/declaration.ts +22 -1
- package/src/workspace/ls.schema.json +2 -1
- package/src/workspace/ls.ts +3 -2
- package/src/workspace/member-pipeline.ts +2 -1
- package/src/workspace/merge-record.schema.json +49 -0
- package/src/workspace/pr-report.schema.json +3 -3
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +18 -0
- package/src/workspace/release-candidates.schema.json +353 -0
- package/src/workspace/release-candidates.test.ts +106 -0
- package/src/workspace/release-candidates.ts +315 -0
- package/src/workspace/security.test.ts +70 -0
- package/src/workspace/security.ts +118 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `release-candidates` read (#4168 4.2): green commits of trunk, each
|
|
3
|
+
* with the commits since the previous one, held to
|
|
4
|
+
* `release-candidates.schema.json`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { contract } from "./__fixtures__/contract-repo";
|
|
13
|
+
import candidatesSchema from "./release-candidates.schema.json";
|
|
14
|
+
import { prFromSubject, recordTrailers, releaseCandidatesDocument, RELEASE_CANDIDATES_SCHEMA_ID } from "./release-candidates";
|
|
15
|
+
import { appendCut, makeRelease, persistRelease } from "../lifecycle/release-store";
|
|
16
|
+
import { appendReleaseRecord } from "../lifecycle/release-ledger";
|
|
17
|
+
import { writeLedgerFiles } from "../lifecycle/git";
|
|
18
|
+
|
|
19
|
+
const { expectValid } = contract(candidatesSchema);
|
|
20
|
+
|
|
21
|
+
function git(dir: string, ...args: string[]): string {
|
|
22
|
+
const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
23
|
+
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
|
|
24
|
+
return r.stdout.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A repo on main with chant.config.ts declaring staging and prod, and one commit per subject. */
|
|
28
|
+
function repo(dir: string, subjects: string[]): string[] {
|
|
29
|
+
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@chant.dev"], ["config", "user.name", "T"]]) git(dir, ...args);
|
|
30
|
+
writeFileSync(join(dir, "chant.config.ts"), `export default { environments: ["staging", "prod"] };\n`);
|
|
31
|
+
git(dir, "add", ".");
|
|
32
|
+
git(dir, "commit", "-q", "-m", "init");
|
|
33
|
+
const shas = [git(dir, "rev-parse", "HEAD")];
|
|
34
|
+
for (const s of subjects) {
|
|
35
|
+
git(dir, "commit", "-q", "--allow-empty", "-m", s);
|
|
36
|
+
shas.push(git(dir, "rev-parse", "HEAD"));
|
|
37
|
+
}
|
|
38
|
+
return shas;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("recordTrailers and prFromSubject", () => {
|
|
42
|
+
test("read work ids, every record, and a squash subject's pull request", () => {
|
|
43
|
+
expect(recordTrailers({ "Chant-Record": ["work:w-1", "decision:d-2", "nonsense"] })).toEqual({ work: ["w-1"], records: ["work:w-1", "decision:d-2"] });
|
|
44
|
+
expect(prFromSubject("ci: a thing (#4168) (#4174)")).toBe(4174);
|
|
45
|
+
expect(prFromSubject("no number here")).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("releaseCandidatesDocument", () => {
|
|
50
|
+
test("partitions the walk at green commits, skips revoked ones, and says which environments each is new for", async () => {
|
|
51
|
+
await withTestDir(async (dir) => {
|
|
52
|
+
const [init, a, b, c, d, e] = repo(dir, ["a (#1)", "b\n\nChant-Record: work:w-7", "c", "d (#4)", "e"]);
|
|
53
|
+
for (const sha of [init, b, d]) git(dir, "tag", `ci/green/${sha}`);
|
|
54
|
+
git(dir, "tag", `ci/green/${c}`);
|
|
55
|
+
git(dir, "tag", `ci/revoked/${c}`);
|
|
56
|
+
|
|
57
|
+
// staging runs a release of init; prod runs nothing.
|
|
58
|
+
const r = makeRelease({ member: null, commit: init, artifactDigests: [], targets: [{ env: "staging", templates: [], params: [] }] });
|
|
59
|
+
await persistRelease(r, [], { cwd: dir });
|
|
60
|
+
await appendCut({ releaseDigest: r.releaseDigest, label: "v0", actor: "alice", timestamp: "2026-10-20T10:00:00.000Z", runOrigin: null }, { cwd: dir });
|
|
61
|
+
await appendReleaseRecord(
|
|
62
|
+
{ component: "api", env: "staging", digest: `sha256:${"a".repeat(64)}`, gitSha: init, runId: "r", timestamp: "2026-10-20T11:00:00.000Z", actor: "ci", releaseDigest: r.releaseDigest },
|
|
63
|
+
{ cwd: dir },
|
|
64
|
+
);
|
|
65
|
+
// A merge record for d names its pull request.
|
|
66
|
+
await writeLedgerFiles([{ path: `_merges/${d}.json`, content: JSON.stringify({ pr: 40, approvedDigest: "sha256:x" }) }], "merge record", { cwd: dir });
|
|
67
|
+
|
|
68
|
+
const doc = await releaseCandidatesDocument({ cwd: dir });
|
|
69
|
+
expectValid(doc);
|
|
70
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
71
|
+
expect(doc).toMatchObject({ $schema: RELEASE_CANDIDATES_SCHEMA_ID, contract: 1, branch: "main", tip: e, member: null });
|
|
72
|
+
expect(doc.environments).toEqual([
|
|
73
|
+
{ env: "staging", running: [{ releaseDigest: r.releaseDigest, label: "v0", commit: init, components: ["api"] }] },
|
|
74
|
+
{ env: "prod", running: [] },
|
|
75
|
+
]);
|
|
76
|
+
// prod runs nothing, so the walk is not bounded and reaches init.
|
|
77
|
+
expect(doc.since).toBeNull();
|
|
78
|
+
expect(doc.candidates.map((x) => x.commit)).toEqual([d, b, init]);
|
|
79
|
+
expect(doc.candidates[0]).toMatchObject({ newFor: ["staging", "prod"], cut: [] });
|
|
80
|
+
expect(doc.candidates[0].changes.map((x) => x.commit)).toEqual([d, c]);
|
|
81
|
+
expect(doc.candidates[0].changes[0]).toMatchObject({ pr: 40, merge: { pr: 40, approvedDigest: "sha256:x" } });
|
|
82
|
+
expect(doc.candidates[1].changes.map((x) => x.commit)).toEqual([b, a]);
|
|
83
|
+
expect(doc.candidates[1].changes[0].work).toEqual(["w-7"]);
|
|
84
|
+
expect(doc.candidates[1].changes[1].pr).toBe(1);
|
|
85
|
+
expect(doc.candidates[2]).toMatchObject({ commit: init, newFor: ["prod"], cut: ["v0"] });
|
|
86
|
+
expect(doc.skipped).toEqual([{ commit: c, subject: "c", reason: "revoked" }]);
|
|
87
|
+
expect(doc.pending.map((x) => x.commit)).toEqual([e]);
|
|
88
|
+
|
|
89
|
+
// --env staging stops at what staging runs.
|
|
90
|
+
const staging = await releaseCandidatesDocument({ cwd: dir, env: "staging" });
|
|
91
|
+
if ("error" in staging) throw new Error(staging.error.message);
|
|
92
|
+
expectValid(staging);
|
|
93
|
+
expect(staging.since).toBe(init);
|
|
94
|
+
expect(staging.candidates.map((x) => x.commit)).toEqual([d, b]);
|
|
95
|
+
expect(staging.candidates.every((x) => x.newFor.join() === "staging")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("outside git it is the failure not-a-git-repository", async () => {
|
|
100
|
+
await withTestDir(async (dir) => {
|
|
101
|
+
const doc = await releaseCandidatesDocument({ cwd: dir });
|
|
102
|
+
expectValid(doc);
|
|
103
|
+
expect(doc).toMatchObject({ error: { code: "not-a-git-repository" } });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `release-candidates` read (#4168 4.2): which green commits of trunk a
|
|
3
|
+
* person can cut a release from, and what each one would ship.
|
|
4
|
+
*
|
|
5
|
+
* `chant release candidates [--env <env>] [--json]` prints this document.
|
|
6
|
+
* It walks the first-parent line of the trunk (`ci.green.branch`, else what
|
|
7
|
+
* `origin/HEAD` names, as `release cut` resolves it) from its tip back to the
|
|
8
|
+
* oldest commit an environment of the member runs, or `limit` commits when an
|
|
9
|
+
* environment runs no release yet. An environment runs the releases its
|
|
10
|
+
* latest deploy records name in `releaseDigest`, as the `releases` read
|
|
11
|
+
* (./releases.ts) says.
|
|
12
|
+
*
|
|
13
|
+
* Every green commit that is not revoked is a candidate. Its `changes` are
|
|
14
|
+
* the first-parent commits after the previous candidate up to and including
|
|
15
|
+
* it, so the candidates partition the walk. A revoked commit is listed under
|
|
16
|
+
* `skipped`, and its changes carry on into the next candidate. Commits after
|
|
17
|
+
* the newest candidate are listed under `pending`: they are not green yet, or
|
|
18
|
+
* went red.
|
|
19
|
+
*
|
|
20
|
+
* For each change it names the work items and other records its
|
|
21
|
+
* `Chant-Record` trailers name, the pull request a forge's squash subject
|
|
22
|
+
* names (`... (#123)`), and the merge record `_merges/<sha>.json` on
|
|
23
|
+
* `chant/lifecycle` when one exists (#4168 4.1). Each candidate lists the
|
|
24
|
+
* flags whose record files changed in its range, from the declared flag kinds
|
|
25
|
+
* (#4024).
|
|
26
|
+
*
|
|
27
|
+
* It reads local refs and tags only and never fetches. Git is read through a
|
|
28
|
+
* local `git` subprocess.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { relative, resolve, sep } from "node:path";
|
|
33
|
+
import { latestPerComponent, readReleaseLedger } from "../lifecycle/release-ledger";
|
|
34
|
+
import { readAllReleases, readCuts } from "../lifecycle/release-store";
|
|
35
|
+
import { resolveMemberLedger } from "../lifecycle/member-ledger";
|
|
36
|
+
import { readBlobFromPath } from "../lifecycle/git";
|
|
37
|
+
import { readerVersion } from "./declaration";
|
|
38
|
+
|
|
39
|
+
export const RELEASE_CANDIDATES_CONTRACT_VERSION = 1;
|
|
40
|
+
export const RELEASE_CANDIDATES_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/release-candidates/v1/release-candidates.schema.json";
|
|
41
|
+
|
|
42
|
+
/** Why the read stopped. Closed; each code is in `reason-codes.ts`. */
|
|
43
|
+
export const RELEASE_CANDIDATES_ERROR_CODES = ["not-a-git-repository", "ledger-unreadable"] as const;
|
|
44
|
+
|
|
45
|
+
/** How many first-parent commits the walk reads when no environment bounds it. */
|
|
46
|
+
export const DEFAULT_CANDIDATE_LIMIT = 50;
|
|
47
|
+
|
|
48
|
+
/** A release an environment runs, by the components whose latest deploy names it. */
|
|
49
|
+
export interface RunningRelease {
|
|
50
|
+
releaseDigest: string;
|
|
51
|
+
label: string | null;
|
|
52
|
+
/** The commit it was cut from, or null when its release file is not on this clone's branch. */
|
|
53
|
+
commit: string | null;
|
|
54
|
+
components: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CandidateEnvironment {
|
|
58
|
+
env: string;
|
|
59
|
+
/** Newest cut first. Empty when no deploy there names a release. */
|
|
60
|
+
running: RunningRelease[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One first-parent commit in a candidate's range. */
|
|
64
|
+
export interface CandidateChange {
|
|
65
|
+
commit: string;
|
|
66
|
+
subject: string;
|
|
67
|
+
/** Author date, ISO 8601. */
|
|
68
|
+
date: string;
|
|
69
|
+
/** The ids its `Chant-Record: work:<id>` trailers name. */
|
|
70
|
+
work: string[];
|
|
71
|
+
/** Every `Chant-Record` trailer, as `<kind>:<id>`. */
|
|
72
|
+
records: string[];
|
|
73
|
+
/** The pull request the subject names (`(#123)` at its end), the merge record's, or null. */
|
|
74
|
+
pr: number | null;
|
|
75
|
+
/** The merge record `_merges/<sha>.json` (#4168 4.1), as stored, or null when there is none. */
|
|
76
|
+
merge: Record<string, unknown> | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CandidateFlag {
|
|
80
|
+
id: string;
|
|
81
|
+
member: string;
|
|
82
|
+
param: string;
|
|
83
|
+
/** The record file from the workspace root. */
|
|
84
|
+
path: string;
|
|
85
|
+
/** The value per environment, as the working tree's record writes it. */
|
|
86
|
+
values: Record<string, string | number | boolean>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface Candidate {
|
|
90
|
+
commit: string;
|
|
91
|
+
subject: string;
|
|
92
|
+
date: string;
|
|
93
|
+
/** The environments that don't run this commit or a later one yet. */
|
|
94
|
+
newFor: string[];
|
|
95
|
+
/** The labels of releases already cut from it. */
|
|
96
|
+
cut: string[];
|
|
97
|
+
changes: CandidateChange[];
|
|
98
|
+
flags: CandidateFlag[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type ReleaseCandidatesDocument =
|
|
102
|
+
| {
|
|
103
|
+
$schema: string;
|
|
104
|
+
contract: number;
|
|
105
|
+
chant: string;
|
|
106
|
+
member: string | null;
|
|
107
|
+
branch: string;
|
|
108
|
+
/** The tip of the branch as this clone knows it. */
|
|
109
|
+
tip: string | null;
|
|
110
|
+
environments: CandidateEnvironment[];
|
|
111
|
+
/** The oldest commit an environment runs, where the walk stopped, or null when it read `limit` commits. */
|
|
112
|
+
since: string | null;
|
|
113
|
+
/** Newest first. */
|
|
114
|
+
candidates: Candidate[];
|
|
115
|
+
/** Revoked green commits, newest first. */
|
|
116
|
+
skipped: Array<{ commit: string; subject: string; reason: "revoked" }>;
|
|
117
|
+
/** Commits after the newest candidate, newest first. */
|
|
118
|
+
pending: CandidateChange[];
|
|
119
|
+
}
|
|
120
|
+
| { $schema: string; contract: number; chant: string; error: { code: (typeof RELEASE_CANDIDATES_ERROR_CODES)[number]; message: string } };
|
|
121
|
+
|
|
122
|
+
export interface ReleaseCandidatesQuery {
|
|
123
|
+
cwd: string;
|
|
124
|
+
/** Only this environment's view: the walk stops at what it runs, and `newFor` names only it. */
|
|
125
|
+
env?: string;
|
|
126
|
+
limit?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function git(top: string, args: string[]): string {
|
|
130
|
+
return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function tryGit(top: string, args: string[]): string | null {
|
|
134
|
+
try {
|
|
135
|
+
return git(top, args);
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const PR_IN_SUBJECT = /\(#(\d+)\)\s*$/;
|
|
142
|
+
|
|
143
|
+
/** The work ids and `<kind>:<id>` records a commit's `Chant-Record` trailers name. */
|
|
144
|
+
export function recordTrailers(trailers: Record<string, string[]>): { work: string[]; records: string[] } {
|
|
145
|
+
const values = Object.entries(trailers)
|
|
146
|
+
.filter(([k]) => k.toLowerCase() === "chant-record")
|
|
147
|
+
.flatMap(([, v]) => v);
|
|
148
|
+
const records = values.map((v) => v.trim()).filter((v) => /^[^:\s]+:\S+$/.test(v));
|
|
149
|
+
const work = records.filter((r) => r.startsWith("work:")).map((r) => r.slice("work:".length));
|
|
150
|
+
return { work, records };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The pull request number at the end of a squash subject, or null. */
|
|
154
|
+
export function prFromSubject(subject: string): number | null {
|
|
155
|
+
const m = PR_IN_SUBJECT.exec(subject);
|
|
156
|
+
return m ? Number(m[1]) : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read the release candidates of the member at `query.cwd`. */
|
|
160
|
+
export async function releaseCandidatesDocument(query: ReleaseCandidatesQuery): Promise<ReleaseCandidatesDocument> {
|
|
161
|
+
const head = { $schema: RELEASE_CANDIDATES_SCHEMA_ID, contract: RELEASE_CANDIDATES_CONTRACT_VERSION, chant: readerVersion() };
|
|
162
|
+
const cwd = resolve(query.cwd);
|
|
163
|
+
const top = tryGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
164
|
+
if (!top) return { ...head, error: { code: "not-a-git-repository", message: "candidates are read from trunk's commits and tags, and this directory is not in a git repository" } };
|
|
165
|
+
try {
|
|
166
|
+
const { scopeSource } = await import("./write-scope");
|
|
167
|
+
const { resolveTrunk } = await import("../lifecycle/release-workstation");
|
|
168
|
+
const { judgedCiTags } = await import("../lifecycle/release-revocation");
|
|
169
|
+
const { commitDetails } = await import("./intent");
|
|
170
|
+
const source = scopeSource(cwd);
|
|
171
|
+
const green = source.declaration?.ci?.green ?? null;
|
|
172
|
+
const trunk = resolveTrunk(top, green?.branch ?? null);
|
|
173
|
+
const tip =
|
|
174
|
+
tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/remotes/${trunk.remote}/${trunk.branch}^{commit}`]) ??
|
|
175
|
+
tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${trunk.branch}^{commit}`]);
|
|
176
|
+
const ledger = await resolveMemberLedger(cwd);
|
|
177
|
+
const loc = { cwd };
|
|
178
|
+
|
|
179
|
+
// What each environment runs.
|
|
180
|
+
const { readMemberPromotionOrder } = await import("./promotion-order");
|
|
181
|
+
const order = ((await readMemberPromotionOrder(cwd)) ?? []).map((s) => s.name);
|
|
182
|
+
const envNames = query.env !== undefined ? [query.env] : order;
|
|
183
|
+
const { releases } = await readAllReleases(loc);
|
|
184
|
+
const { cuts } = await readCuts(loc);
|
|
185
|
+
const commitOf = new Map(releases.map((r) => [r.releaseDigest, r.commit]));
|
|
186
|
+
const labelOf = new Map<string, string>();
|
|
187
|
+
const cutAt = new Map<string, string>();
|
|
188
|
+
for (const c of cuts) {
|
|
189
|
+
if (c.label !== null && !labelOf.has(c.releaseDigest)) labelOf.set(c.releaseDigest, c.label);
|
|
190
|
+
if (!cutAt.has(c.releaseDigest) || c.timestamp > cutAt.get(c.releaseDigest)!) cutAt.set(c.releaseDigest, c.timestamp);
|
|
191
|
+
}
|
|
192
|
+
const environments: CandidateEnvironment[] = [];
|
|
193
|
+
for (const env of envNames) {
|
|
194
|
+
const { records } = await readReleaseLedger(env, loc);
|
|
195
|
+
const byDigest = new Map<string, string[]>();
|
|
196
|
+
for (const record of latestPerComponent(records).values()) {
|
|
197
|
+
const digest = (record as { releaseDigest?: unknown }).releaseDigest;
|
|
198
|
+
if (typeof digest !== "string") continue;
|
|
199
|
+
byDigest.set(digest, [...(byDigest.get(digest) ?? []), record.component].sort());
|
|
200
|
+
}
|
|
201
|
+
const running = [...byDigest]
|
|
202
|
+
.map(([releaseDigest, components]) => ({ releaseDigest, label: labelOf.get(releaseDigest) ?? null, commit: commitOf.get(releaseDigest) ?? null, components }))
|
|
203
|
+
.sort((a, b) => ((cutAt.get(b.releaseDigest) ?? "") < (cutAt.get(a.releaseDigest) ?? "") ? -1 : 1));
|
|
204
|
+
environments.push({ env, running });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const empty = { ...head, member: ledger.members.length > 0 ? ledger.members.join("/") : null, branch: trunk.branch, tip, environments, since: null, candidates: [], skipped: [], pending: [] };
|
|
208
|
+
if (!tip) return empty;
|
|
209
|
+
|
|
210
|
+
// The oldest commit any environment runs bounds the walk, when every environment runs one.
|
|
211
|
+
const limit = query.limit ?? DEFAULT_CANDIDATE_LIMIT;
|
|
212
|
+
const line = git(top, ["rev-list", "--first-parent", `--max-count=${Math.max(limit, 1) * 20}`, tip]).split("\n").filter(Boolean);
|
|
213
|
+
const position = new Map(line.map((sha, i) => [sha, i]));
|
|
214
|
+
const runningCommits = (e: CandidateEnvironment): string[] => e.running.map((r) => r.commit).filter((c): c is string => c !== null && position.has(c));
|
|
215
|
+
const bounded = environments.length > 0 && environments.every((e) => runningCommits(e).length > 0);
|
|
216
|
+
let since: string | null = null;
|
|
217
|
+
let walk = line.slice(0, limit);
|
|
218
|
+
if (bounded) {
|
|
219
|
+
const oldest = Math.max(...environments.flatMap((e) => runningCommits(e).map((c) => position.get(c)!)));
|
|
220
|
+
since = line[oldest];
|
|
221
|
+
walk = line.slice(0, oldest);
|
|
222
|
+
}
|
|
223
|
+
// An environment's newest running commit on the line, by position (smaller is newer).
|
|
224
|
+
const newestRun = new Map(environments.map((e) => [e.env, Math.min(...runningCommits(e).map((c) => position.get(c)!), Number.POSITIVE_INFINITY)]));
|
|
225
|
+
|
|
226
|
+
const tags = await judgedCiTags(top, green);
|
|
227
|
+
const details = commitDetails(top, walk);
|
|
228
|
+
const merges = new Map<string, Record<string, unknown>>();
|
|
229
|
+
for (const sha of walk) {
|
|
230
|
+
const text = (await readBlobFromPath("_merges", `${sha}.json`, loc)) ?? (ledger.prefix !== "" ? await readBlobFromPath("_merges", `${sha}.json`, { cwd, ledgerPrefix: "" }) : null);
|
|
231
|
+
if (text === null) continue;
|
|
232
|
+
try {
|
|
233
|
+
const parsed = JSON.parse(text) as unknown;
|
|
234
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) merges.set(sha, parsed as Record<string, unknown>);
|
|
235
|
+
} catch {
|
|
236
|
+
// A merge record that is not JSON is left out.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const change = (sha: string): CandidateChange => {
|
|
240
|
+
const d = details.get(sha);
|
|
241
|
+
const { work, records } = recordTrailers(d?.trailers ?? {});
|
|
242
|
+
const merge = merges.get(sha) ?? null;
|
|
243
|
+
const mergePr = merge && typeof merge.pr === "number" ? merge.pr : null;
|
|
244
|
+
const subject = d?.subject ?? "";
|
|
245
|
+
return { commit: sha, subject, date: d?.date ?? "", work, records, pr: mergePr ?? prFromSubject(subject), merge };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const flagReader = await flagsReader(top, source.rootOnDisk);
|
|
249
|
+
const cutLabels = new Map<string, string[]>();
|
|
250
|
+
for (const r of releases) {
|
|
251
|
+
const label = labelOf.get(r.releaseDigest);
|
|
252
|
+
if (label !== undefined) cutLabels.set(r.commit, [...(cutLabels.get(r.commit) ?? []), label].sort());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Oldest first, so each candidate closes the commits since the previous one.
|
|
256
|
+
const candidates: Candidate[] = [];
|
|
257
|
+
const skipped: Array<{ commit: string; subject: string; reason: "revoked" }> = [];
|
|
258
|
+
let open: string[] = [];
|
|
259
|
+
for (let i = walk.length - 1; i >= 0; i--) {
|
|
260
|
+
const sha = walk[i];
|
|
261
|
+
open.push(sha);
|
|
262
|
+
if (tags.revoked.has(sha)) {
|
|
263
|
+
skipped.push({ commit: sha, subject: details.get(sha)?.subject ?? "", reason: "revoked" });
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (!tags.green.has(sha)) continue;
|
|
267
|
+
const own = change(sha);
|
|
268
|
+
candidates.push({
|
|
269
|
+
commit: sha,
|
|
270
|
+
subject: own.subject,
|
|
271
|
+
date: own.date,
|
|
272
|
+
newFor: environments.filter((e) => i < newestRun.get(e.env)!).map((e) => e.env),
|
|
273
|
+
cut: cutLabels.get(sha) ?? [],
|
|
274
|
+
changes: open.map(change).reverse(),
|
|
275
|
+
flags: flagReader(open),
|
|
276
|
+
});
|
|
277
|
+
open = [];
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
...empty,
|
|
281
|
+
since,
|
|
282
|
+
candidates: candidates.reverse(),
|
|
283
|
+
skipped: skipped.reverse(),
|
|
284
|
+
pending: open.map(change).reverse(),
|
|
285
|
+
};
|
|
286
|
+
} catch (err) {
|
|
287
|
+
return { ...head, error: { code: "ledger-unreadable", message: `reading the candidates failed: ${err instanceof Error ? err.message : String(err)}` } };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The flags whose record files the given commits change, or a reader that finds none when no flag kind can be read. */
|
|
292
|
+
async function flagsReader(top: string, root: string | null): Promise<(shas: string[]) => CandidateFlag[]> {
|
|
293
|
+
if (!root) return () => [];
|
|
294
|
+
let flags: Awaited<ReturnType<typeof import("./flags-read").readWorkspaceFlags>>["flags"];
|
|
295
|
+
try {
|
|
296
|
+
const { readWorkspaceFlags } = await import("./flags-read");
|
|
297
|
+
flags = (await readWorkspaceFlags(root)).flags;
|
|
298
|
+
} catch {
|
|
299
|
+
return () => [];
|
|
300
|
+
}
|
|
301
|
+
if (flags.length === 0) return () => [];
|
|
302
|
+
const fromTop = (p: string): string => relative(top, resolve(root, p)).split(sep).join("/");
|
|
303
|
+
const byPath = new Map(flags.map((f) => [fromTop(f.path), f]));
|
|
304
|
+
return (shas) => {
|
|
305
|
+
const touched = new Set<string>();
|
|
306
|
+
for (const sha of shas) {
|
|
307
|
+
const names = tryGit(top, ["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha]) ?? "";
|
|
308
|
+
for (const n of names.split("\n")) if (byPath.has(n)) touched.add(n);
|
|
309
|
+
}
|
|
310
|
+
return [...touched]
|
|
311
|
+
.sort()
|
|
312
|
+
.map((p) => byPath.get(p)!)
|
|
313
|
+
.map((f) => ({ id: f.id, member: f.member, param: f.param, path: f.path, values: f.values }));
|
|
314
|
+
};
|
|
315
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The security groups (#4168): which groups a member runs under, and the
|
|
3
|
+
* reference pages printed from the registries.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
12
|
+
import { WORKSPACE_CHECKS } from "./checks";
|
|
13
|
+
import { memberAtCwd, securityFeatureTable, securityGroupsAt, securityGroupsOf } from "./security";
|
|
14
|
+
|
|
15
|
+
const DOCS = fileURLToPath(new URL("../../../../docs/src/content/docs/", import.meta.url));
|
|
16
|
+
|
|
17
|
+
const scratch: string[] = [];
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("securityGroupsOf", () => {
|
|
23
|
+
const declaration = {
|
|
24
|
+
security: ["verified-releases" as const],
|
|
25
|
+
members: [
|
|
26
|
+
{ name: "api", dir: "api", security: ["supply-chain" as const] },
|
|
27
|
+
{ name: "web", dir: "web", security: [] },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test("a member runs under the top-level groups and its own", () => {
|
|
32
|
+
expect([...securityGroupsOf(declaration as never, "api")]).toEqual(["verified-releases", "supply-chain"]);
|
|
33
|
+
expect([...securityGroupsOf(declaration as never, "web")]).toEqual(["verified-releases"]);
|
|
34
|
+
expect([...securityGroupsOf(declaration as never)]).toEqual(["verified-releases"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("no declaration or no security key turns nothing on", () => {
|
|
38
|
+
expect(securityGroupsOf(null).size).toBe(0);
|
|
39
|
+
expect(securityGroupsOf({ security: [], members: [] } as never, "api").size).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("securityGroupsAt reads the member holding the directory", () => {
|
|
43
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-security-groups-")));
|
|
44
|
+
scratch.push(root);
|
|
45
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
46
|
+
mkdirSync(join(root, "api", "src"), { recursive: true });
|
|
47
|
+
writeFileSync(
|
|
48
|
+
join(root, "chant.workspace.json"),
|
|
49
|
+
JSON.stringify({ name: "acme", schema: 1, security: ["review-rules"], members: [{ name: "api", dir: "api", kind: "other", because: "x", security: ["who-ran-it"] }] }),
|
|
50
|
+
);
|
|
51
|
+
expect(memberAtCwd({ declaration: { members: [{ name: "api", dir: "api" }] } as never, rootOnDisk: root }, join(root, "api", "src"))).toBe("api");
|
|
52
|
+
expect([...securityGroupsAt(join(root, "api", "src"))]).toEqual(["review-rules", "who-ran-it"]);
|
|
53
|
+
expect([...securityGroupsAt(root)]).toEqual(["review-rules"]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("reference pages", () => {
|
|
58
|
+
test("the security features page carries the table SECURITY_FEATURES prints", () => {
|
|
59
|
+
const page = readFileSync(join(DOCS, "reference", "security-features.mdx"), "utf-8");
|
|
60
|
+
const table = /^\| Feature \| Default \|[^\n]*\n(?:\|[^\n]*\n)*/m.exec(page)?.[0].trimEnd();
|
|
61
|
+
expect(table, "regenerate the table under Features from securityFeatureTable()").toBe(securityFeatureTable());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("the workspace check page lists every check with its name, default severity and whether it can be set", () => {
|
|
65
|
+
const page = readFileSync(join(DOCS, "cli", "workspace-check.mdx"), "utf-8");
|
|
66
|
+
for (const c of WORKSPACE_CHECKS) {
|
|
67
|
+
expect(page, c.id).toContain(`| \`${c.id}\` | ${c.name} | ${c.severity} | ${c.configurable ? "yes" : "no"} |`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The security groups a workspace opts into (#4168 section 8): the
|
|
3
|
+
* declaration's top-level `security`, such as `["verified-releases"]`, plus
|
|
4
|
+
* the `security` of the member a command runs in (#4168 step 7). With no
|
|
5
|
+
* `security` key none is on, and a security feature the workspace has not
|
|
6
|
+
* turned on never refuses, warns or hides anything (the rule #4156 set for
|
|
7
|
+
* runner signing).
|
|
8
|
+
*
|
|
9
|
+
* What each group turns on:
|
|
10
|
+
* - `verified-releases`: `chant release publish` refuses a release with no
|
|
11
|
+
* verified signature, as the `release` gate in `identity.gates` does, and
|
|
12
|
+
* `chant components promote --release` verifies the release first.
|
|
13
|
+
* - `supply-chain`: a publish without OIDC provenance warns, and a target
|
|
14
|
+
* with no SBOM source is refused (`publish-sbom-missing`).
|
|
15
|
+
* - `who-ran-it`: `chant ci tick` refuses to tag without a runner key.
|
|
16
|
+
* - `review-rules`: `chant components pr-apply` counts an approval only from
|
|
17
|
+
* someone who approved the pull request on the forge, as
|
|
18
|
+
* `--require-review` does, and a generated pull-request pipeline passes
|
|
19
|
+
* the flag.
|
|
20
|
+
* - `isolated-runners`: `chant ci run apply`, under `chant ci serve` too,
|
|
21
|
+
* refuses to start without a provisioner's runner-start statement for the
|
|
22
|
+
* job (#4147), as `--require-runner-start` does, and a generated
|
|
23
|
+
* pull-request pipeline passes the flag. A `--local` run is exempt.
|
|
24
|
+
*
|
|
25
|
+
* Each feature still turns on alone: `identity.gates.release`,
|
|
26
|
+
* `--require-provenance`, `--require-review`, `ci.green.signedSince`.
|
|
27
|
+
* `chant workspace check` reports what a declared group still lacks
|
|
28
|
+
* (./checks/security.ts, WSP161 to WSP165). {@link SECURITY_FEATURES} is the
|
|
29
|
+
* table the reference page prints.
|
|
30
|
+
*/
|
|
31
|
+
import { realpathSync } from "node:fs";
|
|
32
|
+
import { relative, sep } from "node:path";
|
|
33
|
+
import type { Declaration } from "./declaration";
|
|
34
|
+
import { memberHolding } from "./record-assets";
|
|
35
|
+
import { scopeSource, type ScopeSource } from "./write-scope";
|
|
36
|
+
|
|
37
|
+
export const SECURITY_GROUPS = ["verified-releases", "supply-chain", "who-ran-it", "review-rules", "isolated-runners"] as const;
|
|
38
|
+
|
|
39
|
+
export type SecurityGroup = (typeof SECURITY_GROUPS)[number];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The groups `declaration` turns on for `member` (a member name): the
|
|
43
|
+
* top-level list plus that member's own. Empty for no declaration or no
|
|
44
|
+
* `security` key anywhere.
|
|
45
|
+
*/
|
|
46
|
+
export function securityGroupsOf(declaration: Pick<Declaration, "security" | "members"> | null | undefined, member: string | null = null): ReadonlySet<SecurityGroup> {
|
|
47
|
+
const own = member === null ? [] : (declaration?.members?.find((m) => m.name === member)?.security ?? []);
|
|
48
|
+
return new Set([...(declaration?.security ?? []), ...own]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The member of `source`'s declaration whose directory holds `cwd`, or null. */
|
|
52
|
+
export function memberAtCwd(source: Pick<ScopeSource, "declaration" | "rootOnDisk">, cwd: string): string | null {
|
|
53
|
+
if (!source.declaration || source.rootOnDisk === null) return null;
|
|
54
|
+
let rel: string;
|
|
55
|
+
try {
|
|
56
|
+
rel = relative(realpathSync(source.rootOnDisk), realpathSync(cwd));
|
|
57
|
+
} catch {
|
|
58
|
+
rel = relative(source.rootOnDisk, cwd);
|
|
59
|
+
}
|
|
60
|
+
if (rel.startsWith("..")) return null;
|
|
61
|
+
const posix = sep === "/" ? rel : rel.split(sep).join("/");
|
|
62
|
+
return memberHolding(posix === "" ? "." : posix, source.declaration.members);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The groups `source` turns on for a command run in `cwd`: the top-level list and the list of the member holding `cwd`. */
|
|
66
|
+
export function securityGroupsIn(source: Pick<ScopeSource, "declaration" | "rootOnDisk">, cwd: string): ReadonlySet<SecurityGroup> {
|
|
67
|
+
return securityGroupsOf(source.declaration, memberAtCwd(source, cwd));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The groups the workspace holding `cwd` turns on for the member holding
|
|
72
|
+
* `cwd`, read at base (the target branch tip) when there is one, else from
|
|
73
|
+
* the working tree, as the release gate is read. Empty outside a workspace.
|
|
74
|
+
*/
|
|
75
|
+
export function securityGroupsAt(cwd: string, at?: string): ReadonlySet<SecurityGroup> {
|
|
76
|
+
return securityGroupsIn(scopeSource(cwd, at), cwd);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One row of the security feature table (#4168 section 8). */
|
|
80
|
+
export interface SecurityFeature {
|
|
81
|
+
feature: string;
|
|
82
|
+
/** What happens with no group and no setting. */
|
|
83
|
+
byDefault: string;
|
|
84
|
+
/** What turns it on alone. */
|
|
85
|
+
setting: string;
|
|
86
|
+
group: SecurityGroup | null;
|
|
87
|
+
/** The `workspace check` reason codes that report it missing under its group. */
|
|
88
|
+
codes: readonly string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Every security feature, the group that raises it and the setting that
|
|
93
|
+
* turns it on alone. The reference page's table is printed from this list
|
|
94
|
+
* (`securityFeatureTable`), and a test compares the two.
|
|
95
|
+
*/
|
|
96
|
+
export const SECURITY_FEATURES: readonly SecurityFeature[] = [
|
|
97
|
+
{ feature: "Publish refuses an unsigned release", byDefault: "off: publishes and records `signature: unsigned`", setting: "`identity.gates.release`", group: "verified-releases", codes: ["security-signers-missing", "security-publish-allows-unsigned"] },
|
|
98
|
+
{ feature: "Signed cut in the release gate's class", byDefault: "off", setting: "`identity.gates.release` with a `class`", group: "verified-releases", codes: ["security-release-gate-missing"] },
|
|
99
|
+
{ feature: "Promote verifies the release", byDefault: "off: `chant release verify` on demand", setting: "`chant release verify`", group: "verified-releases", codes: [] },
|
|
100
|
+
{ feature: "SBOM on every target", byDefault: "off: a target with no SBOM source publishes without one", setting: "`sbom` on the target", group: "supply-chain", codes: ["security-sbom-disabled", "security-sbom-unsourced"] },
|
|
101
|
+
{ feature: "OIDC provenance", byDefault: "off: a plan field", setting: "`--require-provenance`", group: "supply-chain", codes: ["security-publish-no-provenance"] },
|
|
102
|
+
{ feature: "SBOM and verify steps on components", byDefault: "off", setting: "a `generate-sbom` or `verify` step", group: "supply-chain", codes: ["security-component-no-sbom", "security-component-no-verify"] },
|
|
103
|
+
{ feature: "Signed green tags and runs", byDefault: "off: every tag counts", setting: "`ci.green.signedSince`", group: "who-ran-it", codes: ["security-signed-since-unset"] },
|
|
104
|
+
{ feature: "Trusted runner keys", byDefault: "off", setting: "`.chant/trust.json` with a runner role", group: "who-ran-it", codes: ["security-runner-untrusted"] },
|
|
105
|
+
{ feature: "Tick signs its tags", byDefault: "off: unsigned tags", setting: "`--key` or `CHANT_RUNNER_KEY`", group: "who-ran-it", codes: ["security-tick-no-key"] },
|
|
106
|
+
{ feature: "Forge review required, authors never count", byDefault: "off", setting: "`--require-review`, `requireReview: true`", group: "review-rules", codes: ["security-review-not-required"] },
|
|
107
|
+
{ feature: "Identified attribution", byDefault: "off: `any`", setting: "`identity.attribution: \"identified\"`", group: "review-rules", codes: ["security-attribution-any"] },
|
|
108
|
+
{ feature: "Signed approvals", byDefault: "off", setting: "`identity.gates.<gate>`", group: "review-rules", codes: ["security-gate-unsigned"] },
|
|
109
|
+
{ feature: "Apply jobs require a runner-start statement", byDefault: "off", setting: "`--require-runner-start`", group: "isolated-runners", codes: ["security-runner-start-no-key", "security-runner-start-no-role", "security-trust-unreadable"] },
|
|
110
|
+
{ feature: "Secrets file", byDefault: "off", setting: "`--secrets`", group: null, codes: [] },
|
|
111
|
+
{ feature: "Env allowlist and log redaction", byDefault: "on: `ci run`'s contract, a listed exception", setting: "`--pass-env`", group: null, codes: [] },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
/** The reference page's table of {@link SECURITY_FEATURES}, as markdown. */
|
|
115
|
+
export function securityFeatureTable(): string {
|
|
116
|
+
const rows = SECURITY_FEATURES.map((f) => `| ${f.feature} | ${f.byDefault} | ${f.setting} | ${f.group ? `\`${f.group}\`` : "none"} | ${f.codes.map((c) => `\`${c}\``).join(", ") || "none"} |`);
|
|
117
|
+
return ["| Feature | Default | Turns it on alone | Group | Checked as |", "|---|---|---|---|---|", ...rows].join("\n");
|
|
118
|
+
}
|