@intentius/chant 0.124.32 → 0.124.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ci-jobs.d.ts +4 -0
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +9 -2
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +18 -3
- package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
- package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle-health.js +150 -0
- package/dist/cli/handlers/lifecycle.d.ts +26 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.js +1 -1
- package/dist/cli/handlers/pr.d.ts +2 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +124 -2
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +277 -74
- package/dist/cli/handlers/release.d.ts +16 -1
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +83 -11
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +99 -22
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +30 -1
- package/dist/cli/registry.d.ts +21 -5
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-inputs.d.ts +91 -0
- package/dist/components/deploy-inputs.d.ts.map +1 -0
- package/dist/components/deploy-inputs.js +184 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +9 -0
- package/dist/components/gate-plan.d.ts +8 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +6 -0
- package/dist/components/pr-pipeline.d.ts +33 -10
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +42 -11
- package/dist/components/pr-run.d.ts +1 -1
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/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/release-compile.d.ts +37 -2
- package/dist/components/release-compile.d.ts.map +1 -1
- package/dist/components/release-compile.js +64 -8
- package/dist/components/release-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 +17 -3
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +79 -18
- package/dist/components/verbs/bom-writer.d.ts +1 -1
- package/dist/components/verbs/bom-writer.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
- package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
- package/dist/components/wave-plan.d.ts +10 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +32 -4
- package/dist/graph-declared.d.ts +4 -0
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +11 -1
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +32 -7
- package/dist/health.d.ts +110 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +105 -0
- package/dist/health.schema.json +191 -0
- package/dist/lexicon.d.ts +27 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/merge-record.d.ts +104 -0
- package/dist/lifecycle/merge-record.d.ts.map +1 -0
- package/dist/lifecycle/merge-record.js +102 -0
- package/dist/lifecycle/publish-attachments.d.ts +8 -5
- package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
- package/dist/lifecycle/publish-attachments.js +27 -13
- package/dist/lifecycle/publish-npm.d.ts +3 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +4 -3
- package/dist/lifecycle/release-at.d.ts +40 -0
- package/dist/lifecycle/release-at.d.ts.map +1 -0
- package/dist/lifecycle/release-at.js +83 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +23 -12
- package/dist/lifecycle/release-publish.d.ts +28 -12
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +29 -17
- package/dist/lifecycle/release-store.d.ts +25 -2
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +40 -13
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +1 -0
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +4 -2
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +3 -0
- package/dist/op/activities/shell.d.ts +38 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +89 -3
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +7 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +14 -3
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +15 -5
- package/dist/pr-report-url.d.ts +2 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/runner-start.d.ts +179 -0
- package/dist/runner-start.d.ts.map +1 -0
- package/dist/runner-start.js +277 -0
- package/dist/wave-runtime.mjs +236 -229
- 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 +88 -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 +123 -0
- package/dist/workspace/checks/security.d.ts +50 -0
- package/dist/workspace/checks/security.d.ts.map +1 -0
- package/dist/workspace/checks/security.js +305 -0
- package/dist/workspace/checks.d.ts +2 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +8 -0
- package/dist/workspace/ci-builds.d.ts +163 -0
- package/dist/workspace/ci-builds.d.ts.map +1 -0
- package/dist/workspace/ci-builds.js +315 -0
- package/dist/workspace/ci-green-cli.d.ts +2 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +25 -5
- package/dist/workspace/ci-green-forge.d.ts +12 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +12 -0
- package/dist/workspace/ci-green-workflow.d.ts +55 -1
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +128 -1
- package/dist/workspace/ci-green.d.ts +89 -4
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +310 -23
- package/dist/workspace/ci-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-last-green.schema.json +49 -0
- package/dist/workspace/ci-list.schema.json +35 -1
- package/dist/workspace/ci-run-index.d.ts +1 -1
- package/dist/workspace/ci-run-index.d.ts.map +1 -1
- package/dist/workspace/ci-run-index.schema.json +1 -1
- package/dist/workspace/ci-run.d.ts +12 -6
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +72 -16
- package/dist/workspace/ci-runner-start.d.ts +77 -0
- package/dist/workspace/ci-runner-start.d.ts.map +1 -0
- package/dist/workspace/ci-runner-start.js +185 -0
- package/dist/workspace/ci-serve.d.ts +76 -7
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +153 -19
- package/dist/workspace/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 +35 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +12 -2
- package/dist/workspace/declaration.schema.json +51 -0
- package/dist/workspace/init.js +2 -2
- package/dist/workspace/ls.d.ts +2 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -1
- package/dist/workspace/ls.schema.json +2 -1
- package/dist/workspace/member-pipeline.d.ts +1 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +2 -0
- package/dist/workspace/merge-record.schema.json +49 -0
- package/dist/workspace/pr-report.schema.json +3 -3
- package/dist/workspace/reason-codes.d.ts +26 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +28 -0
- package/dist/workspace/release-candidates.d.ts +129 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -0
- package/dist/workspace/release-candidates.js +233 -0
- package/dist/workspace/release-candidates.schema.json +353 -0
- package/dist/workspace/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/security.d.ts +40 -0
- package/dist/workspace/security.d.ts.map +1 -0
- package/dist/workspace/security.js +99 -0
- package/package.json +8 -1
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
- package/src/ci-jobs.ts +11 -2
- package/src/cli/handlers/build.test.ts +36 -0
- package/src/cli/handlers/build.ts +18 -3
- package/src/cli/handlers/lifecycle-health.test.ts +123 -0
- package/src/cli/handlers/lifecycle-health.ts +157 -0
- package/src/cli/handlers/lifecycle.ts +1 -1
- package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
- package/src/cli/handlers/pr-record.test.ts +201 -0
- package/src/cli/handlers/pr.test.ts +22 -0
- package/src/cli/handlers/pr.ts +127 -4
- package/src/cli/handlers/promote.test.ts +17 -0
- package/src/cli/handlers/promote.ts +296 -84
- package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
- package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
- package/src/cli/handlers/release.e2e.test.ts +19 -28
- package/src/cli/handlers/release.ts +81 -11
- package/src/cli/main.ts +99 -22
- package/src/cli/parse-args.ts +22 -1
- package/src/cli/registry.ts +21 -5
- package/src/components/deploy-inputs.test.ts +108 -0
- package/src/components/deploy-inputs.ts +225 -0
- package/src/components/driver.ts +11 -0
- package/src/components/gate-plan.ts +9 -0
- package/src/components/pr-pipeline.ts +56 -15
- package/src/components/pr-run.ts +1 -1
- package/src/components/promote-rounds.test.ts +220 -0
- package/src/components/promote-rounds.ts +232 -0
- package/src/components/release-compile.test.ts +34 -0
- package/src/components/release-compile.ts +89 -8
- package/src/components/release-gate.test.ts +47 -0
- package/src/components/release-gate.ts +111 -46
- package/src/components/release-promote.test.ts +55 -2
- package/src/components/release-promote.ts +93 -23
- package/src/components/verbs/bom-writer.ts +1 -1
- package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
- package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
- package/src/components/wave-plan.ts +32 -4
- package/src/graph-declared-exports.test.ts +40 -0
- package/src/graph-declared.ts +9 -1
- package/src/graph-detail.test.ts +57 -0
- package/src/graph-detail.ts +33 -7
- package/src/health.schema.json +191 -0
- package/src/health.test.ts +62 -0
- package/src/health.ts +178 -0
- package/src/lexicon.ts +41 -1
- package/src/lifecycle/merge-record.test.ts +111 -0
- package/src/lifecycle/merge-record.ts +154 -0
- package/src/lifecycle/publish-attachments.ts +25 -13
- package/src/lifecycle/publish-npm.ts +4 -3
- package/src/lifecycle/publish-targets.test.ts +34 -4
- package/src/lifecycle/release-at.test.ts +48 -0
- package/src/lifecycle/release-at.ts +100 -0
- package/src/lifecycle/release-cut.test.ts +41 -0
- package/src/lifecycle/release-cut.ts +22 -11
- package/src/lifecycle/release-publish.test.ts +43 -13
- package/src/lifecycle/release-publish.ts +38 -18
- package/src/lifecycle/release-store.ts +57 -13
- package/src/lifecycle/run-ledger.test.ts +13 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
- package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
- package/src/op/activities/activity-contracts.ts +3 -0
- package/src/op/activities/shell-container.e2e.test.ts +67 -0
- package/src/op/activities/shell.test.ts +44 -1
- package/src/op/activities/shell.ts +110 -3
- package/src/op/builders.ts +7 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/step-console.test.ts +4 -2
- package/src/pr-loop.test.ts +29 -0
- package/src/pr-loop.ts +26 -7
- package/src/pr-report-url.ts +2 -2
- package/src/runner-start-bundle.test.ts +29 -0
- package/src/runner-start.test.ts +179 -0
- package/src/runner-start.ts +329 -0
- package/src/workspace/__fixtures__/contract-repo.ts +11 -2
- package/src/workspace/check-contract.test.ts +4 -2
- package/src/workspace/check.schema.json +88 -2
- package/src/workspace/checks/ci-workflow.test.ts +150 -0
- package/src/workspace/checks/ci-workflow.ts +129 -0
- package/src/workspace/checks/security.test.ts +138 -0
- package/src/workspace/checks/security.ts +305 -0
- package/src/workspace/checks.ts +8 -0
- package/src/workspace/ci-builds.e2e.test.ts +208 -0
- package/src/workspace/ci-builds.test.ts +174 -0
- package/src/workspace/ci-builds.ts +385 -0
- package/src/workspace/ci-green-builds.test.ts +180 -0
- package/src/workspace/ci-green-cli.test.ts +54 -0
- package/src/workspace/ci-green-cli.ts +26 -5
- package/src/workspace/ci-green-forge.ts +24 -0
- package/src/workspace/ci-green-workflow.test.ts +84 -1
- package/src/workspace/ci-green-workflow.ts +139 -2
- package/src/workspace/ci-green.test.ts +5 -2
- package/src/workspace/ci-green.ts +359 -27
- package/src/workspace/ci-init.test.ts +159 -0
- package/src/workspace/ci-init.ts +445 -0
- package/src/workspace/ci-last-green.schema.json +49 -0
- package/src/workspace/ci-list.schema.json +35 -1
- package/src/workspace/ci-report-url.test.ts +6 -1
- package/src/workspace/ci-run-index.schema.json +1 -1
- package/src/workspace/ci-run-index.ts +1 -1
- package/src/workspace/ci-run.test.ts +24 -7
- package/src/workspace/ci-run.ts +78 -16
- package/src/workspace/ci-runner-start.test.ts +151 -0
- package/src/workspace/ci-runner-start.ts +193 -0
- package/src/workspace/ci-serve-builds.test.ts +86 -0
- package/src/workspace/ci-serve.test.ts +59 -0
- package/src/workspace/ci-serve.ts +177 -22
- package/src/workspace/ci-workflow.ts +71 -0
- package/src/workspace/declaration.schema.json +51 -0
- package/src/workspace/declaration.test.ts +17 -0
- package/src/workspace/declaration.ts +53 -3
- package/src/workspace/init.ts +2 -2
- package/src/workspace/ls.schema.json +2 -1
- package/src/workspace/ls.ts +3 -2
- package/src/workspace/member-pipeline.ts +2 -1
- package/src/workspace/merge-record.schema.json +49 -0
- package/src/workspace/pr-report.schema.json +3 -3
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +28 -0
- package/src/workspace/release-candidates.schema.json +353 -0
- package/src/workspace/release-candidates.test.ts +106 -0
- package/src/workspace/release-candidates.ts +315 -0
- package/src/workspace/release-workflow.ts +167 -0
- package/src/workspace/security.test.ts +70 -0
- package/src/workspace/security.ts +118 -0
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* ran from it, through the release gate `promote --release` binds (#4045).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { resolve } from "node:path";
|
|
25
|
-
import { readFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
26
26
|
import { loadChantConfig, type ChantConfig } from "../../config";
|
|
27
27
|
import { promotionOrder } from "../../workspace/promotion-order";
|
|
28
28
|
import { resolvePromotionSource } from "../../components/promote-order";
|
|
@@ -70,7 +70,7 @@ import type { CommandContext } from "../registry";
|
|
|
70
70
|
import { redeployLine, releaseRollbackLine, rollbackTarget, unsettledComponents } from "../../lifecycle/mixed-environment";
|
|
71
71
|
|
|
72
72
|
const PROMOTE_USAGE =
|
|
73
|
-
"chant components promote --to <env> [--from <env>] [--allow-skip] [--release <label|digest>] [--component <name> [--digest <sha256:...>] | --digest <component>=<sha256:...> ...] [--dry-run] [--json]";
|
|
73
|
+
"chant components promote --to <env> [--from <env>] [--allow-skip] [--release <label|digest>] [--component <name> [--digest <sha256:...>] | --digest <component>=<sha256:...> ...] [--wave-gate <gate>] [--resume <file>] [--dry-run] [--json]";
|
|
74
74
|
const ROLLBACK_USAGE =
|
|
75
75
|
"chant components rollback <env> (--component <name> [--digest <sha256:...>] | --release <label|digest>) [--dry-run] [--json]";
|
|
76
76
|
const REDEPLOY_USAGE =
|
|
@@ -152,6 +152,20 @@ async function readLedger(env: string): Promise<ReleaseRecord[]> {
|
|
|
152
152
|
return ledger.records;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** What {@link bindReleaseGate} hands the run: the round-one binding, and how to plan and bind a later round or wave (#4168 step 4). */
|
|
156
|
+
interface BoundReleaseGate {
|
|
157
|
+
binding: ReleaseGateBinding;
|
|
158
|
+
doc: ChangeSetDocument;
|
|
159
|
+
empty: boolean;
|
|
160
|
+
reads: import("../../components/release-gate").ReleasePlan["reads"];
|
|
161
|
+
plannedOutputs?: Record<string, string[]>;
|
|
162
|
+
unknownInputs?: Record<string, string[]>;
|
|
163
|
+
/** The approval a resume record carried on, when it still covers what is left. */
|
|
164
|
+
resumedFrom?: string;
|
|
165
|
+
replan: (only: ReadonlySet<string>, outputs: Record<string, Record<string, unknown>>) => Promise<ChangeSetDocument>;
|
|
166
|
+
bind: (doc: ChangeSetDocument, gates: string[]) => Promise<ReleaseGateBinding>;
|
|
167
|
+
}
|
|
168
|
+
|
|
155
169
|
/**
|
|
156
170
|
* Plan, store and bind the release gate for a promote under a release
|
|
157
171
|
* (#4045, ws-104 questions 2 and 6). Undefined when no promoted composition
|
|
@@ -159,6 +173,11 @@ async function readLedger(env: string): Promise<ReleaseRecord[]> {
|
|
|
159
173
|
* a refusal already printed: a provisional member, or a member that failed
|
|
160
174
|
* to plan. On an empty change set, each gate gets an `empty: true`
|
|
161
175
|
* resolution the run then counts once it reads the stored document back.
|
|
176
|
+
*
|
|
177
|
+
* A dependent is planned against what a promoted upstream's plan says it will
|
|
178
|
+
* expose (#4168 step 4). With a resume record, only what is left is planned,
|
|
179
|
+
* against the outputs the record kept, and the approval the record names
|
|
180
|
+
* carries on when it covers that plan.
|
|
162
181
|
*/
|
|
163
182
|
async function bindReleaseGate(input: {
|
|
164
183
|
plan: PromotionPlan;
|
|
@@ -170,7 +189,8 @@ async function bindReleaseGate(input: {
|
|
|
170
189
|
actor: string;
|
|
171
190
|
projectPath: string;
|
|
172
191
|
head: string | undefined;
|
|
173
|
-
|
|
192
|
+
resume?: import("../../components/promote-rounds").PromoteResume;
|
|
193
|
+
}): Promise<BoundReleaseGate | number | undefined> {
|
|
174
194
|
const { plan, under, port } = input;
|
|
175
195
|
const gate = await import("../../components/release-gate");
|
|
176
196
|
const gates = gate.gateNamesOf(input.prepared);
|
|
@@ -199,75 +219,162 @@ async function bindReleaseGate(input: {
|
|
|
199
219
|
unsettled.set(component, `the ${u.outcome.verb} run ${u.outcome.runId} left ${component} ${u.state} in "${env}" and nothing has been recorded for it since, so what it runs is not known`);
|
|
200
220
|
}
|
|
201
221
|
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
message: `the change set of release ${under.label ?? releaseDigest} for "${env}" has provisional members (${provisional.join(", ")}), which no approval covers; nothing was deployed`,
|
|
216
|
-
hint: "Deploy what they read first, so they plan against real values.",
|
|
217
|
-
}));
|
|
218
|
-
return 1;
|
|
219
|
-
}
|
|
220
|
-
const failed = doc.members.filter((m) => m.status === "failed");
|
|
221
|
-
if (failed.length > 0) {
|
|
222
|
-
for (const m of failed) console.error(formatError({ message: `${m.member} failed to plan for "${env}"${m.error ? `: ${m.error}` : ""}` }));
|
|
223
|
-
console.error(formatError({ message: "nothing was deployed" }));
|
|
224
|
-
return 1;
|
|
225
|
-
}
|
|
222
|
+
const digests = new Map(plan.items.map((i) => [i.component, i.digest]));
|
|
223
|
+
const planSet = (only: ReadonlySet<string> | undefined, outputs: Record<string, Record<string, unknown>>) =>
|
|
224
|
+
gate.planReleaseSet({
|
|
225
|
+
release: under.release,
|
|
226
|
+
env,
|
|
227
|
+
components: input.prepared,
|
|
228
|
+
digests,
|
|
229
|
+
registry: input.registry,
|
|
230
|
+
current,
|
|
231
|
+
unsettled,
|
|
232
|
+
seededOutputs: outputs,
|
|
233
|
+
...(only ? { only } : {}),
|
|
234
|
+
});
|
|
226
235
|
|
|
227
|
-
// The document is stored before the gate is decided, so the plan a pending
|
|
228
|
-
// gate waits on can be read, and an empty pass can be checked.
|
|
229
236
|
const { storeGateChangeSet } = await import("../../lifecycle/gate-change-set");
|
|
230
|
-
|
|
231
|
-
const stored = await storeGateChangeSet({ doc, cwd: input.projectPath, at: input.head ?? null });
|
|
232
|
-
if (stored.warning) console.error(formatWarning({ message: stored.warning }));
|
|
233
|
-
} catch (err) {
|
|
234
|
-
console.error(formatWarning({ message: `could not store the change set ${doc.digest}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
235
|
-
}
|
|
237
|
+
const { readStoredChangeSet } = await import("../../lifecycle/change-set-store");
|
|
236
238
|
const { isEmptyChangeSet } = await import("../../change-set");
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
for (const name of gates) {
|
|
249
|
-
const standing = resolutions.some(
|
|
250
|
-
(r) => r.empty === true && r.gate === name && r.environment === env && r.releaseDigest === releaseDigest && r.planDigest === doc.digest,
|
|
251
|
-
);
|
|
252
|
-
if (standing) continue;
|
|
253
|
-
// Pushed with the resolution; the deploy records the promote writes push the same branch.
|
|
254
|
-
await port.appendResolution(
|
|
255
|
-
gate.emptyResolution({ gate: name, doc, actor: input.actor, timestamp, ...(input.head ? { gitSha: input.head } : {}) }),
|
|
256
|
-
);
|
|
239
|
+
const verifyEmpty = gate.emptyVerifier((digest) => readStoredChangeSet(digest, { cwd: input.projectPath }), { releaseDigest, environment: env });
|
|
240
|
+
|
|
241
|
+
/** Refuse, store, and pass the gates of an empty plan; the binding, or the exit code of a refusal already printed. */
|
|
242
|
+
const bindDoc = async (doc: ChangeSetDocument, names: readonly string[], what: string): Promise<{ binding: ReleaseGateBinding; empty: boolean } | number> => {
|
|
243
|
+
const provisional = gate.provisionalMembers(doc);
|
|
244
|
+
if (provisional.length > 0) {
|
|
245
|
+
console.error(formatError({
|
|
246
|
+
message: `${what} of release ${under.label ?? releaseDigest} for "${env}" has provisional members (${provisional.join(", ")}), which no approval covers; nothing more was deployed`,
|
|
247
|
+
hint: "Deploy what they read first, so they plan against real values.",
|
|
248
|
+
}));
|
|
249
|
+
return 1;
|
|
257
250
|
}
|
|
258
|
-
|
|
251
|
+
const failed = doc.members.filter((m) => m.status === "failed");
|
|
252
|
+
if (failed.length > 0) {
|
|
253
|
+
for (const m of failed) console.error(formatError({ message: `${m.member} failed to plan for "${env}"${m.error ? `: ${m.error}` : ""}` }));
|
|
254
|
+
console.error(formatError({ message: "nothing more was deployed" }));
|
|
255
|
+
return 1;
|
|
256
|
+
}
|
|
257
|
+
// The document is stored before the gate is decided, so the plan a pending
|
|
258
|
+
// gate waits on can be read, and an empty pass can be checked.
|
|
259
|
+
try {
|
|
260
|
+
const stored = await storeGateChangeSet({ doc, cwd: input.projectPath, at: input.head ?? null });
|
|
261
|
+
if (stored.warning) console.error(formatWarning({ message: stored.warning }));
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error(formatWarning({ message: `could not store the change set ${doc.digest}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
264
|
+
}
|
|
265
|
+
const empty = isEmptyChangeSet(doc);
|
|
266
|
+
console.error(formatInfo(
|
|
267
|
+
`release gate: ${what} ${doc.digest} for "${env}"` +
|
|
268
|
+
(empty ? ", empty: its gates pass without an approval" : `, ${doc.summary.entries} entr${doc.summary.entries === 1 ? "y" : "ies"}, ${doc.summary.holes} hole(s)`) +
|
|
269
|
+
` (chant change-set show ${doc.digest})`,
|
|
270
|
+
));
|
|
271
|
+
if (empty) {
|
|
272
|
+
if (!port.appendResolution) throw new Error("the gate ledger can't record a resolution, so an empty change set can't pass its gates");
|
|
273
|
+
const { resolutions } = await port.read(gate.RELEASE_GATE_OP);
|
|
274
|
+
const timestamp = new Date().toISOString();
|
|
275
|
+
for (const name of names) {
|
|
276
|
+
const standing = resolutions.some(
|
|
277
|
+
(r) => r.empty === true && r.gate === name && r.environment === env && r.releaseDigest === releaseDigest && r.planDigest === doc.digest,
|
|
278
|
+
);
|
|
279
|
+
if (standing) continue;
|
|
280
|
+
// Pushed with the resolution; the deploy records the promote writes push the same branch.
|
|
281
|
+
await port.appendResolution(
|
|
282
|
+
gate.emptyResolution({ gate: name, doc, actor: input.actor, timestamp, ...(input.head ? { gitSha: input.head } : {}) }),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { binding: { op: gate.RELEASE_GATE_OP, planDigest: doc.digest, releaseDigest, verifyEmpty }, empty };
|
|
287
|
+
};
|
|
259
288
|
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
doc,
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
planDigest: doc.digest,
|
|
267
|
-
releaseDigest,
|
|
268
|
-
verifyEmpty: gate.emptyVerifier((digest) => readStoredChangeSet(digest, { cwd: input.projectPath }), { releaseDigest, environment: env }),
|
|
289
|
+
const later = {
|
|
290
|
+
replan: async (only: ReadonlySet<string>, outputs: Record<string, Record<string, unknown>>) => (await planSet(only, outputs)).doc,
|
|
291
|
+
bind: async (doc: ChangeSetDocument, names: string[]) => {
|
|
292
|
+
const bound = await bindDoc(doc, names, "a later plan");
|
|
293
|
+
if (typeof bound === "number") throw new PromoteStageRefused();
|
|
294
|
+
return bound.binding;
|
|
269
295
|
},
|
|
270
296
|
};
|
|
297
|
+
|
|
298
|
+
// #4168 step 4: a resume plans what is left against the outputs it kept.
|
|
299
|
+
if (input.resume) {
|
|
300
|
+
const done = new Set(input.resume.completed);
|
|
301
|
+
const left = new Set(plan.items.map((i) => i.component).filter((c) => !done.has(c)));
|
|
302
|
+
const fresh = await planSet(left, { ...input.seededOutputs, ...input.resume.outputs });
|
|
303
|
+
const stored = await readStoredChangeSet(input.resume.approvedDigest, { cwd: input.projectPath });
|
|
304
|
+
if (stored.status === "found") {
|
|
305
|
+
const { waveNotCovered } = await import("../../components/pr-run");
|
|
306
|
+
const notCovered = waveNotCovered(stored.document, fresh.doc);
|
|
307
|
+
if (notCovered.length === 0) {
|
|
308
|
+
console.error(formatInfo(`resuming: ${done.size} component(s) applied earlier; what is left is covered by change set ${stored.document.digest}`));
|
|
309
|
+
return {
|
|
310
|
+
doc: stored.document,
|
|
311
|
+
empty: isEmptyChangeSet(stored.document),
|
|
312
|
+
binding: { op: gate.RELEASE_GATE_OP, planDigest: stored.document.digest, releaseDigest, verifyEmpty },
|
|
313
|
+
reads: fresh.reads,
|
|
314
|
+
resumedFrom: stored.document.digest,
|
|
315
|
+
...(fresh.plannedOutputs ? { plannedOutputs: fresh.plannedOutputs } : {}),
|
|
316
|
+
...(fresh.unknownInputs ? { unknownInputs: fresh.unknownInputs } : {}),
|
|
317
|
+
...later,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
for (const line of notCovered) console.error(formatWarning({ message: `resume: ${line}` }));
|
|
321
|
+
}
|
|
322
|
+
const bound = await bindDoc(fresh.doc, gates, "change set");
|
|
323
|
+
if (typeof bound === "number") return bound;
|
|
324
|
+
return { doc: fresh.doc, ...bound, reads: fresh.reads, ...(fresh.plannedOutputs ? { plannedOutputs: fresh.plannedOutputs } : {}), ...(fresh.unknownInputs ? { unknownInputs: fresh.unknownInputs } : {}), ...later };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const first = await planSet(undefined, input.seededOutputs);
|
|
328
|
+
const bound = await bindDoc(first.doc, gates, "change set");
|
|
329
|
+
if (typeof bound === "number") return bound;
|
|
330
|
+
for (const [component, refs] of Object.entries(first.unknownInputs ?? {})) {
|
|
331
|
+
console.error(formatInfo(`${component} reads ${refs.join(", ")}, known only once its upstream applies; it is planned again then, and needs its own approval if its plan moves`));
|
|
332
|
+
}
|
|
333
|
+
return { doc: first.doc, ...bound, reads: first.reads, ...(first.plannedOutputs ? { plannedOutputs: first.plannedOutputs } : {}), ...(first.unknownInputs ? { unknownInputs: first.unknownInputs } : {}), ...later };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A later round's or wave's plan was refused (provisional or failed members); the refusal is already printed. */
|
|
337
|
+
class PromoteStageRefused extends Error {
|
|
338
|
+
constructor() {
|
|
339
|
+
super("a later round's plan was refused");
|
|
340
|
+
this.name = "PromoteStageRefused";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The pull requests a release carries into `env` and how each was approved
|
|
346
|
+
* on merge (#4168 decision 2): shown beside the release gate, never counted
|
|
347
|
+
* by it. The commits are the release commit's first-parent history since
|
|
348
|
+
* the commits `env` runs, at most 200, read from the merge records.
|
|
349
|
+
*/
|
|
350
|
+
async function pullRequestApprovals(release: { commit: string }, since: readonly string[], cwd: string): Promise<Array<import("../../lifecycle/merge-record").MergeRecord>> {
|
|
351
|
+
const { execFileSync } = await import("node:child_process");
|
|
352
|
+
const { readMergeRecord } = await import("../../lifecycle/merge-record");
|
|
353
|
+
let commits: string[] = [];
|
|
354
|
+
try {
|
|
355
|
+
const out = execFileSync("git", ["rev-list", "--first-parent", "--max-count=200", release.commit, ...since.map((c) => `^${c}`)], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
356
|
+
commits = out.split("\n").filter(Boolean);
|
|
357
|
+
} catch {
|
|
358
|
+
return [];
|
|
359
|
+
}
|
|
360
|
+
const records: Array<import("../../lifecycle/merge-record").MergeRecord> = [];
|
|
361
|
+
for (const commit of commits) {
|
|
362
|
+
const record = await readMergeRecord(commit, { cwd }).catch(() => null);
|
|
363
|
+
if (record) records.push(record);
|
|
364
|
+
}
|
|
365
|
+
return records;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** One line per merge record: where it came from and how its plan stood against its approval. */
|
|
369
|
+
function approvalLine(r: import("../../lifecycle/merge-record").MergeRecord): string {
|
|
370
|
+
const from = r.source.kind === "pull-request" ? `#${r.source.pr}` : r.source.kind === "work-item" ? r.source.records.map((x) => `${x.kind}:${x.id}`).join(", ") : "direct push";
|
|
371
|
+
const how =
|
|
372
|
+
r.plan === "approved"
|
|
373
|
+
? `approved by ${r.approvedBy.join(", ") || "nobody named"}${r.authorApproved?.length ? ` (by its author: ${r.authorApproved.join(", ")})` : ""}`
|
|
374
|
+
: r.plan === "changed"
|
|
375
|
+
? `its plan moved after the approval of ${r.approvedDigest ?? "another digest"}`
|
|
376
|
+
: r.plan;
|
|
377
|
+
return `${r.commit.slice(0, 12)} ${from}: ${how}`;
|
|
271
378
|
}
|
|
272
379
|
|
|
273
380
|
/**
|
|
@@ -315,7 +422,7 @@ async function deployPlan(
|
|
|
315
422
|
if (under) {
|
|
316
423
|
const { pinReleaseTemplates } = await import("../../components/release-promote");
|
|
317
424
|
const { readReleaseTemplate } = await import("../../lifecycle/release-store");
|
|
318
|
-
const pinned = await pinReleaseTemplates(prepared, under.release, under.target, (digest) => readReleaseTemplate(digest));
|
|
425
|
+
const pinned = await pinReleaseTemplates(prepared, under.release, under.target, (digest, encoding) => readReleaseTemplate(digest, undefined, encoding));
|
|
319
426
|
if ("errors" in pinned) {
|
|
320
427
|
for (const message of pinned.errors) console.error(formatError({ message }));
|
|
321
428
|
console.error(formatError({ message: `nothing was deployed` }));
|
|
@@ -387,22 +494,50 @@ async function deployPlan(
|
|
|
387
494
|
// #4045: under a release, every gate binds the release's change set for this environment.
|
|
388
495
|
let releaseGate: ReleaseGateBinding | undefined;
|
|
389
496
|
let changeSetJson: { changeSet: string; empty: boolean } | undefined;
|
|
497
|
+
let bound: BoundReleaseGate | undefined;
|
|
498
|
+
// #4168 step 4: a promote that stopped at a round's or a wave's gate left a resume record.
|
|
499
|
+
const rounds = await import("../../components/promote-rounds");
|
|
500
|
+
const resumePath = resolve(project.projectPath, args.resume ?? join(".chant", "promote", `${plan.to}.resume.json`));
|
|
501
|
+
let resume: import("../../components/promote-rounds").PromoteResume | undefined;
|
|
502
|
+
if (under && existsSync(resumePath)) {
|
|
503
|
+
try {
|
|
504
|
+
const record = JSON.parse(readFileSync(resumePath, "utf8")) as import("../../components/promote-rounds").PromoteResume;
|
|
505
|
+
if (record.schema === rounds.PROMOTE_RESUME_SCHEMA && record.env === plan.to && record.releaseDigest === under.release.releaseDigest) resume = record;
|
|
506
|
+
else if (args.resume) console.error(formatWarning({ message: `--resume ${args.resume} is for another release or environment; planning from the start` }));
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.error(formatWarning({ message: `could not read the resume record ${resumePath}: ${err instanceof Error ? err.message : String(err)}` }));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
let pullRequests: Array<import("../../lifecycle/merge-record").MergeRecord> = [];
|
|
390
512
|
if (under) {
|
|
391
513
|
try {
|
|
392
|
-
const
|
|
393
|
-
if (typeof
|
|
514
|
+
const result = await bindReleaseGate({ plan, prepared, under, registry, port, seededOutputs, actor: actor!, projectPath: project.projectPath, head, ...(resume ? { resume } : {}) });
|
|
515
|
+
if (typeof result === "number") {
|
|
394
516
|
tidy();
|
|
395
|
-
return
|
|
517
|
+
return result;
|
|
396
518
|
}
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
519
|
+
if (result) {
|
|
520
|
+
bound = result;
|
|
521
|
+
releaseGate = result.binding;
|
|
522
|
+
changeSetJson = { changeSet: result.doc.digest, empty: result.empty };
|
|
523
|
+
// #4168 decision 2: the pull requests' own approvals, shown beside the gate.
|
|
524
|
+
const since = [...new Set((await readLedger(plan.to)).filter((r) => r.env === plan.to).map((r) => r.gitSha))];
|
|
525
|
+
pullRequests = await pullRequestApprovals(under.release, since, project.projectPath);
|
|
526
|
+
if (pullRequests.length > 0 && !args.json) {
|
|
527
|
+
console.error(formatInfo(`pull requests this release carries into "${plan.to}" (shown, not counted by the release gate):`));
|
|
528
|
+
for (const r of pullRequests) console.error(` ${approvalLine(r)}`);
|
|
529
|
+
}
|
|
400
530
|
}
|
|
401
531
|
} catch (err) {
|
|
402
532
|
tidy();
|
|
403
533
|
throw err;
|
|
404
534
|
}
|
|
405
535
|
}
|
|
536
|
+
if (args.waveGate && !bound) {
|
|
537
|
+
console.error(formatError({ message: "--wave-gate needs a promote under --release whose compositions declare a gate", hint: PROMOTE_USAGE }));
|
|
538
|
+
tidy();
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
406
541
|
// The run id the release records carry, chosen before the run so that
|
|
407
542
|
// `--events-json` (#4041) names the run by it too.
|
|
408
543
|
const { runId, runOrigin } = resolveRunId(args.runId);
|
|
@@ -413,23 +548,83 @@ async function deployPlan(
|
|
|
413
548
|
cwd: project.projectPath,
|
|
414
549
|
});
|
|
415
550
|
let run: Awaited<ReturnType<typeof runPromotion>>;
|
|
551
|
+
let staged: import("../../components/promote-rounds").PromoteRoundsResult | undefined;
|
|
552
|
+
// #4168 step 4: a wave at a time when a dependent read an upstream's plan,
|
|
553
|
+
// under --wave-gate, or on a resume; one run otherwise.
|
|
554
|
+
const inPlan = new Set(plan.items.map((i) => i.component));
|
|
555
|
+
const readsPlanned = bound?.plannedOutputs !== undefined && Object.keys(bound.plannedOutputs).some((d) => inPlan.has(d));
|
|
556
|
+
const inRounds = bound !== undefined && (readsPlanned || args.waveGate !== undefined || resume !== undefined);
|
|
416
557
|
try {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
558
|
+
if (inRounds && bound) {
|
|
559
|
+
staged = await rounds.runPromotionInRounds({
|
|
560
|
+
components: prepared,
|
|
561
|
+
outputs: { ...seededOutputs, ...(resume?.outputs ?? {}) },
|
|
562
|
+
binding: bound.binding,
|
|
563
|
+
approved: bound.doc,
|
|
564
|
+
reads: bound.reads,
|
|
565
|
+
...(resume ? { completed: resume.completed } : {}),
|
|
566
|
+
...(args.waveGate ? { waveGate: args.waveGate } : {}),
|
|
567
|
+
replan: bound.replan,
|
|
568
|
+
bind: bound.bind,
|
|
569
|
+
run: (components, outputs, binding) =>
|
|
570
|
+
runPromotion({
|
|
571
|
+
plan: { ...plan, items: plan.items.filter((i) => components.some((c) => c.name === i.component)) },
|
|
572
|
+
components,
|
|
573
|
+
registry,
|
|
574
|
+
componentOutputs: outputs,
|
|
575
|
+
gates: port,
|
|
576
|
+
releaseGate: binding,
|
|
577
|
+
...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
|
|
578
|
+
...(events ? { onRunEvent: events.emit } : {}),
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
run = staged.run;
|
|
582
|
+
} else {
|
|
583
|
+
run = await runPromotion({
|
|
584
|
+
plan,
|
|
585
|
+
components: prepared,
|
|
586
|
+
registry,
|
|
587
|
+
componentOutputs: seededOutputs,
|
|
588
|
+
gates: port,
|
|
589
|
+
...(releaseGate ? { releaseGate } : {}),
|
|
590
|
+
...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
|
|
591
|
+
...(events ? { onRunEvent: events.emit } : {}),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
427
594
|
} catch (err) {
|
|
595
|
+
if (err instanceof PromoteStageRefused) {
|
|
596
|
+
events?.settle("failed", err.message);
|
|
597
|
+
return 1;
|
|
598
|
+
}
|
|
428
599
|
events?.settle("failed", err instanceof Error ? err.message : String(err));
|
|
429
600
|
throw err;
|
|
430
601
|
} finally {
|
|
431
602
|
tidy();
|
|
432
603
|
}
|
|
604
|
+
if (staged && bound) {
|
|
605
|
+
for (const stage of staged.stages) {
|
|
606
|
+
const label = stage.kind === "round" ? `round ${stage.number}` : `wave ${stage.number}`;
|
|
607
|
+
console.error(formatInfo(`${label} (${stage.components.join(", ")}): planned again as ${stage.digest}, ${stage.status}`));
|
|
608
|
+
for (const line of stage.notCovered) console.error(` not covered by ${bound.doc.digest}: ${line}`);
|
|
609
|
+
if (stage.message) console.error(formatError({ message: stage.message }));
|
|
610
|
+
}
|
|
611
|
+
if (run.status === "ok") {
|
|
612
|
+
rmSync(resumePath, { force: true });
|
|
613
|
+
} else if (staged.completed.length > 0) {
|
|
614
|
+
const record: import("../../components/promote-rounds").PromoteResume = {
|
|
615
|
+
schema: rounds.PROMOTE_RESUME_SCHEMA,
|
|
616
|
+
env: plan.to,
|
|
617
|
+
releaseDigest: under!.release.releaseDigest,
|
|
618
|
+
approvedDigest: resume?.approvedDigest ?? bound.resumedFrom ?? bound.doc.digest,
|
|
619
|
+
completed: staged.completed,
|
|
620
|
+
outputs: staged.outputs,
|
|
621
|
+
at: new Date().toISOString(),
|
|
622
|
+
};
|
|
623
|
+
mkdirSync(dirname(resumePath), { recursive: true });
|
|
624
|
+
writeFileSync(resumePath, `${JSON.stringify(record, null, 2)}\n`);
|
|
625
|
+
console.error(formatInfo(`resume record: ${resumePath}; the same promote carries on from here`));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
433
628
|
// The driver settles the stream; this only ends one it left open.
|
|
434
629
|
events?.settle(run.status === "fail" ? "failed" : run.status);
|
|
435
630
|
if (!args.json) renderDriverHuman(run);
|
|
@@ -464,7 +659,9 @@ async function deployPlan(
|
|
|
464
659
|
|
|
465
660
|
if (args.json) {
|
|
466
661
|
const withChangeSet = under && changeSetJson ? { release: { ...releaseJson.release!, ...changeSetJson } } : releaseJson;
|
|
467
|
-
|
|
662
|
+
const stagedJson = staged ? { stages: staged.stages, ...(bound?.plannedOutputs ? { plannedOutputs: bound.plannedOutputs } : {}), ...(bound?.unknownInputs ? { unknownInputs: bound.unknownInputs } : {}) } : {};
|
|
663
|
+
const prJson = pullRequests.length > 0 ? { pullRequests: pullRequests.map((r) => ({ commit: r.commit, source: r.source, plan: r.plan, digest: r.digest, approvedBy: r.approvedBy, ...(r.authorApproved ? { authorApproved: r.authorApproved } : {}) })) } : {};
|
|
664
|
+
console.log(JSON.stringify({ ...planJson(plan, removed), ...withChangeSet, ...stagedJson, ...prJson, run, recorded, ...(outcome ? { outcome } : {}) }, null, 2));
|
|
468
665
|
} else {
|
|
469
666
|
for (const r of recorded) {
|
|
470
667
|
console.error(formatSuccess(
|
|
@@ -595,6 +792,21 @@ async function resolveUnderRelease(ref: string, env: string, verb: "promote" | "
|
|
|
595
792
|
console.error(formatError(revokedRefusal({ releaseDigest: found.release.releaseDigest, label: found.label }, revocation, await fix(found.release))));
|
|
596
793
|
return 1;
|
|
597
794
|
}
|
|
795
|
+
// #4168: under verified-releases a release deploys only once `chant release verify` passes for it.
|
|
796
|
+
const { securityGroupsAt } = await import("../../workspace/security");
|
|
797
|
+
if (securityGroupsAt(process.cwd()).has("verified-releases")) {
|
|
798
|
+
const { verifyRelease, verificationCode } = await import("../../lifecycle/release-verify");
|
|
799
|
+
const name = found.label ?? found.release.releaseDigest;
|
|
800
|
+
const v = await verifyRelease({ cwd: process.cwd(), ref: found.release.releaseDigest });
|
|
801
|
+
const code = "error" in v ? v.error.code : verificationCode(v);
|
|
802
|
+
if (code) {
|
|
803
|
+
console.error(formatError({
|
|
804
|
+
message: `${code}: release ${name} does not verify (${"error" in v ? v.error.message : v.reason}), and the workspace opts into verified-releases, so ${verb} deploys only a verified release`,
|
|
805
|
+
hint: `Check it with: chant release verify ${name}`,
|
|
806
|
+
}));
|
|
807
|
+
return 1;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
598
810
|
return { release: found.release, label: found.label, target: target.target };
|
|
599
811
|
}
|
|
600
812
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant release cut --at <sha>` and `chant release candidates` end to end
|
|
3
|
+
* (#4168 4.2): a cut of a chosen green commit of trunk from a temporary
|
|
4
|
+
* worktree, while the checkout sits elsewhere with uncommitted changes, with
|
|
5
|
+
* the label reaching the build as CHANT_RELEASE_LABEL (decision 5). A temp
|
|
6
|
+
* repo with a temp bare remote; no cloud, no push of tags.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from "vitest";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { contract, REPO } from "../../workspace/__fixtures__/contract-repo";
|
|
16
|
+
import candidatesSchema from "../../workspace/release-candidates.schema.json";
|
|
17
|
+
|
|
18
|
+
const CHANT = [process.execPath, "--import", pathToFileURL(join(REPO, "node_modules", "tsx", "dist", "loader.mjs")).href, join(REPO, "packages", "core", "src", "cli", "main.ts")];
|
|
19
|
+
const CI_VARS = ["CI", "GITHUB_ACTIONS", "GITHUB_RUN_ID", "GITHUB_SHA", "GITHUB_ACTOR", "GITLAB_CI", "CI_PIPELINE_ID", "CHANT_ENV", "CHANT_RELEASE_LABEL"];
|
|
20
|
+
const { expectValid } = contract(candidatesSchema);
|
|
21
|
+
|
|
22
|
+
function chant(cwd: string, args: string[], extra: Record<string, string> = {}): { code: number; stdout: string; stderr: string } {
|
|
23
|
+
const env: NodeJS.ProcessEnv = { ...process.env, USER: "tester" };
|
|
24
|
+
for (const k of CI_VARS) delete env[k];
|
|
25
|
+
Object.assign(env, extra);
|
|
26
|
+
const r = spawnSync(CHANT[0], [...CHANT.slice(1), ...args], { cwd, encoding: "utf-8", env, timeout: 120_000 });
|
|
27
|
+
return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function git(cwd: string, ...args: string[]): string {
|
|
31
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
32
|
+
if (r.status !== 0) throw new Error(r.stderr);
|
|
33
|
+
return r.stdout.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The template records the environment, the label, and which commit's file the build read.
|
|
37
|
+
const BUILD = `mkdir -p dist && printf '{"env":"%s","label":"%s","marker":"%s"}' "$CHANT_ENV" "$CHANT_RELEASE_LABEL" "$(cat marker.txt)" > dist/api.template.json`;
|
|
38
|
+
|
|
39
|
+
describe("chant release cut --at and release candidates", () => {
|
|
40
|
+
test(
|
|
41
|
+
"cuts a chosen green commit from a temporary worktree with the label in the build, and lists candidates",
|
|
42
|
+
() => {
|
|
43
|
+
const root = mkdtempSync(join(tmpdir(), "chant-release-cut-at-"));
|
|
44
|
+
try {
|
|
45
|
+
const dir = join(root, "work");
|
|
46
|
+
const remote = join(root, "origin.git");
|
|
47
|
+
git(root, "init", "-q", "--bare", "-b", "main", remote);
|
|
48
|
+
git(root, "init", "-q", "-b", "main", dir);
|
|
49
|
+
git(dir, "config", "user.email", "t@chant.dev");
|
|
50
|
+
git(dir, "config", "user.name", "T");
|
|
51
|
+
writeFileSync(join(dir, "chant.config.ts"), `export default { environments: ["dev", "prod"] };\n`);
|
|
52
|
+
writeFileSync(
|
|
53
|
+
join(dir, "api.component.ts"),
|
|
54
|
+
`export const api = { name: "api", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", template: "dist/api.template.json" }] }] };\n`,
|
|
55
|
+
);
|
|
56
|
+
writeFileSync(join(dir, ".gitignore"), "dist/\n");
|
|
57
|
+
writeFileSync(join(dir, "marker.txt"), "one");
|
|
58
|
+
git(dir, "add", ".");
|
|
59
|
+
git(dir, "commit", "-q", "-m", "one (#11)");
|
|
60
|
+
const one = git(dir, "rev-parse", "HEAD");
|
|
61
|
+
writeFileSync(join(dir, "marker.txt"), "two");
|
|
62
|
+
git(dir, "commit", "-q", "-am", "two (#12)");
|
|
63
|
+
const two = git(dir, "rev-parse", "HEAD");
|
|
64
|
+
git(dir, "remote", "add", "origin", remote);
|
|
65
|
+
git(dir, "push", "-q", "origin", "main");
|
|
66
|
+
git(dir, "fetch", "-q", "origin");
|
|
67
|
+
git(dir, "tag", `ci/green/${one}`);
|
|
68
|
+
// The checkout is at two, not green, with an uncommitted change.
|
|
69
|
+
writeFileSync(join(dir, "marker.txt"), "dirty");
|
|
70
|
+
|
|
71
|
+
const unknown = chant(dir, ["release", "cut", "v1", "--at", "nope", "--build", BUILD]);
|
|
72
|
+
expect(unknown.code).toBe(1);
|
|
73
|
+
expect(unknown.stderr).toContain("[release-at-unknown]");
|
|
74
|
+
const notGreen = chant(dir, ["release", "cut", "v1", "--at", two, "--build", BUILD]);
|
|
75
|
+
expect(notGreen.code).toBe(1);
|
|
76
|
+
expect(notGreen.stderr).toContain("[release-commit-not-green]");
|
|
77
|
+
|
|
78
|
+
const candidates = chant(dir, ["release", "candidates", "--json"]);
|
|
79
|
+
expect(candidates.code, candidates.stderr).toBe(0);
|
|
80
|
+
const doc = JSON.parse(candidates.stdout);
|
|
81
|
+
expectValid(doc);
|
|
82
|
+
expect(doc.candidates.map((c: { commit: string }) => c.commit)).toEqual([one]);
|
|
83
|
+
expect(doc.candidates[0]).toMatchObject({ newFor: ["dev", "prod"], cut: [] });
|
|
84
|
+
expect(doc.candidates[0].changes[0]).toMatchObject({ commit: one, pr: 11 });
|
|
85
|
+
expect(doc.pending.map((c: { commit: string }) => c.commit)).toEqual([two]);
|
|
86
|
+
|
|
87
|
+
const dry = chant(dir, ["release", "cut", "v1", "--at", one, "--build", BUILD, "--json"]);
|
|
88
|
+
expect(dry.code, dry.stderr).toBe(0);
|
|
89
|
+
expect(JSON.parse(dry.stdout)).toMatchObject({ dryRun: true, commit: one, at: { ref: one, commit: one } });
|
|
90
|
+
|
|
91
|
+
const cut = chant(dir, ["release", "cut", "v1", "--at", one, "--build", BUILD, "--yes", "--json"]);
|
|
92
|
+
expect(cut.code, cut.stderr).toBe(0);
|
|
93
|
+
const result = JSON.parse(cut.stdout);
|
|
94
|
+
expect(result.release.commit).toBe(one);
|
|
95
|
+
expect(result.tag).toEqual({ name: "release/v1", created: true });
|
|
96
|
+
expect(git(dir, "rev-parse", "release/v1^{commit}")).toBe(one);
|
|
97
|
+
|
|
98
|
+
// The recorded template is commit one's, built with the label, and the checkout is untouched.
|
|
99
|
+
const out = join(root, "export");
|
|
100
|
+
const exported = chant(dir, ["release", "export", "v1", "--env", "prod", "-o", out]);
|
|
101
|
+
expect(exported.code, exported.stderr).toBe(0);
|
|
102
|
+
expect(JSON.parse(readFileSync(join(out, "dist", "api.template.json"), "utf-8"))).toEqual({ env: "prod", label: "v1", marker: "one" });
|
|
103
|
+
expect(readFileSync(join(dir, "marker.txt"), "utf-8")).toBe("dirty");
|
|
104
|
+
expect(git(dir, "rev-parse", "HEAD")).toBe(two);
|
|
105
|
+
expect(git(dir, "worktree", "list").split("\n")).toHaveLength(1);
|
|
106
|
+
|
|
107
|
+
const after = JSON.parse(chant(dir, ["release", "candidates", "--json"]).stdout);
|
|
108
|
+
expect(after.candidates[0].cut).toEqual(["v1"]);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(root, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
240_000,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
@@ -199,10 +199,10 @@ describe("chant release publish with targets", () => {
|
|
|
199
199
|
}
|
|
200
200
|
expect(pub.stderr).toContain("npm notice token [redacted]");
|
|
201
201
|
|
|
202
|
-
// From a workstation the
|
|
202
|
+
// From a workstation the plan offers no provenance, and with no supply-chain group says nothing about it (#4168).
|
|
203
203
|
const asJson = await chant(dir, ["release", "publish", "v1", "--json"], { ...path, ...secrets });
|
|
204
204
|
expect(asJson.code, asJson.stderr).toBe(0);
|
|
205
|
-
expect(asJson.stderr).toContain("this publish runs without OIDC");
|
|
205
|
+
expect(asJson.stderr).not.toContain("this publish runs without OIDC");
|
|
206
206
|
const doc = JSON.parse(asJson.stdout);
|
|
207
207
|
expect(doc).toMatchObject({ dryRun: true, label: "v1", provenance: { oidc: false } });
|
|
208
208
|
expect(doc.targets.map((t: { kind: string }) => t.kind)).toEqual(["npm", "oci"]);
|