@intentius/chant 0.124.34 → 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 +23 -8
- 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 +19 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +17 -0
- package/dist/cli/registry.d.ts +14 -0
- 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.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- 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 +5 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +7 -2
- 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 +225 -223
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +8 -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-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 +4 -4
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +2 -2
- 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/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/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/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 +22 -8
- 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 +19 -1
- package/src/cli/parse-args.ts +13 -0
- package/src/cli/registry.ts +14 -0
- 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.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- 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.test.ts +18 -0
- package/src/graph-declared.ts +8 -1
- 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/checks/ci-workflow.test.ts +12 -2
- package/src/workspace/checks/ci-workflow.ts +7 -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-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 +4 -4
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -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/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/releases.schema.json +30 -0
|
@@ -44,6 +44,12 @@ function releaseTagShapes(): Array<{ recipe: string; example: string }> {
|
|
|
44
44
|
.replace(/\$\{?next\}?/g, "9.9.9");
|
|
45
45
|
shapes.push({ recipe: raw, example });
|
|
46
46
|
}
|
|
47
|
+
// `just release` (#4040) tags nothing itself: the cut makes the tag, and the
|
|
48
|
+
// recipe pushes it as `git push origin "refs/tags/release/npm/$next"`.
|
|
49
|
+
for (const m of justfile.matchAll(/git push origin "refs\/tags\/([^"]+)"/g)) {
|
|
50
|
+
if (m[1] === "$rtag") continue;
|
|
51
|
+
shapes.push({ recipe: m[1], example: m[1].replace(/\$\{?next\}?/g, "9.9.9") });
|
|
52
|
+
}
|
|
47
53
|
return shapes;
|
|
48
54
|
}
|
|
49
55
|
|
|
@@ -79,6 +85,7 @@ describe("release wiring: justfile tags vs publish.yml trigger", () => {
|
|
|
79
85
|
const examples = releaseTagShapes().map((s) => s.example);
|
|
80
86
|
// Whole-repo release and single-lexicon patch. If a recipe stops
|
|
81
87
|
// producing one of these, the assertion above would pass vacuously.
|
|
88
|
+
expect(examples).toContain("release/npm/9.9.9");
|
|
82
89
|
expect(examples).toContain("chant-v9.9.9");
|
|
83
90
|
expect(examples).toContain("lexicon-docker-v9.9.9");
|
|
84
91
|
});
|
|
@@ -124,7 +131,7 @@ describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
|
|
|
124
131
|
// The rewrite is generic (`with_entries` over every `@intentius/*`
|
|
125
132
|
// peer) rather than named keys — a hand-listed rewrite goes stale the
|
|
126
133
|
// same way the hand-listed publish steps did.
|
|
127
|
-
const body = recipeBody("release");
|
|
134
|
+
const body = recipeBody("release-legacy");
|
|
128
135
|
expect(body).toMatch(/\.peerDependencies\s*\|=\s*with_entries/);
|
|
129
136
|
expect(body).toMatch(/startswith\("@intentius\/"\)/);
|
|
130
137
|
});
|
|
@@ -150,7 +157,7 @@ describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
|
|
|
150
157
|
});
|
|
151
158
|
|
|
152
159
|
it("both recipes keep the committed lockfile in step", () => {
|
|
153
|
-
for (const recipe of ["release", "release-lexicon"]) {
|
|
160
|
+
for (const recipe of ["release-legacy", "release-lexicon"]) {
|
|
154
161
|
expect(recipeBody(recipe)).toMatch(/npm install --package-lock-only/);
|
|
155
162
|
}
|
|
156
163
|
});
|
|
@@ -162,7 +169,7 @@ describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
|
|
|
162
169
|
*/
|
|
163
170
|
describe("release wiring: preflight gates both recipes (#1255)", () => {
|
|
164
171
|
it("every release recipe runs the preflight before tagging", () => {
|
|
165
|
-
for (const recipe of ["release", "release-lexicon"]) {
|
|
172
|
+
for (const recipe of ["release-legacy", "release-lexicon"]) {
|
|
166
173
|
const body = recipeBody(recipe);
|
|
167
174
|
expect(
|
|
168
175
|
body.includes("scripts/release-preflight.sh"),
|
|
@@ -183,7 +190,7 @@ describe("release wiring: preflight gates both recipes (#1255)", () => {
|
|
|
183
190
|
*/
|
|
184
191
|
describe("release wiring: release the newest green commit (#2816)", () => {
|
|
185
192
|
it("both recipes tag the commit the preflight chose, not HEAD", () => {
|
|
186
|
-
for (const recipe of ["release", "release-lexicon"]) {
|
|
193
|
+
for (const recipe of ["release-legacy", "release-lexicon"]) {
|
|
187
194
|
const body = recipeBody(recipe);
|
|
188
195
|
expect(body).toMatch(/sha=\$\(bash scripts\/release-preflight\.sh/);
|
|
189
196
|
expect(body).toMatch(/release_open "\$sha"/);
|
|
@@ -191,7 +198,7 @@ describe("release wiring: release the newest green commit (#2816)", () => {
|
|
|
191
198
|
});
|
|
192
199
|
|
|
193
200
|
it("both recipes merge the tag back into main instead of pushing HEAD", () => {
|
|
194
|
-
for (const recipe of ["release", "release-lexicon"]) {
|
|
201
|
+
for (const recipe of ["release-legacy", "release-lexicon"]) {
|
|
195
202
|
const body = recipeBody(recipe);
|
|
196
203
|
expect(body).toMatch(/release_ship "\$tag"/);
|
|
197
204
|
expect(body).not.toMatch(/git push/);
|
|
@@ -205,16 +212,22 @@ describe("release wiring: release the newest green commit (#2816)", () => {
|
|
|
205
212
|
});
|
|
206
213
|
|
|
207
214
|
it("both recipes offer a dry run that stops before committing", () => {
|
|
208
|
-
for (const recipe of ["release", "release-lexicon"]) {
|
|
215
|
+
for (const recipe of ["release-legacy", "release-lexicon"]) {
|
|
209
216
|
const body = recipeBody(recipe);
|
|
210
217
|
expect(body.indexOf("RELEASE_DRY_RUN")).toBeGreaterThan(-1);
|
|
211
218
|
expect(body.indexOf("RELEASE_DRY_RUN")).toBeLessThan(body.indexOf("git commit"));
|
|
212
219
|
}
|
|
213
220
|
});
|
|
214
221
|
|
|
215
|
-
it("the
|
|
216
|
-
expect(recipeBody("release")).toMatch(/release_version_at "\$RELEASE_MAIN"/);
|
|
217
|
-
|
|
222
|
+
it("the floor reads main as well as the green commit", () => {
|
|
223
|
+
expect(recipeBody("release-legacy")).toMatch(/release_version_at "\$RELEASE_MAIN"/);
|
|
224
|
+
// release-lexicon floors through release_lexicon_floor (#4040), which also reads tags and npm.
|
|
225
|
+
expect(recipeBody("release-lexicon")).toMatch(/release_lexicon_floor \{\{name\}\} "\$sha"/);
|
|
226
|
+
const lib = readFileSync(join(repoRoot, "scripts", "release-lib.sh"), "utf-8");
|
|
227
|
+
const floor = lib.slice(lib.indexOf("release_lexicon_floor() {"));
|
|
228
|
+
expect(floor).toMatch(/release_version_at "\$\{RELEASE_MAIN:-origin\/main\}"/);
|
|
229
|
+
expect(floor).toMatch(/git tag -l 'release\/npm\/\*'/);
|
|
230
|
+
expect(floor).toMatch(/npm view "\$pkg" version/);
|
|
218
231
|
});
|
|
219
232
|
|
|
220
233
|
it("a push to main does not cancel main's chant run; PR runs still cancel", () => {
|
|
@@ -226,3 +239,37 @@ describe("release wiring: release the newest green commit (#2816)", () => {
|
|
|
226
239
|
expect(block).toMatch(/cancel-in-progress: \$\{\{ github\.ref != 'refs\/heads\/main' \}\}/);
|
|
227
240
|
});
|
|
228
241
|
});
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* #4040 — `just release` cuts chant with its own release command: the
|
|
245
|
+
* preflight still picks the green commit, `chant release cut --at` cuts it
|
|
246
|
+
* with no bump commit and no signing, and the recipe pushes the tag the cut
|
|
247
|
+
* made. Its floor reads every tag, since package.json no longer moves.
|
|
248
|
+
*/
|
|
249
|
+
describe("release wiring: just release runs chant release cut (#4040)", () => {
|
|
250
|
+
const body = () => recipeBody("release");
|
|
251
|
+
|
|
252
|
+
it("cuts the commit the preflight chose, from the release/ member", () => {
|
|
253
|
+
expect(body()).toMatch(/sha=\$\(bash scripts\/release-preflight\.sh/);
|
|
254
|
+
expect(body()).toMatch(/cd release && .*release cut "\$next" --at "\$sha"/);
|
|
255
|
+
expect(body().indexOf("scripts/release-preflight.sh")).toBeLessThan(body().indexOf("release cut"));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("makes no bump commit and signs nothing", () => {
|
|
259
|
+
expect(body()).not.toMatch(/git commit/);
|
|
260
|
+
expect(body()).not.toMatch(/--sign/);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("a dry run cuts without --yes and stops before any push", () => {
|
|
264
|
+
expect(body()).toMatch(/\[ "\$RELEASE_DRY_RUN" = 1 \] \|\| yes=\(--yes\)/);
|
|
265
|
+
const dry = body().indexOf('if [ "$RELEASE_DRY_RUN" = 1 ]; then');
|
|
266
|
+
expect(dry).toBeGreaterThan(-1);
|
|
267
|
+
expect(dry).toBeLessThan(body().indexOf("git push"));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("floors on package.json at the commit and on every chant-v and release/npm tag", () => {
|
|
271
|
+
expect(body()).toMatch(/release_version_at "\$sha"/);
|
|
272
|
+
expect(body()).toMatch(/git tag -l 'chant-v\*'/);
|
|
273
|
+
expect(body()).toMatch(/git tag -l 'release\/npm\/\*'/);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -170,6 +170,28 @@ describe("maybeRecordAutoRelease", () => {
|
|
|
170
170
|
expect(appendReleaseRecordMock).not.toHaveBeenCalled();
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
test("#4222: no artifact under a release -> recorded with the release's templates digest", async () => {
|
|
174
|
+
appendReleaseRecordMock.mockResolvedValue({
|
|
175
|
+
commit: "c".repeat(40),
|
|
176
|
+
record: { version: 1, component: "infra", env: "staging", digest: "sha256:tpl", gitSha: "x", runId: "r", timestamp: "t", actor: "alice" },
|
|
177
|
+
});
|
|
178
|
+
const outcome = await maybeRecordAutoRelease(
|
|
179
|
+
{ component: "infra", env: "staging", success: true, records: [applyRecord()], runId: "run-1", releaseDigest: "sha256:rel", templatesDigest: "sha256:tpl" },
|
|
180
|
+
{ actor: "alice", gitSha: "a".repeat(40) },
|
|
181
|
+
);
|
|
182
|
+
expect(outcome).toMatchObject({ recorded: true });
|
|
183
|
+
const [input] = appendReleaseRecordMock.mock.calls[0];
|
|
184
|
+
expect(input).toMatchObject({ digest: "sha256:tpl", releaseDigest: "sha256:rel" });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("#4222: a templates digest without a release is ignored", async () => {
|
|
188
|
+
const outcome = await maybeRecordAutoRelease(
|
|
189
|
+
{ component: "infra", env: "staging", success: true, records: [applyRecord()], runId: "run-1", templatesDigest: "sha256:tpl" },
|
|
190
|
+
{ actor: "alice" },
|
|
191
|
+
);
|
|
192
|
+
expect(outcome).toMatchObject({ recorded: false, reason: "no-digest" });
|
|
193
|
+
});
|
|
194
|
+
|
|
173
195
|
test("no resolvable actor -> skipped, not an error", async () => {
|
|
174
196
|
const outcome = await maybeRecordAutoRelease(
|
|
175
197
|
{ component: "svc", env: "prod", success: true, records: [publishRecord("sha256:abc")], runId: "run-1" },
|
|
@@ -103,6 +103,12 @@ export interface AutoReleaseRunInfo {
|
|
|
103
103
|
inputDigest?: string;
|
|
104
104
|
/** The release the deploy was made under (#4043), recorded as the release record's `releaseDigest`. Set only when the caller named a release; never inferred. */
|
|
105
105
|
releaseDigest?: string;
|
|
106
|
+
/**
|
|
107
|
+
* The digest of the templates the release records for this component in
|
|
108
|
+
* this environment (#4222, `releaseTemplatesDigest`), recorded as `digest`
|
|
109
|
+
* when the run published no artifact. Set only with `releaseDigest`.
|
|
110
|
+
*/
|
|
111
|
+
templatesDigest?: string;
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
/** Opt-out + field-override knobs for auto-release recording, threaded from CLI flags/config (#597: "opt-out-able (flag/config), documented default"). */
|
|
@@ -135,7 +141,8 @@ function resolveActor(override?: string): string | undefined {
|
|
|
135
141
|
* - `options.disabled` — the opt-out flag/config was set.
|
|
136
142
|
* - `run.success` false — defensive; callers should not reach here on failure.
|
|
137
143
|
* - no digest found in the run's records — a component with no publish step
|
|
138
|
-
* has nothing to bind a release record to
|
|
144
|
+
* has nothing to bind a release record to, unless the run was made under a
|
|
145
|
+
* release that records its templates (`templatesDigest`, #4222).
|
|
139
146
|
* - no resolvable actor — mirrors `runComponentsReleaseRecord`'s hard
|
|
140
147
|
* requirement; an unattributed record defeats the ledger's purpose.
|
|
141
148
|
*
|
|
@@ -159,7 +166,7 @@ export async function maybeRecordAutoRelease(
|
|
|
159
166
|
return { recorded: false, reason: "run-not-successful" };
|
|
160
167
|
}
|
|
161
168
|
|
|
162
|
-
const digest = run.digest ?? extractRunDigest(run.records ?? []);
|
|
169
|
+
const digest = run.digest ?? extractRunDigest(run.records ?? []) ?? (run.releaseDigest ? run.templatesDigest : undefined);
|
|
163
170
|
if (!digest) {
|
|
164
171
|
return { recorded: false, reason: "no-digest", detail: `component "${run.component}" published no digest-bearing artifact` };
|
|
165
172
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4168 step 9: apply on merge per green commit. `coverByMerges` checks the
|
|
3
|
+
* plan at a green commit against the approved plans of the merges since the
|
|
4
|
+
* last applied one; `resolveGreenTarget` finds those merges and where the
|
|
5
|
+
* change is measured from.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
9
|
+
import { composeChangeSet, type ChangeSetEntry } from "../change-set";
|
|
10
|
+
import { cleanScratch, commitAll, declaration, git, repo, writeFiles } from "../workspace/__fixtures__/contract-repo";
|
|
11
|
+
import { memoryGateLedgerPort } from "../op/gate";
|
|
12
|
+
import { persistChangeSet } from "../lifecycle/change-set-store";
|
|
13
|
+
import { MERGE_RECORD_CONTRACT, MERGE_RECORD_SCHEMA_ID, writeMergeRecord, type MergeRecord } from "../lifecycle/merge-record";
|
|
14
|
+
import { GREEN_APPLY_CONTRACT, readGreenApply, writeGreenApply } from "../lifecycle/green-apply";
|
|
15
|
+
import { coverByMerges, greenOp, resolveGreenTarget, type ApprovedMerge } from "./green-cover";
|
|
16
|
+
|
|
17
|
+
afterAll(cleanScratch);
|
|
18
|
+
|
|
19
|
+
const member = (name: string, digest: string) => ({
|
|
20
|
+
member: name,
|
|
21
|
+
lexicon: "terraform",
|
|
22
|
+
planner: "tofu" as const,
|
|
23
|
+
status: "planned" as const,
|
|
24
|
+
planDigest: `jcs1-sha256:${digest.repeat(64)}`,
|
|
25
|
+
holes: [],
|
|
26
|
+
});
|
|
27
|
+
const write = (name: string, cidr: string, action: ChangeSetEntry["action"] = "update"): ChangeSetEntry => ({
|
|
28
|
+
member: name,
|
|
29
|
+
lexicon: "terraform",
|
|
30
|
+
planner: "tofu",
|
|
31
|
+
address: `${name}.main`,
|
|
32
|
+
type: "aws_subnet",
|
|
33
|
+
action,
|
|
34
|
+
attributes: action === "no-op" ? [] : [{ path: "cidr", after: cidr }],
|
|
35
|
+
});
|
|
36
|
+
const merge = (commit: string, pr: number, parts: Array<[string, string, string]>, approvedBy = [`github:r${pr}`]): ApprovedMerge => ({
|
|
37
|
+
commit,
|
|
38
|
+
pr,
|
|
39
|
+
doc: composeChangeSet(parts.map(([name, digest, cidr]) => ({ member: member(name, digest), entries: [write(name, cidr)] }))),
|
|
40
|
+
approvedBy,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("coverByMerges", () => {
|
|
44
|
+
test("each member is checked against the newest merge that planned it, and the approvers of the merges used are kept", () => {
|
|
45
|
+
const merges = [merge("a".repeat(40), 1, [["net", "1", "10.0.0.0/16"], ["app", "2", "10.1.0.0/16"]]), merge("b".repeat(40), 2, [["app", "3", "10.2.0.0/16"]])];
|
|
46
|
+
// net as #1 approved it, app as #2 (the newer one) approved it.
|
|
47
|
+
const fresh = composeChangeSet([
|
|
48
|
+
{ member: member("net", "1"), entries: [write("net", "10.0.0.0/16")] },
|
|
49
|
+
{ member: member("app", "3"), entries: [write("app", "10.2.0.0/16")] },
|
|
50
|
+
]);
|
|
51
|
+
expect(coverByMerges(fresh, merges)).toEqual({ covered: true, uncovered: [], approvedBy: ["github:r1", "github:r2"], coveredBy: ["a".repeat(40), "b".repeat(40)] });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an older merge's plan does not cover a member a newer merge planned again", () => {
|
|
55
|
+
const merges = [merge("a".repeat(40), 1, [["app", "2", "10.1.0.0/16"]]), merge("b".repeat(40), 2, [["app", "3", "10.2.0.0/16"]])];
|
|
56
|
+
const fresh = composeChangeSet([{ member: member("app", "4"), entries: [write("app", "10.1.0.0/16")] }]);
|
|
57
|
+
const cover = coverByMerges(fresh, merges);
|
|
58
|
+
expect(cover.covered).toBe(false);
|
|
59
|
+
expect(cover.uncovered).toEqual(["app now writes app.main.cidr to a different value than the approved plan (checked against #2)"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a member no merge planned is not covered, and a member with nothing to do needs no approval", () => {
|
|
63
|
+
const merges = [merge("a".repeat(40), 1, [["net", "1", "10.0.0.0/16"]])];
|
|
64
|
+
const fresh = composeChangeSet([
|
|
65
|
+
{ member: member("net", "1"), entries: [write("net", "10.0.0.0/16")] },
|
|
66
|
+
{ member: member("edge", "5"), entries: [write("edge", "10.9.0.0/16")] },
|
|
67
|
+
{ member: member("idle", "6"), entries: [write("idle", "", "no-op")] },
|
|
68
|
+
]);
|
|
69
|
+
expect(coverByMerges(fresh, merges)).toMatchObject({
|
|
70
|
+
covered: false,
|
|
71
|
+
uncovered: ["edge: no merge since the last applied green commit had an approved plan for it"],
|
|
72
|
+
coveredBy: ["a".repeat(40)],
|
|
73
|
+
});
|
|
74
|
+
expect(coverByMerges(composeChangeSet([{ member: member("idle", "6"), entries: [write("idle", "", "no-op")] }]), [])).toMatchObject({ covered: true, approvedBy: [] });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a green commit's own gate is recorded under green-<sha>", () => {
|
|
78
|
+
expect(greenOp("c".repeat(40))).toBe(`green-${"c".repeat(12)}`);
|
|
79
|
+
expect(greenOp("c".repeat(40), "net")).toBe(`green-${"c".repeat(12)}-net`);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("resolveGreenTarget", () => {
|
|
84
|
+
const GREEN = { branch: "main", phases: { test: ["test"] }, require: ["test"], source: "runs" };
|
|
85
|
+
|
|
86
|
+
/** A repository with three commits on main: c1 and c3 tagged green, c2 not. */
|
|
87
|
+
function history(): { root: string; c1: string; c2: string; c3: string } {
|
|
88
|
+
const root = repo({ "chant.workspace.json": declaration([], { ci: { green: GREEN } }), "a.txt": "1\n" }, true);
|
|
89
|
+
git(root, "branch", "-M", "main");
|
|
90
|
+
const c1 = git(root, "rev-parse", "HEAD");
|
|
91
|
+
writeFiles(root, { "a.txt": "2\n" });
|
|
92
|
+
const c2 = commitAll(root, "two");
|
|
93
|
+
writeFiles(root, { "a.txt": "3\n" });
|
|
94
|
+
const c3 = commitAll(root, "three");
|
|
95
|
+
for (const c of [c1, c3]) git(root, "tag", `ci/green/${c}`, c);
|
|
96
|
+
return { root, c1, c2, c3 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const record = (commit: string, base: string, pr: number, plan: MergeRecord["plan"], digest: string | null): MergeRecord => ({
|
|
100
|
+
$schema: MERGE_RECORD_SCHEMA_ID,
|
|
101
|
+
contract: MERGE_RECORD_CONTRACT,
|
|
102
|
+
commit,
|
|
103
|
+
base,
|
|
104
|
+
env: "prod",
|
|
105
|
+
source: { kind: "pull-request", pr },
|
|
106
|
+
op: `pr-${pr}`,
|
|
107
|
+
gate: "pr-apply",
|
|
108
|
+
digest,
|
|
109
|
+
plan,
|
|
110
|
+
approvedBy: [],
|
|
111
|
+
components: ["net"],
|
|
112
|
+
recordedAt: "2026-10-21T00:00:00Z",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("HEAD must be green", async () => {
|
|
116
|
+
const { root, c2 } = history();
|
|
117
|
+
git(root, "checkout", "-q", c2);
|
|
118
|
+
const r = await resolveGreenTarget({ cwd: root, env: "prod", gate: "pr-apply" });
|
|
119
|
+
expect(r).toMatchObject({ status: "error", message: expect.stringContaining("is not a green commit") });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("with nothing applied it measures from the green commit before, else --base, and lists the merges with a standing approval", async () => {
|
|
123
|
+
const { root, c1, c2, c3 } = history();
|
|
124
|
+
const approved = composeChangeSet([{ member: member("net", "1"), entries: [write("net", "10.0.0.0/16")] }]);
|
|
125
|
+
await persistChangeSet(approved, { cwd: root });
|
|
126
|
+
await writeMergeRecord(record(c2, c1, 2, "approved", approved.digest), { cwd: root });
|
|
127
|
+
await writeMergeRecord(record(c3, c2, 3, "unapproved", null), { cwd: root });
|
|
128
|
+
const ledger = memoryGateLedgerPort({
|
|
129
|
+
resolutions: [{ version: 1, kind: "resolution", op: "pr-2", gate: "pr-apply", resolvedBy: "github:alice", timestamp: "2026-10-21T00:00:00Z", planDigest: approved.digest }],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const r = await resolveGreenTarget({ cwd: root, env: "prod", gate: "pr-apply", ledger });
|
|
133
|
+
expect(r.status).toBe("target");
|
|
134
|
+
if (r.status !== "target") return;
|
|
135
|
+
expect(r.target).toMatchObject({ commit: c3, base: c1, baseFrom: "previous-green", merges: [c2, c3], prs: [2, 3] });
|
|
136
|
+
expect(r.target.approved.map((m) => [m.commit, m.pr, m.doc.digest, m.approvedBy])).toEqual([[c2, 2, approved.digest, ["github:alice"]]]);
|
|
137
|
+
|
|
138
|
+
// No approval stands for #2 any more: nothing covers.
|
|
139
|
+
const none = await resolveGreenTarget({ cwd: root, env: "prod", gate: "pr-apply", ledger: memoryGateLedgerPort() });
|
|
140
|
+
expect(none.status === "target" && none.target.approved).toEqual([]);
|
|
141
|
+
|
|
142
|
+
const flagged = await resolveGreenTarget({ cwd: root, env: "prod", gate: "pr-apply", base: c2, ledger });
|
|
143
|
+
expect(flagged).toMatchObject({ status: "target", target: { base: c2, baseFrom: "flag", merges: [c3] } });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("once a green commit is applied the next measures from it, and applying it again is nothing", async () => {
|
|
147
|
+
const { root, c1, c2, c3 } = history();
|
|
148
|
+
const applied = { contract: GREEN_APPLY_CONTRACT, env: "prod", commit: c2, base: c1, digest: "jcs1-sha256:x", approval: "gate" as const, approvedBy: [], merges: [c2], appliedAt: "2026-10-21T00:00:00Z" };
|
|
149
|
+
await writeGreenApply(applied, { cwd: root });
|
|
150
|
+
expect(await readGreenApply("prod", { cwd: root })).toEqual(applied);
|
|
151
|
+
// The record wins over --base.
|
|
152
|
+
expect(await resolveGreenTarget({ cwd: root, env: "prod", gate: "pr-apply", base: c1, ledger: memoryGateLedgerPort() })).toMatchObject({
|
|
153
|
+
status: "target",
|
|
154
|
+
target: { base: c2, baseFrom: "applied", merges: [c3], prs: [] },
|
|
155
|
+
});
|
|
156
|
+
await writeGreenApply({ ...applied, commit: c3, base: c2, merges: [c3] }, { cwd: root });
|
|
157
|
+
expect(await resolveGreenTarget({ cwd: root, env: "prod", gate: "pr-apply" })).toEqual({ status: "applied", commit: c3 });
|
|
158
|
+
// Another environment has applied nothing.
|
|
159
|
+
expect(await resolveGreenTarget({ cwd: root, env: "staging", gate: "pr-apply", ledger: memoryGateLedgerPort() })).toMatchObject({ status: "target", target: { base: c1 } });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("without ci.green there is no green commit to apply", async () => {
|
|
163
|
+
const root = repo({ "chant.workspace.json": declaration([]), "a.txt": "1\n" }, true);
|
|
164
|
+
expect(await resolveGreenTarget({ cwd: root, env: "prod", gate: "pr-apply" })).toMatchObject({
|
|
165
|
+
status: "error",
|
|
166
|
+
message: expect.stringContaining("apply on merge applies green commits"),
|
|
167
|
+
hint: expect.stringContaining("ci.green"),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply on merge per green commit (#4168 step 9): whether the change sets
|
|
3
|
+
* approved for the merges since the last applied green commit cover the plan
|
|
4
|
+
* at the green one.
|
|
5
|
+
*
|
|
6
|
+
* Each member of the fresh plan is checked against the newest approved change
|
|
7
|
+
* set that planned it, with the same rule `pr-apply` uses for one pull request
|
|
8
|
+
* (`planCoveredBy`, ./pr-run.ts). A member with nothing to do is covered by
|
|
9
|
+
* nothing. Any member left uncovered sends the whole plan to a gate of its own,
|
|
10
|
+
* bound to the combined digest.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import type { ChangeSetDocument } from "../change-set";
|
|
15
|
+
import type { GateLedgerPort } from "../op/gate";
|
|
16
|
+
import { planCoveredBy } from "./pr-run";
|
|
17
|
+
|
|
18
|
+
/** One merge's approved change set, with who approved it. */
|
|
19
|
+
export interface ApprovedMerge {
|
|
20
|
+
commit: string;
|
|
21
|
+
pr?: number;
|
|
22
|
+
doc: ChangeSetDocument;
|
|
23
|
+
approvedBy: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GreenCover {
|
|
27
|
+
covered: boolean;
|
|
28
|
+
/** Why each uncovered member is not covered, one line each. */
|
|
29
|
+
uncovered: string[];
|
|
30
|
+
/** The approvers of the change sets that covered a member, sorted. */
|
|
31
|
+
approvedBy: string[];
|
|
32
|
+
/** The merges whose approval covered a member, in trunk order. */
|
|
33
|
+
coveredBy: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether `member` has anything to apply in `doc`. */
|
|
37
|
+
function hasWork(doc: ChangeSetDocument, member: string): boolean {
|
|
38
|
+
const m = doc.members.find((x) => x.member === member);
|
|
39
|
+
if (m && m.status === "failed") return true;
|
|
40
|
+
if (doc.entries.some((e) => e.member === member && e.action !== "no-op" && e.action !== "read")) return true;
|
|
41
|
+
return (doc.sideEffects ?? []).some((s) => s.member === member);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check `fresh` against `merges`, given oldest first, as the first-parent
|
|
46
|
+
* history between the last applied green commit and this one runs.
|
|
47
|
+
*/
|
|
48
|
+
export function coverByMerges(fresh: ChangeSetDocument, merges: readonly ApprovedMerge[]): GreenCover {
|
|
49
|
+
const uncovered: string[] = [];
|
|
50
|
+
const approvers = new Set<string>();
|
|
51
|
+
const used = new Set<string>();
|
|
52
|
+
for (const member of fresh.members.map((m) => m.member)) {
|
|
53
|
+
if (!hasWork(fresh, member)) continue;
|
|
54
|
+
const newest = [...merges].reverse().find((m) => m.doc.members.some((x) => x.member === member));
|
|
55
|
+
if (!newest) {
|
|
56
|
+
uncovered.push(`${member}: no merge since the last applied green commit had an approved plan for it`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const why = planCoveredBy(newest.doc, fresh, member);
|
|
60
|
+
if (why) {
|
|
61
|
+
uncovered.push(`${why} (checked against ${newest.pr !== undefined ? `#${newest.pr}` : newest.commit.slice(0, 12)})`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
used.add(newest.commit);
|
|
65
|
+
for (const a of newest.approvedBy) approvers.add(a);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
covered: uncovered.length === 0,
|
|
69
|
+
uncovered,
|
|
70
|
+
approvedBy: [...approvers].sort(),
|
|
71
|
+
coveredBy: merges.filter((m) => used.has(m.commit)).map((m) => m.commit),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The green commit `pr-apply --green` applies, what it is measured from, and the approved merges between. */
|
|
76
|
+
export interface GreenTarget {
|
|
77
|
+
commit: string;
|
|
78
|
+
/** The last green commit applied to the environment, else `--base`, else the green commit before this one, else its parent. */
|
|
79
|
+
base: string;
|
|
80
|
+
/** Where `base` came from. */
|
|
81
|
+
baseFrom: "applied" | "flag" | "previous-green" | "parent";
|
|
82
|
+
/** The first-parent commits after `base` up to `commit`, oldest first. */
|
|
83
|
+
merges: string[];
|
|
84
|
+
/** Their pull requests, from the merge records, in trunk order. */
|
|
85
|
+
prs: number[];
|
|
86
|
+
/** The merges whose approved change set still stands, oldest first. */
|
|
87
|
+
approved: ApprovedMerge[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type GreenTargetResult =
|
|
91
|
+
| { status: "target"; target: GreenTarget }
|
|
92
|
+
/** The checkout is the green commit last applied. */
|
|
93
|
+
| { status: "applied"; commit: string }
|
|
94
|
+
| { status: "error"; message: string; hint?: string };
|
|
95
|
+
|
|
96
|
+
function gitOut(cwd: string, args: string[]): string | undefined {
|
|
97
|
+
try {
|
|
98
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the green target for `env` at `cwd`'s HEAD (#4168 step 9). HEAD must
|
|
106
|
+
* carry a counting `ci/green` tag and no `ci/revoked` one, judged by the
|
|
107
|
+
* declaration's `ci.green` as `release cut` judges them. Reads the local tags
|
|
108
|
+
* and `chant/lifecycle` and never fetches.
|
|
109
|
+
*/
|
|
110
|
+
export async function resolveGreenTarget(req: { cwd: string; env: string; base?: string; gate: string; ledger?: GateLedgerPort }): Promise<GreenTargetResult> {
|
|
111
|
+
const { readGreen, CI_GREEN_TAG_PREFIX } = await import("../workspace/ci-green");
|
|
112
|
+
const { judgedCiTags } = await import("../lifecycle/release-revocation");
|
|
113
|
+
let green: ReturnType<typeof readGreen>["green"];
|
|
114
|
+
let top: string;
|
|
115
|
+
try {
|
|
116
|
+
({ green, top } = readGreen(req.cwd));
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return {
|
|
119
|
+
status: "error",
|
|
120
|
+
message: `apply on merge applies green commits (#4168), and ${err instanceof Error ? err.message : String(err)}`,
|
|
121
|
+
hint: "Declare ci.green in chant.workspace.json, or run chant ci init --workflow trunk-curated+apply-on-merge.",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const head = gitOut(req.cwd, ["rev-parse", "HEAD"]);
|
|
125
|
+
if (!head) return { status: "error", message: "pr-apply --green applies the checkout's HEAD, and it has none" };
|
|
126
|
+
const tags = await judgedCiTags(top, green);
|
|
127
|
+
const counts = (sha: string) => tags.green.has(sha) && !tags.revoked.has(sha);
|
|
128
|
+
if (!counts(head)) {
|
|
129
|
+
return {
|
|
130
|
+
status: "error",
|
|
131
|
+
message: `${head.slice(0, 12)} is not a green commit (no counting ${CI_GREEN_TAG_PREFIX}${head.slice(0, 12)}… tag, or a ci/revoked one), so apply on merge does not apply it`,
|
|
132
|
+
hint: "Check out the newest green commit first: git checkout --detach \"$(chant ci last-green)\"",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { readGreenApply } = await import("../lifecycle/green-apply");
|
|
137
|
+
const last = await readGreenApply(req.env, { cwd: req.cwd });
|
|
138
|
+
if (last?.commit === head) return { status: "applied", commit: head };
|
|
139
|
+
let base: string | undefined;
|
|
140
|
+
let baseFrom: GreenTarget["baseFrom"] = "applied";
|
|
141
|
+
if (last && gitOut(req.cwd, ["merge-base", "--is-ancestor", last.commit, head]) !== undefined) base = last.commit;
|
|
142
|
+
if (!base && req.base) {
|
|
143
|
+
base = gitOut(req.cwd, ["rev-parse", "--verify", "--quiet", `${req.base}^{commit}`]);
|
|
144
|
+
baseFrom = "flag";
|
|
145
|
+
if (!base) return { status: "error", message: `--base ${req.base} names no commit here` };
|
|
146
|
+
}
|
|
147
|
+
if (!base) {
|
|
148
|
+
const before = (gitOut(req.cwd, ["rev-list", "--first-parent", `${head}^`]) ?? "").split("\n").filter(Boolean);
|
|
149
|
+
base = before.find(counts);
|
|
150
|
+
baseFrom = "previous-green";
|
|
151
|
+
if (!base) {
|
|
152
|
+
base = before[0];
|
|
153
|
+
baseFrom = "parent";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!base) return { status: "error", message: `${head.slice(0, 12)} is a root commit, so there is nothing to measure its change from; pass --base` };
|
|
157
|
+
|
|
158
|
+
const merges = (gitOut(req.cwd, ["rev-list", "--first-parent", "--reverse", `${base}..${head}`]) ?? "").split("\n").filter(Boolean);
|
|
159
|
+
const { readMergeRecord } = await import("../lifecycle/merge-record");
|
|
160
|
+
const { readStoredChangeSet } = await import("../lifecycle/change-set-store");
|
|
161
|
+
const { gitGateLedgerPort } = await import("../op/gate");
|
|
162
|
+
const { decidePrGate, readOnlyLedger, approvalOf } = await import("./pr-run");
|
|
163
|
+
const prs: number[] = [];
|
|
164
|
+
const approved: ApprovedMerge[] = [];
|
|
165
|
+
for (const commit of merges) {
|
|
166
|
+
const record = await readMergeRecord(commit, { cwd: req.cwd }).catch(() => null);
|
|
167
|
+
if (!record || record.env !== req.env) continue;
|
|
168
|
+
const pr = record.source.kind === "pull-request" ? record.source.pr : undefined;
|
|
169
|
+
if (pr !== undefined) prs.push(pr);
|
|
170
|
+
// The digest a person approved: the merged plan's when it matched, else the one approved before it moved.
|
|
171
|
+
const digest = record.plan === "approved" ? record.digest : record.plan === "changed" ? record.approvedDigest : undefined;
|
|
172
|
+
if (!digest || !record.op) continue;
|
|
173
|
+
const stored = await readStoredChangeSet(digest, { cwd: req.cwd }).catch(() => ({ status: "missing" as const }));
|
|
174
|
+
if (stored.status !== "found") continue;
|
|
175
|
+
// The approval must still stand: one revoked since the merge covers nothing.
|
|
176
|
+
const check = await decidePrGate(readOnlyLedger(req.ledger ?? gitGateLedgerPort()), { op: record.op, gate: record.gate ?? req.gate, digest }).catch(() => undefined);
|
|
177
|
+
if (!check?.satisfied) continue;
|
|
178
|
+
approved.push({ commit, ...(pr !== undefined ? { pr } : {}), doc: stored.document, approvedBy: approvalOf(check).approvedBy ?? [] });
|
|
179
|
+
}
|
|
180
|
+
return { status: "target", target: { commit: head, base, baseFrom, merges, prs, approved } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The op a green commit's own gate is recorded under: `green-<sha>`, or `green-<sha>-<member>` for a workspace member. */
|
|
184
|
+
export function greenOp(commit: string, member?: string): string {
|
|
185
|
+
return member ? `green-${commit.slice(0, 12)}-${member}` : `green-${commit.slice(0, 12)}`;
|
|
186
|
+
}
|
|
@@ -10,7 +10,7 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { afterAll, describe, expect, test } from "vitest";
|
|
12
12
|
import { EMPTY_TREE_SHA } from "./changed-files";
|
|
13
|
-
import { githubBaseFallback, gitlabBaseFallback, prLoopJobs } from "./pr-pipeline";
|
|
13
|
+
import { githubBaseFallback, gitlabBaseFallback, greenCheckout, GREEN_TICK_WORKFLOW, prLoopJobs } from "./pr-pipeline";
|
|
14
14
|
|
|
15
15
|
const ZEROS = "0".repeat(40);
|
|
16
16
|
const dir = mkdtempSync(join(tmpdir(), "chant-pr-base-"));
|
|
@@ -90,11 +90,21 @@ describe("gitlabBaseFallback", () => {
|
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
describe("prLoopJobs", () => {
|
|
93
|
-
test("every forge's
|
|
93
|
+
test("every forge's record settles its base first; no plan does, and the apply checks out the newest green commit (#4168 step 9)", () => {
|
|
94
94
|
for (const forge of ["github", "forgejo", "gitlab"] as const) {
|
|
95
|
-
const [plan, apply] = prLoopJobs(forge, "prod", { branch: "trunk" });
|
|
95
|
+
const [plan, apply, record] = prLoopJobs(forge, "prod", { branch: "trunk" });
|
|
96
96
|
expect(plan!.setup).toBeUndefined();
|
|
97
|
-
expect(
|
|
97
|
+
expect(record!.setup).toEqual([forge === "gitlab" ? gitlabBaseFallback("trunk") : githubBaseFallback("trunk")]);
|
|
98
|
+
expect(apply!.setup).toBeUndefined();
|
|
99
|
+
expect(apply!.checkout).toEqual([greenCheckout('"trunk"')]);
|
|
100
|
+
expect(apply!.env).not.toHaveProperty("BASE_SHA");
|
|
101
|
+
expect(apply!.command).toContain("pr-apply --green");
|
|
102
|
+
expect(apply!.run.command).not.toContain("--base");
|
|
98
103
|
}
|
|
99
104
|
});
|
|
105
|
+
|
|
106
|
+
test("the green tick workflow's name is the one ci workflow writes", async () => {
|
|
107
|
+
const { CI_GREEN_WORKFLOW_NAME } = await import("../workspace/ci-green-workflow");
|
|
108
|
+
expect(GREEN_TICK_WORKFLOW).toBe(CI_GREEN_WORKFLOW_NAME);
|
|
109
|
+
});
|
|
100
110
|
});
|