@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.
Files changed (218) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +284 -81
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +37 -2
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +24 -0
  16. package/dist/cli/registry.d.ts +17 -1
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote-rounds.d.ts +102 -0
  28. package/dist/components/promote-rounds.d.ts.map +1 -0
  29. package/dist/components/promote-rounds.js +174 -0
  30. package/dist/components/promote.d.ts +4 -1
  31. package/dist/components/promote.d.ts.map +1 -1
  32. package/dist/components/promote.js +6 -3
  33. package/dist/components/release-gate.d.ts +30 -0
  34. package/dist/components/release-gate.d.ts.map +1 -1
  35. package/dist/components/release-gate.js +92 -42
  36. package/dist/components/release-promote.d.ts +9 -0
  37. package/dist/components/release-promote.d.ts.map +1 -1
  38. package/dist/components/release-promote.js +15 -0
  39. package/dist/config.d.ts +2 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/deep-observation.d.ts +11 -0
  42. package/dist/deep-observation.d.ts.map +1 -1
  43. package/dist/graph-declared.d.ts +9 -1
  44. package/dist/graph-declared.d.ts.map +1 -1
  45. package/dist/graph-declared.js +18 -3
  46. package/dist/graph-detail.d.ts.map +1 -1
  47. package/dist/graph-detail.js +30 -1
  48. package/dist/graph-ir.d.ts.map +1 -1
  49. package/dist/graph-ir.js +13 -2
  50. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  51. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  52. package/dist/lifecycle/compose-refs.d.ts +52 -0
  53. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  54. package/dist/lifecycle/compose-refs.js +115 -0
  55. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  56. package/dist/lifecycle/deep-observe.js +3 -2
  57. package/dist/lifecycle/green-apply.d.ts +39 -0
  58. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  59. package/dist/lifecycle/green-apply.js +51 -0
  60. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  61. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  62. package/dist/lifecycle/publish-declaration.js +15 -0
  63. package/dist/lifecycle/publish-npm.d.ts +7 -4
  64. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  65. package/dist/lifecycle/publish-npm.js +60 -13
  66. package/dist/lifecycle/release-compose.d.ts +91 -0
  67. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  68. package/dist/lifecycle/release-compose.js +235 -0
  69. package/dist/lifecycle/release-cut.d.ts +8 -0
  70. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  71. package/dist/lifecycle/release-cut.js +2 -0
  72. package/dist/lifecycle/release-publish.d.ts +2 -0
  73. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  74. package/dist/lifecycle/release-publish.js +2 -1
  75. package/dist/lifecycle/release-store.d.ts +15 -0
  76. package/dist/lifecycle/release-store.d.ts.map +1 -1
  77. package/dist/lifecycle/release-store.js +5 -1
  78. package/dist/wave-runtime.mjs +226 -224
  79. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  80. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  81. package/dist/workspace/check.schema.json +20 -2
  82. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  83. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  84. package/dist/workspace/checks/ci-workflow.js +130 -0
  85. package/dist/workspace/checks/security.d.ts +16 -1
  86. package/dist/workspace/checks/security.d.ts.map +1 -1
  87. package/dist/workspace/checks/security.js +4 -4
  88. package/dist/workspace/checks.d.ts +1 -1
  89. package/dist/workspace/checks.d.ts.map +1 -1
  90. package/dist/workspace/checks.js +4 -1
  91. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  92. package/dist/workspace/ci-green-cli.js +5 -3
  93. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  94. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  95. package/dist/workspace/ci-green-workflow.js +27 -5
  96. package/dist/workspace/ci-green.d.ts +31 -0
  97. package/dist/workspace/ci-green.d.ts.map +1 -1
  98. package/dist/workspace/ci-green.js +78 -4
  99. package/dist/workspace/ci-init.d.ts +100 -0
  100. package/dist/workspace/ci-init.d.ts.map +1 -0
  101. package/dist/workspace/ci-init.js +434 -0
  102. package/dist/workspace/ci-run.d.ts +6 -0
  103. package/dist/workspace/ci-run.d.ts.map +1 -1
  104. package/dist/workspace/ci-run.js +2 -1
  105. package/dist/workspace/ci-serve.d.ts +94 -13
  106. package/dist/workspace/ci-serve.d.ts.map +1 -1
  107. package/dist/workspace/ci-serve.js +248 -30
  108. package/dist/workspace/ci-workflow.d.ts +91 -0
  109. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  110. package/dist/workspace/ci-workflow.js +33 -0
  111. package/dist/workspace/declaration.d.ts +18 -0
  112. package/dist/workspace/declaration.d.ts.map +1 -1
  113. package/dist/workspace/declaration.js +9 -2
  114. package/dist/workspace/declaration.schema.json +28 -0
  115. package/dist/workspace/delivery-metrics.d.ts +3 -1
  116. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  117. package/dist/workspace/delivery-metrics.js +12 -3
  118. package/dist/workspace/init.js +2 -2
  119. package/dist/workspace/member-commands.d.ts.map +1 -1
  120. package/dist/workspace/member-commands.js +4 -0
  121. package/dist/workspace/nested-graph.d.ts.map +1 -1
  122. package/dist/workspace/nested-graph.js +1 -0
  123. package/dist/workspace/reason-codes.d.ts +9 -0
  124. package/dist/workspace/reason-codes.d.ts.map +1 -1
  125. package/dist/workspace/reason-codes.js +10 -0
  126. package/dist/workspace/release-candidates.d.ts +28 -0
  127. package/dist/workspace/release-candidates.d.ts.map +1 -1
  128. package/dist/workspace/release-candidates.js +38 -2
  129. package/dist/workspace/release-candidates.schema.json +51 -0
  130. package/dist/workspace/release-workflow.d.ts +31 -0
  131. package/dist/workspace/release-workflow.d.ts.map +1 -0
  132. package/dist/workspace/release-workflow.js +150 -0
  133. package/dist/workspace/releases.schema.json +30 -0
  134. package/package.json +1 -1
  135. package/src/cli/handlers/graph.ts +2 -2
  136. package/src/cli/handlers/pr-green.test.ts +230 -0
  137. package/src/cli/handlers/pr.ts +148 -66
  138. package/src/cli/handlers/promote.test.ts +34 -0
  139. package/src/cli/handlers/promote.ts +302 -91
  140. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  141. package/src/cli/handlers/release.ts +112 -10
  142. package/src/cli/handlers/run.ts +12 -1
  143. package/src/cli/main.ts +37 -2
  144. package/src/cli/parse-args.ts +18 -0
  145. package/src/cli/registry.ts +17 -1
  146. package/src/codegen/release-wiring.test.ts +56 -9
  147. package/src/components/auto-release.test.ts +22 -0
  148. package/src/components/auto-release.ts +9 -2
  149. package/src/components/green-cover.test.ts +170 -0
  150. package/src/components/green-cover.ts +186 -0
  151. package/src/components/pr-pipeline.test.ts +14 -4
  152. package/src/components/pr-pipeline.ts +56 -5
  153. package/src/components/promote-rounds.test.ts +220 -0
  154. package/src/components/promote-rounds.ts +232 -0
  155. package/src/components/promote.test.ts +7 -0
  156. package/src/components/promote.ts +9 -4
  157. package/src/components/release-gate.test.ts +47 -0
  158. package/src/components/release-gate.ts +111 -46
  159. package/src/components/release-promote.test.ts +24 -1
  160. package/src/components/release-promote.ts +15 -0
  161. package/src/deep-observation.ts +8 -0
  162. package/src/graph-declared-exports.test.ts +40 -0
  163. package/src/graph-declared.test.ts +18 -0
  164. package/src/graph-declared.ts +17 -2
  165. package/src/graph-detail.test.ts +68 -1
  166. package/src/graph-detail.ts +29 -1
  167. package/src/graph-ir.ts +12 -4
  168. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  169. package/src/lifecycle/compose-refs.ts +125 -0
  170. package/src/lifecycle/deep-observe.test.ts +20 -0
  171. package/src/lifecycle/deep-observe.ts +4 -2
  172. package/src/lifecycle/green-apply.ts +71 -0
  173. package/src/lifecycle/publish-declaration.ts +15 -0
  174. package/src/lifecycle/publish-npm.ts +55 -13
  175. package/src/lifecycle/publish-targets.test.ts +45 -0
  176. package/src/lifecycle/release-compose.test.ts +125 -0
  177. package/src/lifecycle/release-compose.ts +312 -0
  178. package/src/lifecycle/release-cut.ts +6 -0
  179. package/src/lifecycle/release-publish.ts +4 -1
  180. package/src/lifecycle/release-store.ts +16 -1
  181. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  182. package/src/workspace/check-contract.test.ts +3 -2
  183. package/src/workspace/check.schema.json +20 -2
  184. package/src/workspace/checks/ci-workflow.test.ts +160 -0
  185. package/src/workspace/checks/ci-workflow.ts +135 -0
  186. package/src/workspace/checks/security.ts +4 -4
  187. package/src/workspace/checks.ts +4 -1
  188. package/src/workspace/ci-green-cli.ts +5 -3
  189. package/src/workspace/ci-green-workflow.test.ts +52 -0
  190. package/src/workspace/ci-green-workflow.ts +34 -5
  191. package/src/workspace/ci-green.test.ts +33 -0
  192. package/src/workspace/ci-green.ts +85 -4
  193. package/src/workspace/ci-init.test.ts +159 -0
  194. package/src/workspace/ci-init.ts +445 -0
  195. package/src/workspace/ci-report-url.test.ts +6 -1
  196. package/src/workspace/ci-run.test.ts +18 -13
  197. package/src/workspace/ci-run.ts +8 -1
  198. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  199. package/src/workspace/ci-serve-land.test.ts +176 -0
  200. package/src/workspace/ci-serve.test.ts +77 -43
  201. package/src/workspace/ci-serve.ts +268 -34
  202. package/src/workspace/ci-workflow.ts +71 -0
  203. package/src/workspace/declaration.schema.json +28 -0
  204. package/src/workspace/declaration.ts +32 -3
  205. package/src/workspace/delivery-metrics.ts +12 -3
  206. package/src/workspace/graph-contract.test.ts +3 -2
  207. package/src/workspace/init.ts +2 -2
  208. package/src/workspace/member-commands.test.ts +11 -4
  209. package/src/workspace/member-commands.ts +3 -0
  210. package/src/workspace/nested-graph.test.ts +4 -0
  211. package/src/workspace/nested-graph.ts +1 -0
  212. package/src/workspace/reason-codes.test.ts +2 -0
  213. package/src/workspace/reason-codes.ts +10 -0
  214. package/src/workspace/release-candidates.schema.json +51 -0
  215. package/src/workspace/release-candidates.test.ts +30 -3
  216. package/src/workspace/release-candidates.ts +54 -2
  217. package/src/workspace/release-workflow.ts +167 -0
  218. 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 whole-repo floor reads main as well as the green commit", () => {
216
- expect(recipeBody("release")).toMatch(/release_version_at "\$RELEASE_MAIN"/);
217
- expect(recipeBody("release-lexicon")).toMatch(/release_version_at "\$RELEASE_MAIN"/);
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 apply settles its base first; no plan does", () => {
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(apply!.setup).toEqual([forge === "gitlab" ? gitlabBaseFallback("trunk") : githubBaseFallback("trunk")]);
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
  });