@intentius/chant 0.124.33 → 0.124.35
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/cli/handlers/graph.js +2 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +147 -51
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +284 -81
- package/dist/cli/handlers/release.d.ts +12 -0
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +125 -10
- package/dist/cli/handlers/run.d.ts +3 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +12 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +37 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +24 -0
- package/dist/cli/registry.d.ts +17 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +8 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +3 -2
- package/dist/components/green-cover.d.ts +77 -0
- package/dist/components/green-cover.d.ts.map +1 -0
- package/dist/components/green-cover.js +153 -0
- package/dist/components/pr-pipeline.d.ts +34 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +39 -5
- package/dist/components/promote-rounds.d.ts +102 -0
- package/dist/components/promote-rounds.d.ts.map +1 -0
- package/dist/components/promote-rounds.js +174 -0
- package/dist/components/promote.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- package/dist/components/release-gate.d.ts +30 -0
- package/dist/components/release-gate.d.ts.map +1 -1
- package/dist/components/release-gate.js +92 -42
- package/dist/components/release-promote.d.ts +9 -0
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +15 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +9 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +18 -3
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +30 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +13 -2
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
- package/dist/lifecycle/compose-refs.d.ts +52 -0
- package/dist/lifecycle/compose-refs.d.ts.map +1 -0
- package/dist/lifecycle/compose-refs.js +115 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.js +3 -2
- package/dist/lifecycle/green-apply.d.ts +39 -0
- package/dist/lifecycle/green-apply.d.ts.map +1 -0
- package/dist/lifecycle/green-apply.js +51 -0
- package/dist/lifecycle/publish-declaration.d.ts +4 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +15 -0
- package/dist/lifecycle/publish-npm.d.ts +7 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +60 -13
- package/dist/lifecycle/release-compose.d.ts +91 -0
- package/dist/lifecycle/release-compose.d.ts.map +1 -0
- package/dist/lifecycle/release-compose.js +235 -0
- package/dist/lifecycle/release-cut.d.ts +8 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +2 -0
- package/dist/lifecycle/release-publish.d.ts +2 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +2 -1
- package/dist/lifecycle/release-store.d.ts +15 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +5 -1
- package/dist/wave-runtime.mjs +226 -224
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.js +13 -2
- package/dist/workspace/check.schema.json +20 -2
- package/dist/workspace/checks/ci-workflow.d.ts +17 -0
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/checks/ci-workflow.js +130 -0
- package/dist/workspace/checks/security.d.ts +16 -1
- package/dist/workspace/checks/security.d.ts.map +1 -1
- package/dist/workspace/checks/security.js +4 -4
- package/dist/workspace/checks.d.ts +1 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +18 -2
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +27 -5
- package/dist/workspace/ci-green.d.ts +31 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +78 -4
- package/dist/workspace/ci-init.d.ts +100 -0
- package/dist/workspace/ci-init.d.ts.map +1 -0
- package/dist/workspace/ci-init.js +434 -0
- package/dist/workspace/ci-run.d.ts +6 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-serve.d.ts +94 -13
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +248 -30
- package/dist/workspace/ci-workflow.d.ts +91 -0
- package/dist/workspace/ci-workflow.d.ts.map +1 -0
- package/dist/workspace/ci-workflow.js +33 -0
- package/dist/workspace/declaration.d.ts +18 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +9 -2
- package/dist/workspace/declaration.schema.json +28 -0
- package/dist/workspace/delivery-metrics.d.ts +3 -1
- package/dist/workspace/delivery-metrics.d.ts.map +1 -1
- package/dist/workspace/delivery-metrics.js +12 -3
- package/dist/workspace/init.js +2 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +4 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +1 -0
- package/dist/workspace/reason-codes.d.ts +9 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +10 -0
- package/dist/workspace/release-candidates.d.ts +28 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -1
- package/dist/workspace/release-candidates.js +38 -2
- package/dist/workspace/release-candidates.schema.json +51 -0
- package/dist/workspace/release-workflow.d.ts +31 -0
- package/dist/workspace/release-workflow.d.ts.map +1 -0
- package/dist/workspace/release-workflow.js +150 -0
- package/dist/workspace/releases.schema.json +30 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/pr-green.test.ts +230 -0
- package/src/cli/handlers/pr.ts +148 -66
- package/src/cli/handlers/promote.test.ts +34 -0
- package/src/cli/handlers/promote.ts +302 -91
- package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
- package/src/cli/handlers/release.ts +112 -10
- package/src/cli/handlers/run.ts +12 -1
- package/src/cli/main.ts +37 -2
- package/src/cli/parse-args.ts +18 -0
- package/src/cli/registry.ts +17 -1
- package/src/codegen/release-wiring.test.ts +56 -9
- package/src/components/auto-release.test.ts +22 -0
- package/src/components/auto-release.ts +9 -2
- package/src/components/green-cover.test.ts +170 -0
- package/src/components/green-cover.ts +186 -0
- package/src/components/pr-pipeline.test.ts +14 -4
- package/src/components/pr-pipeline.ts +56 -5
- package/src/components/promote-rounds.test.ts +220 -0
- package/src/components/promote-rounds.ts +232 -0
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-gate.test.ts +47 -0
- package/src/components/release-gate.ts +111 -46
- package/src/components/release-promote.test.ts +24 -1
- package/src/components/release-promote.ts +15 -0
- package/src/deep-observation.ts +8 -0
- package/src/graph-declared-exports.test.ts +40 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +17 -2
- package/src/graph-detail.test.ts +68 -1
- package/src/graph-detail.ts +29 -1
- package/src/graph-ir.ts +12 -4
- package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
- package/src/lifecycle/compose-refs.ts +125 -0
- package/src/lifecycle/deep-observe.test.ts +20 -0
- package/src/lifecycle/deep-observe.ts +4 -2
- package/src/lifecycle/green-apply.ts +71 -0
- package/src/lifecycle/publish-declaration.ts +15 -0
- package/src/lifecycle/publish-npm.ts +55 -13
- package/src/lifecycle/publish-targets.test.ts +45 -0
- package/src/lifecycle/release-compose.test.ts +125 -0
- package/src/lifecycle/release-compose.ts +312 -0
- package/src/lifecycle/release-cut.ts +6 -0
- package/src/lifecycle/release-publish.ts +4 -1
- package/src/lifecycle/release-store.ts +16 -1
- package/src/workspace/__fixtures__/contract-repo.ts +11 -2
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +20 -2
- package/src/workspace/checks/ci-workflow.test.ts +160 -0
- package/src/workspace/checks/ci-workflow.ts +135 -0
- package/src/workspace/checks/security.ts +4 -4
- package/src/workspace/checks.ts +4 -1
- package/src/workspace/ci-green-cli.ts +5 -3
- package/src/workspace/ci-green-workflow.test.ts +52 -0
- package/src/workspace/ci-green-workflow.ts +34 -5
- package/src/workspace/ci-green.test.ts +33 -0
- package/src/workspace/ci-green.ts +85 -4
- package/src/workspace/ci-init.test.ts +159 -0
- package/src/workspace/ci-init.ts +445 -0
- package/src/workspace/ci-report-url.test.ts +6 -1
- package/src/workspace/ci-run.test.ts +18 -13
- package/src/workspace/ci-run.ts +8 -1
- package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
- package/src/workspace/ci-serve-land.test.ts +176 -0
- package/src/workspace/ci-serve.test.ts +77 -43
- package/src/workspace/ci-serve.ts +268 -34
- package/src/workspace/ci-workflow.ts +71 -0
- package/src/workspace/declaration.schema.json +28 -0
- package/src/workspace/declaration.ts +32 -3
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/init.ts +2 -2
- package/src/workspace/member-commands.test.ts +11 -4
- package/src/workspace/member-commands.ts +3 -0
- package/src/workspace/nested-graph.test.ts +4 -0
- package/src/workspace/nested-graph.ts +1 -0
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +10 -0
- package/src/workspace/release-candidates.schema.json +51 -0
- package/src/workspace/release-candidates.test.ts +30 -3
- package/src/workspace/release-candidates.ts +54 -2
- package/src/workspace/release-workflow.ts +167 -0
- package/src/workspace/releases.schema.json +30 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSP151 (#4168 step 6): what a member under `ci.workflow` still lacks.
|
|
3
|
+
*
|
|
4
|
+
* A declaration that names no workflow gets no finding from this file. The
|
|
5
|
+
* requirements and their codes are {@link CI_WORKFLOW_REQUIREMENTS}
|
|
6
|
+
* (../ci-workflow.ts). Like the security checklists (./security.ts), the
|
|
7
|
+
* check reads files and never runs them: `chant.config.ts` for the
|
|
8
|
+
* environments (its literal `environments`, as `workspace status` reads it),
|
|
9
|
+
* `*.component.ts` for components and gates, and the forge pipeline files for
|
|
10
|
+
* the jobs. A generated file that differs from what its generator writes is
|
|
11
|
+
* WSP101 to WSP106's to report; this check asks only whether the jobs the
|
|
12
|
+
* workflow needs are there.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
|
|
16
|
+
import type { CiWorkflow, Member } from "../declaration";
|
|
17
|
+
import { appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
|
|
18
|
+
import { matchesCheckRun } from "../ci-green";
|
|
19
|
+
import { staticPromotionOrder } from "../promotion-order";
|
|
20
|
+
import { joinPath } from "../tree";
|
|
21
|
+
import { allPipelineFiles, commandLines, componentFiles, read } from "./security";
|
|
22
|
+
|
|
23
|
+
export const WSP_CI_WORKFLOW = "WSP151";
|
|
24
|
+
|
|
25
|
+
const memberDir = (dir: string) => (dir === "." ? "" : dir);
|
|
26
|
+
|
|
27
|
+
const PLAN = /\bci\s+run\s+plan\b|\bcomponents\s+pr-plan\b/;
|
|
28
|
+
const RECORD = /\bci\s+run\s+record\b|\bcomponents\s+pr-record\b/;
|
|
29
|
+
const APPLY = /\bci\s+run\s+apply\b|\bcomponents\s+pr-apply\b/;
|
|
30
|
+
const TICK = /\bci\s+tick\b/;
|
|
31
|
+
const PUBLISH = /\brelease\s+publish\b/;
|
|
32
|
+
|
|
33
|
+
/** The apply job names a required phase could match. */
|
|
34
|
+
const APPLY_JOB_NAMES = ["apply", "pr-apply"];
|
|
35
|
+
|
|
36
|
+
export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
|
|
37
|
+
{
|
|
38
|
+
id: WSP_CI_WORKFLOW,
|
|
39
|
+
name: "ci-workflow",
|
|
40
|
+
description: "Under ci.workflow: ci.green with pre-merge phases and no apply job required, each member with a component, environments and a gate on each component, and pipeline files that plan, record (or apply under apply on merge), tick and publish.",
|
|
41
|
+
severity: "warning",
|
|
42
|
+
configurable: true,
|
|
43
|
+
check(ctx) {
|
|
44
|
+
const d = ctx.declaration;
|
|
45
|
+
const under = d.members
|
|
46
|
+
.map((m) => ({ member: m, workflow: workflowOf(d, m) }))
|
|
47
|
+
.filter((u): u is { member: Member; workflow: CiWorkflow } => u.workflow !== null)
|
|
48
|
+
// The top level's workflow covers the members that build with chant; a member that names one itself is held to it.
|
|
49
|
+
.filter((u) => u.member.ciWorkflow !== null || ctx.tree.stat(joinPath(memberDir(u.member.dir), "chant.config.ts")) === "file");
|
|
50
|
+
if (under.length === 0 && !d.ci?.workflow) return [];
|
|
51
|
+
const pointer = d.ci?.workflow ? "/ci/workflow" : `${under[0]!.member.pointer}/ci/workflow`;
|
|
52
|
+
const out: WorkspaceDiagnostic[] = [];
|
|
53
|
+
const finding = (code: CiWorkflowCode, message: string, at: { pointer?: string; entity?: string; treeFile?: string; line?: number } = {}): void => {
|
|
54
|
+
out.push({
|
|
55
|
+
checkId: this.id,
|
|
56
|
+
severity: this.severity,
|
|
57
|
+
code,
|
|
58
|
+
message: `${code}: ${message}`,
|
|
59
|
+
pointer: at.pointer ?? pointer,
|
|
60
|
+
...(at.entity !== undefined ? { entity: at.entity } : {}),
|
|
61
|
+
...(at.treeFile !== undefined ? { treeFile: at.treeFile, line: at.line ?? 1, column: 1 } : {}),
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const green = d.ci?.green ?? null;
|
|
66
|
+
if (!green) {
|
|
67
|
+
finding("ci-workflow-green-undeclared", "the workspace follows a ci.workflow and declares no ci.green, so no commit gets a verdict and no release can be cut; run chant ci init or add a ci.green block");
|
|
68
|
+
} else {
|
|
69
|
+
if ((green.preMerge ?? []).length === 0) {
|
|
70
|
+
finding("ci-workflow-no-pre-merge", "ci.green.preMerge names no phase, so nothing says which checks run before the branch moves; name the quick ones", { pointer: green.pointer });
|
|
71
|
+
}
|
|
72
|
+
for (const name of green.require) {
|
|
73
|
+
const phase = green.phases.find((p) => p.name === name);
|
|
74
|
+
const hit = phase?.runs.find((pattern) => APPLY_JOB_NAMES.some((job) => matchesCheckRun(pattern, job)));
|
|
75
|
+
if (phase && hit) {
|
|
76
|
+
finding("ci-workflow-apply-in-green", `the required phase ${name} matches the apply job with ${JSON.stringify(hit)}, so an apply that waits for a green commit would wait on itself; drop the pattern`, { pointer: phase.pointer });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const { member } of under) {
|
|
82
|
+
const dir = memberDir(member.dir);
|
|
83
|
+
const components = componentFiles(ctx.tree, dir);
|
|
84
|
+
if (components.length === 0) {
|
|
85
|
+
finding("ci-workflow-no-components", `member ${member.name} declares no component, so nothing of it can be cut or promoted; chant ci init writes a stub`, { pointer: member.pointer, entity: member.name });
|
|
86
|
+
} else if (components.every((f) => (read(ctx.tree, f) ?? "").includes(STUB_MARKER))) {
|
|
87
|
+
finding("ci-workflow-no-components", `member ${member.name}'s only component is the stub chant ci init wrote (${components[0]}); say how it is built and deployed, then delete its ${STUB_MARKER} line`, { pointer: member.pointer, entity: member.name, treeFile: components[0] });
|
|
88
|
+
}
|
|
89
|
+
const configPath = joinPath(dir, "chant.config.ts");
|
|
90
|
+
const config = read(ctx.tree, configPath);
|
|
91
|
+
// A config whose environments aren't literals can't be read without running it; say nothing rather than guess.
|
|
92
|
+
const order = config === null ? [] : staticPromotionOrder(config);
|
|
93
|
+
if (order !== undefined && order.length === 0) {
|
|
94
|
+
finding("ci-workflow-no-environments", `member ${member.name}'s chant.config.ts declares no environments, so a release has nowhere to be promoted to`, { pointer: member.pointer, entity: member.name, ...(config !== null ? { treeFile: configPath } : {}) });
|
|
95
|
+
}
|
|
96
|
+
for (const file of components) {
|
|
97
|
+
const text = read(ctx.tree, file) ?? "";
|
|
98
|
+
if (/\bgate\s*\(|kind\s*:\s*["']gate["']/.test(text)) continue;
|
|
99
|
+
const last = order && order.length > 0 ? order[order.length - 1]!.name : "the last environment";
|
|
100
|
+
finding("ci-workflow-ungated-env", `${file} has no gate, so a promote to ${last} waits on no approval; add a gate() step`, { pointer: member.pointer, entity: member.name, treeFile: file });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const files = allPipelineFiles(ctx);
|
|
105
|
+
const has = (re: RegExp) => files.some((f) => f.lines.some((l) => re.test(l)));
|
|
106
|
+
const applyOnMerge = under.some((u) => appliesOnMerge(u.workflow)) || (d.ci?.workflow ? appliesOnMerge(d.ci.workflow) : false);
|
|
107
|
+
const missing = [
|
|
108
|
+
...(has(PLAN) ? [] : ["a plan job (ci run plan)"]),
|
|
109
|
+
// #4168 step 9: every merge is recorded, apply on merge or not, since the apply reads the merge records.
|
|
110
|
+
...(has(RECORD) ? [] : ["a record job (ci run record)"]),
|
|
111
|
+
...(has(TICK) ? [] : ["chant ci tick"]),
|
|
112
|
+
];
|
|
113
|
+
if (missing.length > 0) {
|
|
114
|
+
finding("ci-workflow-pipeline-missing", `no pipeline file runs ${missing.join(", ")}; generate them with chant build --components --generate <forge> --pr-loop and chant ci workflow`);
|
|
115
|
+
}
|
|
116
|
+
if (!applyOnMerge) {
|
|
117
|
+
for (const l of commandLines(ctx, APPLY)) {
|
|
118
|
+
finding("ci-workflow-pipeline-differs", `${l.path} applies on merge, and the workflow is trunk-curated, where a merge records the plan and deploys nothing; regenerate without --apply-on-merge, or set ci.workflow to trunk-curated+apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
119
|
+
}
|
|
120
|
+
} else if (!has(APPLY) && has(RECORD)) {
|
|
121
|
+
const l = commandLines(ctx, RECORD)[0]!;
|
|
122
|
+
finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
123
|
+
} else {
|
|
124
|
+
// #4168 step 9: an apply measured from a push's base applies each merge; the workflow applies green commits.
|
|
125
|
+
for (const l of commandLines(ctx, APPLY).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
|
|
126
|
+
finding("ci-workflow-pipeline-differs", `${l.path} applies each merge, and under trunk-curated+apply-on-merge the apply applies the newest green commit; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!has(PUBLISH)) {
|
|
130
|
+
finding("ci-workflow-publish-missing", "no pipeline file runs chant release publish, so a cut release is never published; generate one with chant release workflow");
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
];
|
|
@@ -83,7 +83,7 @@ function groupPointer(declaration: Declaration, group: SecurityGroup): string {
|
|
|
83
83
|
return m ? `${m.pointer}/security/${m.security.indexOf(group)}` : "/security";
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function read(tree: WorkspaceTree, path: string): string | null {
|
|
86
|
+
export function read(tree: WorkspaceTree, path: string): string | null {
|
|
87
87
|
if (tree.stat(path) !== "file") return null;
|
|
88
88
|
try {
|
|
89
89
|
return tree.read(path);
|
|
@@ -109,18 +109,18 @@ function pipelineFiles(tree: WorkspaceTree, dir: string): { path: string; lines:
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/** Every pipeline file of the workspace: its root's, and each member's. */
|
|
112
|
-
function allPipelineFiles(ctx: WorkspaceCheckContext): { path: string; lines: string[] }[] {
|
|
112
|
+
export function allPipelineFiles(ctx: WorkspaceCheckContext): { path: string; lines: string[] }[] {
|
|
113
113
|
const dirs = new Set(["", ...ctx.declaration.members.map((m) => memberDir(m.dir))]);
|
|
114
114
|
return [...dirs].flatMap((d) => pipelineFiles(ctx.tree, d));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/** The lines of the pipeline files that run a chant command, `command` naming it, such as `release publish`. */
|
|
118
|
-
function commandLines(ctx: WorkspaceCheckContext, command: RegExp): { path: string; line: number; text: string }[] {
|
|
118
|
+
export function commandLines(ctx: WorkspaceCheckContext, command: RegExp): { path: string; line: number; text: string }[] {
|
|
119
119
|
return allPipelineFiles(ctx).flatMap((f) => f.lines.flatMap((text, i) => (command.test(text) ? [{ path: f.path, line: i + 1, text }] : [])));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
/** The member's `*.component.ts` files, walked a few levels down, past node_modules and build output. */
|
|
123
|
-
function componentFiles(tree: WorkspaceTree, dir: string, depth = 4): string[] {
|
|
123
|
+
export function componentFiles(tree: WorkspaceTree, dir: string, depth = 4): string[] {
|
|
124
124
|
const out: string[] = [];
|
|
125
125
|
for (const e of tree.list(dir) ?? []) {
|
|
126
126
|
if (e.type === "dir") {
|
package/src/workspace/checks.ts
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* | WSP126, WSP127 | box intent: the decision record a box names exists, and constrains a member or path of this workspace (#2850, #2857) |
|
|
38
38
|
* | WSP141, WSP142 | member links resolved against a live read, with `--live` (#2549) |
|
|
39
39
|
* | WSP131 to WSP133 | diagram artifacts: source and render exist, a recorded source hash still matches (#2764) |
|
|
40
|
-
* | WSP151 |
|
|
40
|
+
* | WSP151 | what a member under `ci.workflow` still lacks (#4168 step 6) |
|
|
41
41
|
* | WSP161 to WSP165 | the security groups the declaration opts into: what each still lacks (#4168 step 7) |
|
|
42
42
|
*/
|
|
43
43
|
|
|
@@ -61,6 +61,7 @@ import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
|
|
|
61
61
|
import { BOX_CHECKS } from "./checks/boxes";
|
|
62
62
|
import { DIAGRAM_CHECKS } from "./checks/diagrams";
|
|
63
63
|
import { SECURITY_CHECKS } from "./checks/security";
|
|
64
|
+
import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow";
|
|
64
65
|
import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
|
|
65
66
|
import { resolveBoxIntents, type ResolvedBoxIntent } from "./box-intent";
|
|
66
67
|
import { gatherLexiconOutputs, type MemberLexiconOutputs } from "./lexicon-outputs";
|
|
@@ -374,6 +375,8 @@ export const WORKSPACE_CHECKS: readonly WorkspaceCheck[] = [
|
|
|
374
375
|
...DIAGRAM_CHECKS,
|
|
375
376
|
// Links resolved against a live read, with --live (#2549), WSP141 and WSP142.
|
|
376
377
|
...LIVE_CHECKS,
|
|
378
|
+
// What a member under ci.workflow still lacks (#4168 step 6), WSP151.
|
|
379
|
+
...CI_WORKFLOW_CHECKS,
|
|
377
380
|
// The security groups the declaration opts into (#4168 step 7), WSP161 to WSP165.
|
|
378
381
|
...SECURITY_CHECKS,
|
|
379
382
|
];
|
|
@@ -154,7 +154,9 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
|
154
154
|
const sources = readWorkflowSources(repoRoot, fromRepo);
|
|
155
155
|
const scan = workflowsHolding(green, sources);
|
|
156
156
|
// #4169: the triggers and concurrency of the workflows holding a phase that sets build. Refused before anything is written.
|
|
157
|
-
|
|
157
|
+
// #4220: under a ci.workflow, composed releases' branches run the phases and tick too.
|
|
158
|
+
const compose = declaration.ci?.workflow !== undefined || declaration.members.some((m) => m.ciWorkflow !== null);
|
|
159
|
+
const edits = buildTriggerEdits(green, sources, { compose });
|
|
158
160
|
const extra = ctx.args.ciWorkflows ?? [];
|
|
159
161
|
const workflows = [...new Set([...scan.workflows, ...extra])].sort();
|
|
160
162
|
for (const f of scan.unreadable) console.error(`warning: ${f} is not valid YAML, so its jobs were not read`);
|
|
@@ -180,10 +182,10 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
|
180
182
|
const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
|
|
181
183
|
// The install runs in the workspace root, so its lockfile there keys setup-node's npm cache.
|
|
182
184
|
const npmLock = ctx.args.ciInstall && existsSync(join(root, "package-lock.json")) ? (workspaceRoot === "." ? "package-lock.json" : `${workspaceRoot}/package-lock.json`) : undefined;
|
|
183
|
-
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}) });
|
|
185
|
+
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}), ...(compose ? { compose } : {}) });
|
|
184
186
|
const written = writeWorkflowFile(target, ciGreenHeader(command, member ? "member" : "root") + yaml);
|
|
185
187
|
if (member) recordGeneratedFiles(member.memberRoot, [{ path: fromRepo, command }]);
|
|
186
|
-
console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}, and every 15 minutes.`);
|
|
188
|
+
console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}${compose ? " or a chant/compose/ branch" : ""}, and every 15 minutes.`);
|
|
187
189
|
for (const e of edits) {
|
|
188
190
|
if (!e.changed) continue;
|
|
189
191
|
const path = writeWorkflowFile(join(repoRoot, e.path), e.text);
|
|
@@ -381,6 +381,58 @@ describe("the triggers of a phase that sets build (#4169)", () => {
|
|
|
381
381
|
expect(before.on.workflow_run.branches).toEqual(["main"]);
|
|
382
382
|
});
|
|
383
383
|
|
|
384
|
+
test("under a ci.workflow, both builds run on chant/compose/** pushes, a compose branch's every runs are keyed by commit, and the tick ticks on them (#4220)", () => {
|
|
385
|
+
const sources = [
|
|
386
|
+
{ path: ".github/workflows/ci.yml", text: LINT_WF },
|
|
387
|
+
{ path: ".github/workflows/suites.yml", text: SUITES_WF },
|
|
388
|
+
];
|
|
389
|
+
const edits = buildTriggerEdits(green(), sources, { compose: true });
|
|
390
|
+
const ci = parseYAMLDocument(edits[0].text) as Yaml;
|
|
391
|
+
expect(ci.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, pull_request: null });
|
|
392
|
+
expect(ci.concurrency).toEqual({
|
|
393
|
+
group: "chant-build-lint-${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) && github.sha || github.ref }}",
|
|
394
|
+
"cancel-in-progress": "${{ !(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) }}",
|
|
395
|
+
});
|
|
396
|
+
const suites = parseYAMLDocument(edits[1].text) as Yaml;
|
|
397
|
+
expect(suites.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, workflow_dispatch: {} });
|
|
398
|
+
// Writing again changes nothing.
|
|
399
|
+
expect(buildTriggerEdits(green(), edits.map((e) => ({ path: e.path, text: e.text })), { compose: true }).map((e) => e.changed)).toEqual([false, false]);
|
|
400
|
+
// A push with no branch list already runs on every branch, so its triggers stay.
|
|
401
|
+
const anyBranch = LINT_WF.replace(" push:\n branches: [main]\n", " push:\n");
|
|
402
|
+
expect((parseYAMLDocument(buildTriggerEdits(green(), [{ path: ".github/workflows/ci.yml", text: anyBranch }], { compose: true })[0].text) as Yaml).on).toEqual({ push: null, pull_request: null });
|
|
403
|
+
// Without compose the bytes are what #4169 wrote.
|
|
404
|
+
expect(buildTriggerEdits(green(), sources).map((e) => e.text)).toEqual(buildTriggerEdits(green(), sources, { compose: false }).map((e) => e.text));
|
|
405
|
+
const tick = parseYAMLDocument(renderCiGreenWorkflow({ green: green(), workflows: ["ci"], chantVersion: "0.124.33", workspaceRoot: ".", compose: true })) as Yaml;
|
|
406
|
+
expect(tick.on.workflow_run.branches).toEqual(["main", "ci/build/**", "chant/compose/**"]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("chant ci workflow writes the compose triggers only when the declaration names a ci.workflow (#4220)", async () => {
|
|
410
|
+
const run = async (ci: Record<string, unknown>) => {
|
|
411
|
+
const root = scratchDir();
|
|
412
|
+
git(root, "init", "-q");
|
|
413
|
+
writeFiles(root, {
|
|
414
|
+
"chant.workspace.json": declaration([{ name: "root", dir: ".", kind: "other", because: "x" }], { ci }),
|
|
415
|
+
".github/workflows/ci.yml": LINT_WF,
|
|
416
|
+
});
|
|
417
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
418
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
419
|
+
try {
|
|
420
|
+
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
|
|
421
|
+
return { tick: readFileSync(join(root, CI_GREEN_WORKFLOW_FILE), "utf-8"), ci: readFileSync(join(root, ".github/workflows/ci.yml"), "utf-8") };
|
|
422
|
+
} finally {
|
|
423
|
+
cwd.mockRestore();
|
|
424
|
+
log.mockRestore();
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const lint = { branch: "main", phases: { lint: { runs: ["lint"], build: "every" } }, require: ["lint"] };
|
|
428
|
+
const without = await run({ green: lint });
|
|
429
|
+
expect(without.tick).not.toContain("chant/compose");
|
|
430
|
+
expect(without.ci).not.toContain("chant/compose");
|
|
431
|
+
const curated = await run({ green: lint, workflow: "trunk-curated" });
|
|
432
|
+
expect(curated.tick).toContain("chant/compose/**");
|
|
433
|
+
expect(curated.ci).toContain("chant/compose/**");
|
|
434
|
+
});
|
|
435
|
+
|
|
384
436
|
test("the workflows a build request can start are the ones holding its phases with workflow_dispatch", () => {
|
|
385
437
|
expect(dispatchableWorkflows(green(), ["lint", "suites"], [{ path: ".github/workflows/ci.yml", text: LINT_WF }, { path: ".github/workflows/suites.yml", text: SUITES_WF }])).toEqual({ dispatchable: ["suites.yml"], without: ["ci.yml"] });
|
|
386
438
|
});
|
|
@@ -52,6 +52,7 @@ import { emitYAMLEntry, parseYAMLDocument } from "../yaml";
|
|
|
52
52
|
import type { CiBuild, CiGreen } from "./declaration";
|
|
53
53
|
import { matchesCheckRun } from "./ci-green";
|
|
54
54
|
import { githubSecretOrJobToken } from "../ci-jobs";
|
|
55
|
+
import { COMPOSE_BRANCH_PREFIX } from "../lifecycle/compose-refs";
|
|
55
56
|
|
|
56
57
|
/** Where the workflow goes, relative to the repository root. */
|
|
57
58
|
export const CI_GREEN_WORKFLOW_FILE = ".github/workflows/chant-ci-green.yml";
|
|
@@ -194,8 +195,17 @@ export interface WorkflowInput {
|
|
|
194
195
|
* The key's principal needs the `runner` role in `.chant/trust.json`.
|
|
195
196
|
*/
|
|
196
197
|
runnerKeySecret?: string;
|
|
198
|
+
/**
|
|
199
|
+
* The workspace declares a `ci.workflow` (#4220), so composed releases
|
|
200
|
+
* live on `chant/compose/<name>` branches (#4168 4.3): the tick also runs
|
|
201
|
+
* when a phase workflow completes on one of them.
|
|
202
|
+
*/
|
|
203
|
+
compose?: boolean;
|
|
197
204
|
}
|
|
198
205
|
|
|
206
|
+
/** The branch filter for composed releases' branches (#4220). */
|
|
207
|
+
export const COMPOSE_BRANCHES = `${COMPOSE_BRANCH_PREFIX}**`;
|
|
208
|
+
|
|
199
209
|
/**
|
|
200
210
|
* A token the checkout persists for the tick's `git push`: a repository
|
|
201
211
|
* secret, or a GitHub App's installation token minted from its id (an
|
|
@@ -240,6 +250,8 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
|
|
|
240
250
|
` - ${q(green.branch)}`,
|
|
241
251
|
// #4169: a build the tick asked for runs at a ci/build/<sha> tag, and its completion ticks too.
|
|
242
252
|
...(newestPhases(green) ? [` - ${q("ci/build/**")}`] : []),
|
|
253
|
+
// #4220: a composed commit's phases run on its chant/compose/<name> branch, and their completion ticks too.
|
|
254
|
+
...(input.compose ? [` - ${q(COMPOSE_BRANCHES)}`] : []),
|
|
243
255
|
" schedule:",
|
|
244
256
|
" - cron: \"*/15 * * * *\"",
|
|
245
257
|
" workflow_dispatch: {}",
|
|
@@ -319,12 +331,19 @@ export interface BuildTriggerEdit {
|
|
|
319
331
|
* branch, so no push cancels or replaces another's run; elsewhere, such as
|
|
320
332
|
* a pull request, a new push cancels the run it supersedes.
|
|
321
333
|
*
|
|
322
|
-
*
|
|
334
|
+
* With `compose` (#4220, a workspace declaring `ci.workflow`), both builds
|
|
335
|
+
* also run on pushes to `chant/compose/**`: `newest` adds the filter to the
|
|
336
|
+
* branches it writes, `every` adds it to an existing `push.branches` list,
|
|
337
|
+
* and `every`'s group keys a compose branch's runs by commit, as the trunk
|
|
338
|
+
* branch's.
|
|
339
|
+
*
|
|
340
|
+
* Only the top-level `on:` (for `newest`, or `every` with `compose`) and `concurrency:` blocks change,
|
|
323
341
|
* each under {@link BUILD_BLOCK_MARKER}; the rest of the file is kept as it
|
|
324
342
|
* is. Throws when one workflow holds a phase of each build, which no one
|
|
325
343
|
* concurrency group can serve.
|
|
326
344
|
*/
|
|
327
|
-
export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[]): BuildTriggerEdit[] {
|
|
345
|
+
export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[], options: { compose?: boolean } = {}): BuildTriggerEdit[] {
|
|
346
|
+
const compose = options.compose === true;
|
|
328
347
|
const out: BuildTriggerEdit[] = [];
|
|
329
348
|
for (const s of sources) {
|
|
330
349
|
let doc: unknown;
|
|
@@ -349,17 +368,27 @@ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSour
|
|
|
349
368
|
delete on.schedule;
|
|
350
369
|
const push = isObject(on.push) ? { ...on.push } : {};
|
|
351
370
|
const branches = Array.isArray(push.branches) ? push.branches.filter((b): b is string => typeof b === "string") : [];
|
|
352
|
-
|
|
371
|
+
const withBranch = branches.includes(green.branch) ? branches : [...branches, green.branch];
|
|
372
|
+
push.branches = compose && !withBranch.includes(COMPOSE_BRANCHES) ? [...withBranch, COMPOSE_BRANCHES] : withBranch;
|
|
353
373
|
const { push: _push, workflow_dispatch: dispatch, ...rest } = on;
|
|
354
374
|
text = writeTopLevel(text, "on", { push, workflow_dispatch: dispatch ?? {}, ...rest });
|
|
355
375
|
text = writeTopLevel(text, "concurrency", { group: `chant-build-${tag}-\${{ github.ref }}`, "cancel-in-progress": false });
|
|
356
376
|
} else {
|
|
357
377
|
const main = `refs/heads/${green.branch}`;
|
|
378
|
+
// A push filter that lists branches gains the compose branches; one with no list already runs on every branch.
|
|
379
|
+
const on = isObject(doc) && isObject(doc.on) ? doc.on : undefined;
|
|
380
|
+
const pushOn = on && isObject(on.push) ? on.push : undefined;
|
|
381
|
+
if (compose && on && pushOn && Array.isArray(pushOn.branches) && !pushOn.branches.includes(COMPOSE_BRANCHES)) {
|
|
382
|
+
text = writeTopLevel(text, "on", { ...on, push: { ...pushOn, branches: [...pushOn.branches, COMPOSE_BRANCHES] } });
|
|
383
|
+
}
|
|
384
|
+
// #4220: a compose branch's commits each get a verdict, so its runs are keyed by commit, as the trunk branch's are.
|
|
385
|
+
const keyed = compose ? `(github.ref == '${main}' || startsWith(github.ref, 'refs/heads/${COMPOSE_BRANCH_PREFIX}'))` : `github.ref == '${main}'`;
|
|
386
|
+
const unkeyed = compose ? `!${keyed}` : `github.ref != '${main}'`;
|
|
358
387
|
// Written as lines: the emitter would quote the expression, and GitHub reads it the same either way.
|
|
359
388
|
text = writeTopLevel(text, "concurrency", [
|
|
360
389
|
"concurrency:",
|
|
361
|
-
` group: chant-build-${tag}-\${{
|
|
362
|
-
` cancel-in-progress: \${{
|
|
390
|
+
` group: chant-build-${tag}-\${{ ${keyed} && github.sha || github.ref }}`,
|
|
391
|
+
` cancel-in-progress: \${{ ${unkeyed} }}`,
|
|
363
392
|
]);
|
|
364
393
|
}
|
|
365
394
|
out.push({ path: s.path, build, phases, text, changed: text !== s.text });
|
|
@@ -531,3 +531,36 @@ describe("chant ci list (#4021)", () => {
|
|
|
531
531
|
}
|
|
532
532
|
});
|
|
533
533
|
});
|
|
534
|
+
|
|
535
|
+
describe("chant ci tick walks compose branches (#4168 4.3)", () => {
|
|
536
|
+
test("judges composed commits, names their branch in the tag, and warns when a source they carry is revoked", async () => {
|
|
537
|
+
const { root, origin } = workspace();
|
|
538
|
+
const a = commitAt(root, 5, "app/a.txt");
|
|
539
|
+
const b = commitAt(root, 4, "app/b.txt");
|
|
540
|
+
push(root);
|
|
541
|
+
// A composed commit on a, picking b.
|
|
542
|
+
git(root, "checkout", "-q", "-b", "chant/compose/hf", a);
|
|
543
|
+
writeFiles(root, { "app/b.txt": "app/b.txt 4\n" });
|
|
544
|
+
gitAt(root, hoursAgo(2), "add", "-A");
|
|
545
|
+
gitAt(root, hoursAgo(2), "commit", "-q", "-m", `app/b.txt\n\nChant-Composed-From: ${b}`);
|
|
546
|
+
const c = git(root, "rev-parse", "HEAD");
|
|
547
|
+
git(root, "push", "-q", "origin", "chant/compose/hf");
|
|
548
|
+
git(root, "checkout", "-q", "main");
|
|
549
|
+
|
|
550
|
+
const runs = new Map([
|
|
551
|
+
[a, PASSING],
|
|
552
|
+
[b, PASSING],
|
|
553
|
+
[c, PASSING],
|
|
554
|
+
]);
|
|
555
|
+
const first = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
556
|
+
expect(first.composed.map((x) => [x.branch, x.commits.map((k) => k.sha)])).toEqual([["chant/compose/hf", [c]]]);
|
|
557
|
+
expect(first.made.sort()).toEqual([`ci/green/${a}`, `ci/green/${b}`, `ci/green/${c}`].sort());
|
|
558
|
+
const note = git(origin, "tag", "-l", "--format=%(contents)", `ci/green/${c}`);
|
|
559
|
+
expect(JSON.parse(note.slice(note.indexOf("{")))).toMatchObject({ tag: "green", commit: c, branch: "chant/compose/hf" });
|
|
560
|
+
|
|
561
|
+
runs.set(b, [...PASSING, run(9, "lint", "failure")]);
|
|
562
|
+
const revoke = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
563
|
+
expect(revoke.made).toEqual([`ci/revoked/${b}`]);
|
|
564
|
+
expect(revoke.warnings.join("\n")).toContain(`chant/compose/hf carries it as ${c.slice(0, 12)}`);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
@@ -71,6 +71,7 @@ import { gitRevisionSource } from "./record-source";
|
|
|
71
71
|
import { emptyPolicy, readTrustPolicy } from "./trust/policy";
|
|
72
72
|
import { loadRunnerKey } from "./trust/dsse";
|
|
73
73
|
import { signCiTagRecord, verifyCiTagStatement, CI_TAG_SIGNER_ROLE, type CiTagFailure, type CiTagRecord, type CiTagSigner } from "./trust/ci-tag-statement";
|
|
74
|
+
import { COMPOSE_BRANCH_PREFIX, composedIncluding, isComposeBranch, remoteComposeBranches } from "../lifecycle/compose-refs";
|
|
74
75
|
|
|
75
76
|
/** The environment variable a tick (#4031) and `chant run` (#4044) read a runner key from, as PEM, when `--key` is not given. */
|
|
76
77
|
export const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
|
|
@@ -478,7 +479,9 @@ export function readCiTags(top: string, green: CiGreen | null = null, base: stri
|
|
|
478
479
|
continue;
|
|
479
480
|
}
|
|
480
481
|
const p = policyAtBase();
|
|
481
|
-
|
|
482
|
+
// A tag the tick made walking a compose branch (#4168 4.3) is checked against that branch.
|
|
483
|
+
const walked = isComposeBranch(t.record?.branch) ? (t.record!.branch as string) : green!.branch;
|
|
484
|
+
const v = verifyCiTagStatement(t.record, { sha: t.sha, kind: t.kind, branch: walked }, p.runners, p.roles);
|
|
482
485
|
if (v.status === "signed") counted(v.signer);
|
|
483
486
|
else ignore(v.status, `${v.reason}; ci.green.signedSince is ${green!.signedSince}, so a tag on a commit that is not a strict first-parent ancestor of it needs one`);
|
|
484
487
|
}
|
|
@@ -501,7 +504,7 @@ export function signedRunsRequired(cwd: string): string | null {
|
|
|
501
504
|
}
|
|
502
505
|
|
|
503
506
|
/** The declaration's `ci.green`, read where `cwd` is, and the git top. */
|
|
504
|
-
function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { name: string; root: string; file: string } } {
|
|
507
|
+
export function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { name: string; root: string; file: string } } {
|
|
505
508
|
const located = locateWorkspace(cwd);
|
|
506
509
|
const declaration = readDeclaration(located.tree);
|
|
507
510
|
const green = declaration.ci?.green ?? null;
|
|
@@ -535,12 +538,21 @@ export interface TickRequest {
|
|
|
535
538
|
runnerKey?: string;
|
|
536
539
|
}
|
|
537
540
|
|
|
541
|
+
/** One compose branch a tick walked (#4168 4.3): its composed commits within the window. */
|
|
542
|
+
export interface ComposedBranchTick {
|
|
543
|
+
branch: string;
|
|
544
|
+
ref: string;
|
|
545
|
+
commits: TickCommit[];
|
|
546
|
+
}
|
|
547
|
+
|
|
538
548
|
export interface TickResult {
|
|
539
549
|
branch: string;
|
|
540
550
|
window: string;
|
|
541
551
|
/** The ref the commits were read from. */
|
|
542
552
|
ref: string;
|
|
543
553
|
commits: TickCommit[];
|
|
554
|
+
/** The compose branches walked (#4168 4.3), each with its composed commits judged. Their tags are in `made`. */
|
|
555
|
+
composed: ComposedBranchTick[];
|
|
544
556
|
/** The tags made and pushed, or that would be under `dryRun`. */
|
|
545
557
|
made: string[];
|
|
546
558
|
/** The builds asked for (#4169), as `ci/build/<sha>` tags made and pushed, or that would be under `dryRun`. */
|
|
@@ -596,7 +608,16 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
596
608
|
const now = req.now ?? new Date();
|
|
597
609
|
const tracking = `refs/remotes/${remote}/${green.branch}`;
|
|
598
610
|
try {
|
|
599
|
-
git(top, [
|
|
611
|
+
git(top, [
|
|
612
|
+
"fetch",
|
|
613
|
+
"--quiet",
|
|
614
|
+
"--no-tags",
|
|
615
|
+
"--prune",
|
|
616
|
+
remote,
|
|
617
|
+
`+refs/heads/${green.branch}:${tracking}`,
|
|
618
|
+
`+refs/heads/${COMPOSE_BRANCH_PREFIX}*:refs/remotes/${remote}/${COMPOSE_BRANCH_PREFIX}*`,
|
|
619
|
+
"+refs/tags/ci/*:refs/tags/ci/*",
|
|
620
|
+
]);
|
|
600
621
|
} catch (err) {
|
|
601
622
|
throw new Error(`could not fetch ${green.branch} and the ci tags from ${remote}: ${stderrOf(err)}`);
|
|
602
623
|
}
|
|
@@ -648,6 +669,28 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
648
669
|
signer,
|
|
649
670
|
});
|
|
650
671
|
if (links) for (const c of planned) c.work = links.get(c.sha);
|
|
672
|
+
// Compose branches (#4168 4.3): each composed commit within the window is judged as trunk's are, and its tags name its branch.
|
|
673
|
+
const composed: ComposedBranchTick[] = [];
|
|
674
|
+
for (const b of remoteComposeBranches(top, remote)) {
|
|
675
|
+
const own = git(top, ["log", "--first-parent", `--max-age=${cutoff}`, "--format=%H %ct", b.ref, `^${tracking}`])
|
|
676
|
+
.split("\n")
|
|
677
|
+
.filter(Boolean)
|
|
678
|
+
.map((line) => line.split(" "))
|
|
679
|
+
.filter(([, ct]) => Number(ct) >= cutoff)
|
|
680
|
+
.map(([sha, ct]) => ({ sha, committed: iso(new Date(Number(ct) * 1000)) }));
|
|
681
|
+
if (own.length === 0) continue;
|
|
682
|
+
const judged = await planTick({
|
|
683
|
+
green: { ...green, branch: b.branch },
|
|
684
|
+
commits: own,
|
|
685
|
+
greenTags: new Set(tags.green.keys()),
|
|
686
|
+
revokedTags: new Set(tags.revoked.keys()),
|
|
687
|
+
ignored: tags.ignored,
|
|
688
|
+
checkRuns: async (sha) => (runs ? runs.checkRuns(sha) : await forge!.checkRuns(sha)),
|
|
689
|
+
now,
|
|
690
|
+
signer,
|
|
691
|
+
});
|
|
692
|
+
composed.push({ branch: b.branch, ref: b.ref, commits: judged });
|
|
693
|
+
}
|
|
651
694
|
if (runs) {
|
|
652
695
|
for (const c of planned) {
|
|
653
696
|
const ignored = runs.ignored.get(c.sha);
|
|
@@ -658,7 +701,14 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
658
701
|
const requests = readBuildRequests(top);
|
|
659
702
|
const retryVia = green.source === "runs" ? "request" : forge?.rerun ? "forge" : null;
|
|
660
703
|
const builds = planBuilds({ green, commits: planned, runs: runsBy, requests, records, now, retryVia });
|
|
661
|
-
const due = planned.filter((c) => c.made !== null);
|
|
704
|
+
const due = [...planned, ...composed.flatMap((b) => b.commits)].filter((c) => c.made !== null);
|
|
705
|
+
// Decision 4: revoking a trunk commit says which composed commits carry it; their own CI decides their releases.
|
|
706
|
+
for (const c of planned) {
|
|
707
|
+
if (!c.made?.tag.startsWith(CI_REVOKED_TAG_PREFIX)) continue;
|
|
708
|
+
for (const k of composedIncluding(top, c.sha, remote)) {
|
|
709
|
+
warnings.push(`${c.sha.slice(0, 12)} is revoked, and ${k.branch} carries it as ${k.commit.slice(0, 12)}; that composed commit's own CI decides whether its releases stand`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
662
712
|
if (!req.dryRun && due.length > 0) {
|
|
663
713
|
const ident = taggerConfig(top);
|
|
664
714
|
for (const c of due) git(top, [...ident, "tag", "-a", "-m", c.made!.message, c.made!.tag, c.sha]);
|
|
@@ -683,6 +733,7 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
683
733
|
ref: tracking,
|
|
684
734
|
commits: planned,
|
|
685
735
|
made: due.map((c) => c.made!.tag),
|
|
736
|
+
composed,
|
|
686
737
|
requested: builds.requests,
|
|
687
738
|
retried,
|
|
688
739
|
recorded,
|
|
@@ -964,6 +1015,23 @@ export async function readOpRunSource(cwd: string, top: string, base: string, si
|
|
|
964
1015
|
return { checkRuns: (sha) => byCommit.get(sha) ?? [], ignored };
|
|
965
1016
|
}
|
|
966
1017
|
|
|
1018
|
+
/**
|
|
1019
|
+
* Judge `ci.green.preMerge` on `commit` from the Op run records on the ledger
|
|
1020
|
+
* (#4168 4.4), as `ci serve --land` does before it moves the target. The
|
|
1021
|
+
* trust policy is read at `base`, the target's head. An unsigned run counts
|
|
1022
|
+
* as the tick counts one: always, unless `ci.green.signedSince` asks for a
|
|
1023
|
+
* signature. Throws {@link CiGreenError} when the declaration has no
|
|
1024
|
+
* `ci.green` or names no pre-merge phase.
|
|
1025
|
+
*/
|
|
1026
|
+
export async function judgePreMerge(req: { cwd: string; commit: string; base: string }): Promise<CommitVerdict> {
|
|
1027
|
+
const { green, top } = readGreen(req.cwd);
|
|
1028
|
+
const pre = green.preMerge ?? [];
|
|
1029
|
+
if (pre.length === 0) throw new CiGreenError("ci-green-undeclared", "ci.green.preMerge names no phase, so nothing says which checks must pass before the branch moves");
|
|
1030
|
+
const runs = await readOpRunSource(req.cwd, top, req.base, green.signedSince);
|
|
1031
|
+
const latest = latestAttempts(runs.checkRuns(req.commit));
|
|
1032
|
+
return commitVerdictOf(pre.map((name) => evaluatePhase(green.phases.find((p) => p.name === name)!, latest)));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
967
1035
|
/**
|
|
968
1036
|
* What `chant run <op> --key` signs a run's record with (#4044): the runner
|
|
969
1037
|
* key `.chant/trust.json` at the checkout's `HEAD` lists for a principal
|
|
@@ -1063,6 +1131,19 @@ export function formatTick(r: TickResult, dryRun = false): string {
|
|
|
1063
1131
|
for (const r of c.ignoredRuns ?? []) lines.push(` ignored run ${r.run} of ${r.op} in ${r.env} (${r.status}): ${r.reason}`);
|
|
1064
1132
|
if (c.blocked) lines.push(` did not make ${c.blocked}: an ignored tag holds the name. Delete it to let the tick tag the commit: git push origin :refs/tags/${c.blocked}`);
|
|
1065
1133
|
}
|
|
1134
|
+
for (const b of r.composed ?? []) {
|
|
1135
|
+
lines.push(`${b.branch}, composed commits within ${r.window}: ${b.commits.length}`);
|
|
1136
|
+
for (const c of b.commits) {
|
|
1137
|
+
const what = c.made
|
|
1138
|
+
? `${dryRun ? "would make" : "made"} ${c.made.tag.slice(0, c.made.tag.lastIndexOf("/"))}`
|
|
1139
|
+
: c.before !== "none"
|
|
1140
|
+
? c.before
|
|
1141
|
+
: c.verdict?.verdict === "fail"
|
|
1142
|
+
? `failed: phase ${c.verdict.failure!.phase}`
|
|
1143
|
+
: "undecided";
|
|
1144
|
+
lines.push(` ${c.sha.slice(0, 12)} ${what}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1066
1147
|
if (r.signer && r.made.length > 0) lines.push(`signed as ${r.signer}`);
|
|
1067
1148
|
for (const q of r.requested) lines.push(`${dryRun ? "would ask" : "asked"} for ${q.phases.join(", ")} at ${q.commit.slice(0, 12)} (${q.reason}): ${q.tag}`);
|
|
1068
1149
|
for (const x of r.retried) lines.push(`${dryRun ? "would run" : "ran"} ${x.run} at ${x.sha.slice(0, 12)} again`);
|