@intentius/chant 0.124.28 → 0.124.29
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/build-params.d.ts +34 -1
- package/dist/build-params.d.ts.map +1 -1
- package/dist/build-params.js +31 -1
- package/dist/change-set-graph.d.ts +8 -1
- package/dist/change-set-graph.d.ts.map +1 -1
- package/dist/change-set-graph.js +4 -2
- package/dist/change-set.d.ts +10 -0
- package/dist/change-set.d.ts.map +1 -1
- package/dist/change-set.js +4 -3
- package/dist/cli/build-params-cli.d.ts +7 -1
- package/dist/cli/build-params-cli.d.ts.map +1 -1
- package/dist/cli/build-params-cli.js +17 -8
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +6 -3
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +45 -0
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +15 -4
- package/dist/cli/handlers/fan-out.d.ts +9 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +40 -3
- package/dist/cli/handlers/lifecycle-gc.d.ts +19 -0
- package/dist/cli/handlers/lifecycle-gc.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle-gc.js +143 -0
- package/dist/cli/handlers/pr.d.ts +11 -3
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +134 -26
- package/dist/cli/handlers/promote.d.ts +7 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +210 -33
- package/dist/cli/handlers/release.d.ts +62 -5
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +414 -28
- package/dist/cli/handlers/run.d.ts +11 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +67 -6
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +61 -10
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +24 -2
- package/dist/cli/registry.d.ts +15 -3
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/run-events.d.ts +3 -2
- package/dist/cli/run-events.d.ts.map +1 -1
- package/dist/cli/run-events.js +5 -3
- package/dist/components/auto-release.d.ts +2 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +1 -0
- package/dist/components/capability.d.ts +9 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/driver.d.ts +8 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +35 -0
- package/dist/components/fan-out-inputs.d.ts +69 -0
- package/dist/components/fan-out-inputs.d.ts.map +1 -0
- package/dist/components/fan-out-inputs.js +169 -0
- package/dist/components/fan-out-record.d.ts +13 -0
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/fan-out-record.js +16 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +50 -0
- package/dist/components/fan-out.d.ts +4 -0
- package/dist/components/fan-out.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +25 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +22 -4
- package/dist/components/promote.d.ts +23 -0
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +60 -2
- package/dist/components/release-compile.d.ts +9 -0
- package/dist/components/release-compile.d.ts.map +1 -1
- package/dist/components/release-compile.js +9 -3
- package/dist/components/release-gate.d.ts +11 -1
- package/dist/components/release-gate.d.ts.map +1 -1
- package/dist/components/release-gate.js +10 -4
- package/dist/components/wave-plan.d.ts +5 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +10 -2
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/flag-params.d.ts +54 -0
- package/dist/flag-params.d.ts.map +1 -0
- package/dist/flag-params.js +130 -0
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts +120 -0
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -0
- package/dist/lifecycle/__fixtures__/publish-fakes.js +304 -0
- package/dist/lifecycle/change-set-store.d.ts +2 -1
- package/dist/lifecycle/change-set-store.d.ts.map +1 -1
- package/dist/lifecycle/change-set-store.js +2 -1
- package/dist/lifecycle/delivery-metrics.d.ts +139 -0
- package/dist/lifecycle/delivery-metrics.d.ts.map +1 -0
- package/dist/lifecycle/delivery-metrics.js +145 -0
- package/dist/lifecycle/deploy-outcome.d.ts +81 -0
- package/dist/lifecycle/deploy-outcome.d.ts.map +1 -0
- package/dist/lifecycle/deploy-outcome.js +101 -0
- package/dist/lifecycle/gate-change-set.d.ts +10 -1
- package/dist/lifecycle/gate-change-set.d.ts.map +1 -1
- package/dist/lifecycle/gate-change-set.js +35 -3
- package/dist/lifecycle/gate-ledger.d.ts +2 -3
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +30 -1
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/git.js +31 -5
- package/dist/lifecycle/index.d.ts +4 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/index.js +4 -0
- package/dist/lifecycle/mixed-environment.d.ts +95 -0
- package/dist/lifecycle/mixed-environment.d.ts.map +1 -0
- package/dist/lifecycle/mixed-environment.js +154 -0
- package/dist/lifecycle/publish-attachments.d.ts +79 -0
- package/dist/lifecycle/publish-attachments.d.ts.map +1 -0
- package/dist/lifecycle/publish-attachments.js +179 -0
- package/dist/lifecycle/publish-credentials.d.ts +37 -0
- package/dist/lifecycle/publish-credentials.d.ts.map +1 -0
- package/dist/lifecycle/publish-credentials.js +69 -0
- package/dist/lifecycle/publish-declaration.d.ts +97 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -0
- package/dist/lifecycle/publish-declaration.js +83 -0
- package/dist/lifecycle/publish-forge.d.ts +53 -0
- package/dist/lifecycle/publish-forge.d.ts.map +1 -0
- package/dist/lifecycle/publish-forge.js +298 -0
- package/dist/lifecycle/publish-npm.d.ts +61 -0
- package/dist/lifecycle/publish-npm.d.ts.map +1 -0
- package/dist/lifecycle/publish-npm.js +242 -0
- package/dist/lifecycle/publish-oci.d.ts +64 -0
- package/dist/lifecycle/publish-oci.d.ts.map +1 -0
- package/dist/lifecycle/publish-oci.js +383 -0
- package/dist/lifecycle/publish-record.d.ts +66 -0
- package/dist/lifecycle/publish-record.d.ts.map +1 -0
- package/dist/lifecycle/publish-record.js +84 -0
- package/dist/lifecycle/publish-targets.d.ts +45 -0
- package/dist/lifecycle/publish-targets.d.ts.map +1 -0
- package/dist/lifecycle/publish-targets.js +85 -0
- package/dist/lifecycle/release-cut.d.ts +48 -1
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +116 -23
- package/dist/lifecycle/release-join.d.ts +95 -0
- package/dist/lifecycle/release-join.d.ts.map +1 -0
- package/dist/lifecycle/release-join.js +139 -0
- package/dist/lifecycle/release-publish.d.ts +312 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -0
- package/dist/lifecycle/release-publish.js +335 -0
- package/dist/lifecycle/release-revocation.d.ts +80 -0
- package/dist/lifecycle/release-revocation.d.ts.map +1 -0
- package/dist/lifecycle/release-revocation.js +123 -0
- package/dist/lifecycle/release-store.d.ts +12 -2
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +4 -2
- package/dist/lifecycle/release-verify.d.ts +61 -0
- package/dist/lifecycle/release-verify.d.ts.map +1 -0
- package/dist/lifecycle/release-verify.js +92 -0
- package/dist/lifecycle/release-workstation.d.ts +91 -0
- package/dist/lifecycle/release-workstation.d.ts.map +1 -0
- package/dist/lifecycle/release-workstation.js +180 -0
- package/dist/lifecycle/retention-policy.d.ts +45 -0
- package/dist/lifecycle/retention-policy.d.ts.map +1 -0
- package/dist/lifecycle/retention-policy.js +43 -0
- package/dist/lifecycle/retention.d.ts +185 -0
- package/dist/lifecycle/retention.d.ts.map +1 -0
- package/dist/lifecycle/retention.js +400 -0
- package/dist/plan-summary.d.ts.map +1 -1
- package/dist/plan-summary.js +8 -2
- package/dist/pr-loop.d.ts +7 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +23 -4
- package/dist/pr-report-url.d.ts +82 -0
- package/dist/pr-report-url.d.ts.map +1 -0
- package/dist/pr-report-url.js +174 -0
- package/dist/provenance.d.ts +2 -2
- package/dist/provenance.d.ts.map +1 -1
- package/dist/run-event-store.d.ts +29 -2
- package/dist/run-event-store.d.ts.map +1 -1
- package/dist/run-event-store.js +50 -3
- package/dist/run-event-stream.d.ts +2 -1
- package/dist/run-event-stream.d.ts.map +1 -1
- package/dist/run-event-stream.js +5 -1
- package/dist/run-event.d.ts +19 -1
- package/dist/run-event.d.ts.map +1 -1
- package/dist/run-event.js +2 -0
- package/dist/wave-runtime.mjs +260 -229
- package/dist/workspace/__fixtures__/flag-workspace.d.ts +31 -0
- package/dist/workspace/__fixtures__/flag-workspace.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/flag-workspace.js +114 -0
- package/dist/workspace/change-set.schema.json +5 -1
- package/dist/workspace/check.schema.json +4 -2
- package/dist/workspace/checks/records.d.ts +9 -1
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks/records.js +35 -1
- package/dist/workspace/checks.d.ts +1 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +2 -1
- package/dist/workspace/ci-green.d.ts +8 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +10 -3
- package/dist/workspace/declaration.d.ts +16 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +26 -0
- package/dist/workspace/declaration.schema.json +64 -0
- package/dist/workspace/declared-kinds.d.ts +16 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/declared-kinds.js +25 -0
- package/dist/workspace/delivery-metrics.d.ts +91 -0
- package/dist/workspace/delivery-metrics.d.ts.map +1 -0
- package/dist/workspace/delivery-metrics.js +217 -0
- package/dist/workspace/delivery-metrics.schema.json +519 -0
- package/dist/workspace/flags-read.d.ts +37 -0
- package/dist/workspace/flags-read.d.ts.map +1 -0
- package/dist/workspace/flags-read.js +103 -0
- package/dist/workspace/flags.d.ts +100 -0
- package/dist/workspace/flags.d.ts.map +1 -0
- package/dist/workspace/flags.js +98 -0
- package/dist/workspace/graph-cli.js +1 -1
- package/dist/workspace/intent-cli.d.ts +11 -2
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.js +54 -5
- package/dist/workspace/intent-trace.d.ts +219 -0
- package/dist/workspace/intent-trace.d.ts.map +1 -0
- package/dist/workspace/intent-trace.js +314 -0
- package/dist/workspace/intent.d.ts +30 -6
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/intent.js +141 -21
- package/dist/workspace/intent.schema.json +702 -6
- package/dist/workspace/overlap.d.ts +146 -0
- package/dist/workspace/overlap.d.ts.map +1 -0
- package/dist/workspace/overlap.js +276 -0
- package/dist/workspace/reason-codes.d.ts +2 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +3 -0
- package/dist/workspace/records.d.ts +15 -2
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +32 -0
- package/dist/workspace/records.schema.json +21 -2
- package/dist/workspace/reference-kinds/flag/flag.kind.mjs +32 -0
- package/dist/workspace/reference-kinds/flag/flag.schema.json +53 -0
- package/dist/workspace/releases.d.ts +9 -0
- package/dist/workspace/releases.d.ts.map +1 -1
- package/dist/workspace/releases.js +9 -0
- package/dist/workspace/releases.schema.json +123 -1
- package/dist/workspace/run-event.schema.json +10 -3
- package/dist/workspace/status-gates.d.ts +8 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +78 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +170 -3
- package/dist/workspace/status.schema.json +198 -1
- package/dist/workspace/wip.d.ts.map +1 -1
- package/dist/workspace/wip.js +11 -3
- package/package.json +1 -1
- package/src/build-params.test.ts +46 -0
- package/src/build-params.ts +55 -1
- package/src/change-set-graph.ts +10 -2
- package/src/change-set.ts +14 -3
- package/src/cli/build-params-cli.ts +22 -9
- package/src/cli/commands/build.ts +6 -3
- package/src/cli/handlers/approve-release.test.ts +5 -1
- package/src/cli/handlers/approve-revoked.test.ts +155 -0
- package/src/cli/handlers/approve.ts +47 -0
- package/src/cli/handlers/build.test.ts +15 -0
- package/src/cli/handlers/build.ts +17 -4
- package/src/cli/handlers/fan-out.test.ts +180 -1
- package/src/cli/handlers/fan-out.ts +38 -3
- package/src/cli/handlers/intent-trace.e2e.test.ts +201 -0
- package/src/cli/handlers/lifecycle-gc.test.ts +126 -0
- package/src/cli/handlers/lifecycle-gc.ts +146 -0
- package/src/cli/handlers/pr.test.ts +148 -1
- package/src/cli/handlers/pr.ts +139 -28
- package/src/cli/handlers/promote.test.ts +280 -2
- package/src/cli/handlers/promote.ts +210 -34
- package/src/cli/handlers/release-implicit.e2e.test.ts +12 -4
- package/src/cli/handlers/release-join.e2e.test.ts +185 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +295 -0
- package/src/cli/handlers/release-verify.e2e.test.ts +117 -0
- package/src/cli/handlers/release-workstation-ci.e2e.test.ts +135 -0
- package/src/cli/handlers/release.e2e.test.ts +146 -32
- package/src/cli/handlers/release.ts +415 -28
- package/src/cli/handlers/run-auto-release.test.ts +9 -0
- package/src/cli/handlers/run-release.test.ts +55 -0
- package/src/cli/handlers/run.ts +69 -5
- package/src/cli/main.test.ts +1 -0
- package/src/cli/main.ts +61 -10
- package/src/cli/parse-args.ts +19 -2
- package/src/cli/registry.ts +15 -3
- package/src/cli/run-events.ts +5 -2
- package/src/components/auto-release.test.ts +9 -0
- package/src/components/auto-release.ts +3 -0
- package/src/components/capability.ts +9 -0
- package/src/components/driver.test.ts +24 -0
- package/src/components/driver.ts +36 -0
- package/src/components/fan-out-inputs.test.ts +85 -0
- package/src/components/fan-out-inputs.ts +192 -0
- package/src/components/fan-out-record.test.ts +10 -0
- package/src/components/fan-out-record.ts +19 -0
- package/src/components/fan-out-run.ts +48 -0
- package/src/components/fan-out.ts +4 -0
- package/src/components/pr-pipeline.ts +35 -4
- package/src/components/promote.ts +68 -2
- package/src/components/release-compile.ts +17 -3
- package/src/components/release-gate.ts +18 -4
- package/src/components/wave-plan.ts +15 -2
- package/src/config.ts +8 -0
- package/src/flag-params.test.ts +144 -0
- package/src/flag-params.ts +134 -0
- package/src/lifecycle/__fixtures__/publish-fakes.ts +351 -0
- package/src/lifecycle/change-set-store.ts +2 -1
- package/src/lifecycle/delivery-metrics.ts +263 -0
- package/src/lifecycle/deploy-outcome.ts +147 -0
- package/src/lifecycle/gate-change-set.ts +37 -6
- package/src/lifecycle/gate-ledger.ts +2 -2
- package/src/lifecycle/git.ts +61 -8
- package/src/lifecycle/index.ts +4 -0
- package/src/lifecycle/mixed-environment.test.ts +97 -0
- package/src/lifecycle/mixed-environment.ts +204 -0
- package/src/lifecycle/publish-attachments.ts +196 -0
- package/src/lifecycle/publish-credentials.ts +84 -0
- package/src/lifecycle/publish-declaration.ts +95 -0
- package/src/lifecycle/publish-forge.ts +325 -0
- package/src/lifecycle/publish-npm.ts +259 -0
- package/src/lifecycle/publish-oci.ts +396 -0
- package/src/lifecycle/publish-record.ts +113 -0
- package/src/lifecycle/publish-resume.test.ts +168 -0
- package/src/lifecycle/publish-targets.test.ts +494 -0
- package/src/lifecycle/publish-targets.ts +100 -0
- package/src/lifecycle/release-cut.test.ts +74 -2
- package/src/lifecycle/release-cut.ts +158 -22
- package/src/lifecycle/release-flag.test.ts +112 -0
- package/src/lifecycle/release-join.test.ts +68 -0
- package/src/lifecycle/release-join.ts +182 -0
- package/src/lifecycle/release-publish.test.ts +225 -0
- package/src/lifecycle/release-publish.ts +568 -0
- package/src/lifecycle/release-revocation.ts +147 -0
- package/src/lifecycle/release-store.ts +15 -4
- package/src/lifecycle/release-verify.test.ts +110 -0
- package/src/lifecycle/release-verify.ts +120 -0
- package/src/lifecycle/release-workstation.test.ts +204 -0
- package/src/lifecycle/release-workstation.ts +216 -0
- package/src/lifecycle/retention-growth.e2e.test.ts +279 -0
- package/src/lifecycle/retention-policy.ts +63 -0
- package/src/lifecycle/retention.test.ts +363 -0
- package/src/lifecycle/retention.ts +508 -0
- package/src/plan-summary.test.ts +20 -0
- package/src/plan-summary.ts +8 -1
- package/src/pr-loop.ts +25 -4
- package/src/pr-report-url.test.ts +131 -0
- package/src/pr-report-url.ts +209 -0
- package/src/provenance.ts +2 -2
- package/src/run-event-store.ts +63 -3
- package/src/run-event-stream.ts +4 -1
- package/src/run-event.ts +21 -1
- package/src/workspace/__fixtures__/flag-workspace.ts +132 -0
- package/src/workspace/__fixtures__/older-records/change-set-984534ea9.json +100 -0
- package/src/workspace/__fixtures__/older-records/older-records.json +326 -0
- package/src/workspace/change-set.schema.json +5 -1
- package/src/workspace/check.schema.json +4 -2
- package/src/workspace/checks/records.ts +35 -1
- package/src/workspace/checks.ts +2 -1
- package/src/workspace/ci-green.ts +14 -3
- package/src/workspace/declaration.schema.json +64 -0
- package/src/workspace/declaration.test.ts +28 -0
- package/src/workspace/declaration.ts +38 -0
- package/src/workspace/declared-kinds.ts +40 -1
- package/src/workspace/delivery-metrics.schema.json +519 -0
- package/src/workspace/delivery-metrics.test.ts +236 -0
- package/src/workspace/delivery-metrics.ts +286 -0
- package/src/workspace/flags-read.ts +121 -0
- package/src/workspace/flags.test.ts +141 -0
- package/src/workspace/flags.ts +157 -0
- package/src/workspace/graph-cli.ts +1 -1
- package/src/workspace/intent-cli.ts +46 -5
- package/src/workspace/intent-trace.test.ts +173 -0
- package/src/workspace/intent-trace.ts +484 -0
- package/src/workspace/intent.schema.json +702 -6
- package/src/workspace/intent.ts +148 -25
- package/src/workspace/older-records.test.ts +412 -0
- package/src/workspace/overlap.test.ts +165 -0
- package/src/workspace/overlap.ts +340 -0
- package/src/workspace/read-contract.e2e.test.ts +2 -1
- package/src/workspace/reason-codes.test.ts +5 -0
- package/src/workspace/reason-codes.ts +3 -0
- package/src/workspace/record-assets.test.ts +2 -1
- package/src/workspace/records-contract.e2e.test.ts +2 -1
- package/src/workspace/records.schema.json +21 -2
- package/src/workspace/records.ts +36 -2
- package/src/workspace/reference-kinds/flag/flag.kind.mjs +32 -0
- package/src/workspace/reference-kinds/flag/flag.schema.json +53 -0
- package/src/workspace/releases.schema.json +123 -1
- package/src/workspace/releases.ts +11 -0
- package/src/workspace/run-event.schema.json +10 -3
- package/src/workspace/status-contract.test.ts +29 -0
- package/src/workspace/status-gates.ts +8 -0
- package/src/workspace/status-mixed.test.ts +152 -0
- package/src/workspace/status-revoked.test.ts +153 -0
- package/src/workspace/status.schema.json +198 -1
- package/src/workspace/status.ts +254 -3
- package/src/workspace/wip.ts +11 -2
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which releases are revoked (#4033, ws-105 question 2).
|
|
3
|
+
*
|
|
4
|
+
* A release traces to one commit (ws-104), and a commit is revoked when it
|
|
5
|
+
* carries a `ci/revoked/<sha>` tag that counts (ws-103). So a release is
|
|
6
|
+
* revoked exactly when its commit is, and this module derives that at read
|
|
7
|
+
* time from the tags. Nothing is written when a commit is revoked: the tag is
|
|
8
|
+
* the record, `chant ci tick` makes it once, and every reader below judges it
|
|
9
|
+
* the same way.
|
|
10
|
+
*
|
|
11
|
+
* The tags are judged as `release cut` judges them (#4031): with
|
|
12
|
+
* `ci.green.signedSince` declared, a revoked tag without a valid runner
|
|
13
|
+
* statement is ignored and revokes nothing. The declaration and the runner
|
|
14
|
+
* keys are read at base, the green branch's tip on `origin` (or the local
|
|
15
|
+
* branch, or `HEAD`). Only local tags are read; nothing is fetched.
|
|
16
|
+
*
|
|
17
|
+
* The readers:
|
|
18
|
+
*
|
|
19
|
+
* - `chant approve` refuses a release gate whose release is revoked
|
|
20
|
+
* (`release-revoked`), and `chant components promote --release` refuses to
|
|
21
|
+
* deploy one, so a pending gate for it can never pass.
|
|
22
|
+
* - `chant workspace status --json` reads such a gate as `superseded` and
|
|
23
|
+
* flags a component whose environment runs a revoked release, with the
|
|
24
|
+
* rollback line.
|
|
25
|
+
* - `chant release list` marks a revoked release.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import type { CiGreen } from "../workspace/declaration";
|
|
30
|
+
import type { CiTags, RevokedTagView } from "../workspace/ci-green";
|
|
31
|
+
|
|
32
|
+
/** A revoked release: the commit it was cut from and the revoked tag on it, as `ci list` shows the tag. */
|
|
33
|
+
export interface ReleaseRevocation extends RevokedTagView {
|
|
34
|
+
/** The release's commit, which the tag names. */
|
|
35
|
+
commit: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The stable code `approve` and `promote --release` refuse a revoked release with. */
|
|
39
|
+
export const RELEASE_REVOKED_CODE = "release-revoked";
|
|
40
|
+
|
|
41
|
+
function tryGit(top: string, args: string[]): string | null {
|
|
42
|
+
try {
|
|
43
|
+
return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The ci tags at `top`, judged against `green` at base: the green branch's
|
|
51
|
+
* tip on `origin`, else the local branch, else `HEAD`. With no `ci.green`
|
|
52
|
+
* every tag counts. Throws `WorkspaceReadError` (`declaration-invalid`) when
|
|
53
|
+
* `ci.green.signedSince` names no commit here.
|
|
54
|
+
*/
|
|
55
|
+
export async function judgedCiTags(top: string, green: CiGreen | null): Promise<CiTags> {
|
|
56
|
+
const { readCiTags } = await import("../workspace/ci-green");
|
|
57
|
+
const base = green
|
|
58
|
+
? (tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${green.branch}^{commit}`]) ??
|
|
59
|
+
tryGit(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${green.branch}^{commit}`]) ??
|
|
60
|
+
tryGit(top, ["rev-parse", "HEAD"]))
|
|
61
|
+
: null;
|
|
62
|
+
return readCiTags(top, green, base);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every revoked commit in the repository holding `cwd`, keyed by its full
|
|
67
|
+
* sha, with the declaration's `ci.green` read at base as `release cut` reads
|
|
68
|
+
* it. Empty outside a git repository. Throws `WorkspaceReadError`
|
|
69
|
+
* (`declaration-invalid`) when the tags can't be judged.
|
|
70
|
+
*/
|
|
71
|
+
export async function readRevokedCommits(cwd: string): Promise<Map<string, ReleaseRevocation>> {
|
|
72
|
+
const top = tryGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
73
|
+
if (!top) return new Map();
|
|
74
|
+
const { scopeSource } = await import("../workspace/write-scope");
|
|
75
|
+
return revokedCommitsAt(top, scopeSource(cwd).declaration?.ci?.green ?? null);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** {@link readRevokedCommits} for a caller that has the git top and the declaration's `ci.green` at base already. */
|
|
79
|
+
export async function revokedCommitsAt(top: string, green: CiGreen | null): Promise<Map<string, ReleaseRevocation>> {
|
|
80
|
+
const { revokedTagView } = await import("../workspace/ci-green");
|
|
81
|
+
const out = new Map<string, ReleaseRevocation>();
|
|
82
|
+
for (const [sha, counted] of (await judgedCiTags(top, green)).revoked) out.set(sha, { commit: sha, ...revokedTagView(sha, counted) });
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a reader prints for a release it found revoked: the revocation, the release and its label. */
|
|
87
|
+
export interface RevokedRelease extends ReleaseRevocation {
|
|
88
|
+
releaseDigest: string;
|
|
89
|
+
/** The release's label, or null when its cuts gave it none. */
|
|
90
|
+
label: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A memoised lookup from a release digest to its revocation, for the member
|
|
95
|
+
* whose ledger is read from `memberDir`: null when the release is not
|
|
96
|
+
* revoked, or its file can't be read. `revoked` is called on the first
|
|
97
|
+
* lookup only, so a reader that meets no release reads no tags, and no
|
|
98
|
+
* release file is read while no commit is revoked.
|
|
99
|
+
*/
|
|
100
|
+
export function revokedReleaseLookup(
|
|
101
|
+
memberDir: string,
|
|
102
|
+
revoked: () => Promise<ReadonlyMap<string, ReleaseRevocation>>,
|
|
103
|
+
): (releaseDigest: string) => Promise<RevokedRelease | null> {
|
|
104
|
+
const seen = new Map<string, Promise<RevokedRelease | null>>();
|
|
105
|
+
let labels: Promise<Map<string, string>> | undefined;
|
|
106
|
+
return (releaseDigest) => {
|
|
107
|
+
let found = seen.get(releaseDigest);
|
|
108
|
+
if (!found) {
|
|
109
|
+
found = (async () => {
|
|
110
|
+
const commits = await revoked();
|
|
111
|
+
if (commits.size === 0) return null;
|
|
112
|
+
const { readRelease, readCuts, labelsByDigest } = await import("./release-store");
|
|
113
|
+
const loc = { cwd: memberDir };
|
|
114
|
+
const release = await readRelease(releaseDigest, loc).catch(() => null);
|
|
115
|
+
const r = release ? revocationOf(release, commits) : null;
|
|
116
|
+
if (!r) return null;
|
|
117
|
+
labels ??= readCuts(loc).then((c) => labelsByDigest(c.cuts), () => new Map<string, string>());
|
|
118
|
+
return { ...r, releaseDigest, label: (await labels).get(releaseDigest) ?? null };
|
|
119
|
+
})();
|
|
120
|
+
seen.set(releaseDigest, found);
|
|
121
|
+
}
|
|
122
|
+
return found;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Whether `release` is revoked: its commit's revoked tag, or null. */
|
|
127
|
+
export function revocationOf(release: { commit: string }, revoked: ReadonlyMap<string, ReleaseRevocation>): ReleaseRevocation | null {
|
|
128
|
+
return revoked.get(release.commit) ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What failed, in words: `phase e2e failed in deploy-smoke (Op run r-12 in staging)`. */
|
|
132
|
+
export function describeRevocation(r: ReleaseRevocation): string {
|
|
133
|
+
const run = r.run
|
|
134
|
+
? ` in ${r.run.name}${r.run.opRun ? ` (Op run ${r.run.opRun.id} in ${r.run.opRun.env})` : r.run.id !== null ? ` (run ${r.run.id})` : ""}`
|
|
135
|
+
: "";
|
|
136
|
+
return r.phase !== null ? `phase ${r.phase} failed${run}` : `a required phase failed${run}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The refusal `approve` and `promote --release` print for a revoked release, and the line that fixes it. */
|
|
140
|
+
export function revokedRefusal(release: { releaseDigest: string; label: string | null }, r: ReleaseRevocation, fix: string): { message: string; hint: string } {
|
|
141
|
+
return {
|
|
142
|
+
message:
|
|
143
|
+
`release ${release.label ?? release.releaseDigest} was cut from commit ${r.commit.slice(0, 12)}, which ${r.tag} revokes ` +
|
|
144
|
+
`(${describeRevocation(r)}), so it is not deployed and no approval of it counts [${RELEASE_REVOKED_CODE}]`,
|
|
145
|
+
hint: fix,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -111,7 +111,16 @@ export interface ReleaseCut {
|
|
|
111
111
|
label: string | null;
|
|
112
112
|
actor: string;
|
|
113
113
|
timestamp: string;
|
|
114
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* The run the cut ran in (#4027): the CI run's id in CI, or an id minted on
|
|
116
|
+
* the machine that cut (`local-<epoch ms>`). Null on every cut written
|
|
117
|
+
* before 0.124.29.
|
|
118
|
+
*/
|
|
119
|
+
runId: string | null;
|
|
120
|
+
/**
|
|
121
|
+
* Where that run ran: the CI run's origin, or `{ forge: "local" }` for a
|
|
122
|
+
* minted id (#4027). A cut written before 0.124.29 holds null outside CI.
|
|
123
|
+
*/
|
|
115
124
|
runOrigin: RunOrigin | null;
|
|
116
125
|
/**
|
|
117
126
|
* The cut's signature (#4031): an armored ssh signature by the actor's key
|
|
@@ -310,12 +319,13 @@ export function parseCuts(text: string | null): { cuts: ReleaseCut[]; malformed:
|
|
|
310
319
|
typeof c.actor === "string" &&
|
|
311
320
|
typeof c.timestamp === "string" &&
|
|
312
321
|
(c.runOrigin === null || (typeof c.runOrigin === "object" && typeof c.runOrigin?.forge === "string")) &&
|
|
313
|
-
(c.signature === null || c.signature === undefined || typeof c.signature === "string")
|
|
322
|
+
(c.signature === null || c.signature === undefined || typeof c.signature === "string") &&
|
|
323
|
+
(c.runId === null || c.runId === undefined || typeof c.runId === "string");
|
|
314
324
|
if (!ok) {
|
|
315
325
|
malformed++;
|
|
316
326
|
continue;
|
|
317
327
|
}
|
|
318
|
-
cuts.push({ ...(c as ReleaseCut), signature: c.signature ?? null });
|
|
328
|
+
cuts.push({ ...(c as ReleaseCut), runId: c.runId ?? null, signature: c.signature ?? null });
|
|
319
329
|
} catch {
|
|
320
330
|
malformed++;
|
|
321
331
|
}
|
|
@@ -351,7 +361,7 @@ const CUT_RETRY_ATTEMPTS = 5;
|
|
|
351
361
|
* label another digest has, throws {@link ReleaseLabelError}. Does not push.
|
|
352
362
|
*/
|
|
353
363
|
export async function appendCut(
|
|
354
|
-
input: Omit<ReleaseCut, "version" | "signature"> & { signature?: string | null },
|
|
364
|
+
input: Omit<ReleaseCut, "version" | "signature" | "runId"> & { signature?: string | null; runId?: string | null },
|
|
355
365
|
opts?: { cwd?: string },
|
|
356
366
|
): Promise<CutOutcome> {
|
|
357
367
|
if (input.label !== null) {
|
|
@@ -387,6 +397,7 @@ export async function appendCut(
|
|
|
387
397
|
label: input.label,
|
|
388
398
|
actor: input.actor,
|
|
389
399
|
timestamp: input.timestamp,
|
|
400
|
+
runId: input.runId ?? null,
|
|
390
401
|
runOrigin: input.runOrigin,
|
|
391
402
|
signature: input.signature ?? null,
|
|
392
403
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4049: `chant release verify` checks a release's signature offline against
|
|
3
|
+
* `.chant/allowed_signers` at the release's commit. A release signed by a
|
|
4
|
+
* listed key verifies; an unsigned one and one signed by a key the file does
|
|
5
|
+
* not list do not; and a key listed only after the release's commit does not
|
|
6
|
+
* make it verify. Throwaway keys in temp repos only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
10
|
+
import { rmSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { cutRelease } from "./release-cut";
|
|
13
|
+
import { verificationCode, verifyRelease, type ReleaseVerification } from "./release-verify";
|
|
14
|
+
import type { ReleaseArtifact } from "./release-store";
|
|
15
|
+
import type { CompiledTarget, TargetCompiler } from "../components/release-compile";
|
|
16
|
+
import { hasSshKeygen, TestRepo, type Key } from "../workspace/trust/test-repo";
|
|
17
|
+
|
|
18
|
+
const repos: TestRepo[] = [];
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
for (const r of repos) rmSync(dirname(r.dir), { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const compiler: TargetCompiler = {
|
|
24
|
+
async compile(env): Promise<CompiledTarget> {
|
|
25
|
+
return { env, params: [], components: ["api"], buildSteps: {}, buildInputs: {}, componentFiles: { api: "api.component.ts" }, templates: [{ component: "api", path: "dist/api.json", content: `{"env":"${env}"}\n` }] };
|
|
26
|
+
},
|
|
27
|
+
async buildArtifacts(): Promise<ReleaseArtifact[]> {
|
|
28
|
+
return [];
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** A plain project whose signers file lists alice, with one green commit. */
|
|
33
|
+
function setup(): { r: TestRepo; alice: Key; bob: Key } {
|
|
34
|
+
const r = new TestRepo("release-verify");
|
|
35
|
+
repos.push(r);
|
|
36
|
+
const alice = r.key("alice");
|
|
37
|
+
const bob = r.key("bob");
|
|
38
|
+
r.write(".chant/allowed_signers", `alice ${alice.pub}\n`);
|
|
39
|
+
r.write("chant.config.ts", `export default { environments: ["dev"] };\n`);
|
|
40
|
+
r.write("api.component.ts", "export const api = {};\n");
|
|
41
|
+
green(r, "init");
|
|
42
|
+
return { r, alice, bob };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function green(r: TestRepo, message: string): string {
|
|
46
|
+
const head = r.commit(message);
|
|
47
|
+
r.git(["tag", `ci/green/${head}`]);
|
|
48
|
+
return head;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cut = (r: TestRepo, label: string, actor: string, sign?: Key) =>
|
|
52
|
+
cutRelease({ cwd: r.dir, compiler, label, actor, env: {}, now: () => new Date("2026-10-17T10:00:00.000Z"), ...(sign ? { sign: sign.file } : {}) });
|
|
53
|
+
const checked = async (r: TestRepo, ref: string): Promise<ReleaseVerification> => {
|
|
54
|
+
const v = await verifyRelease({ cwd: r.dir, ref });
|
|
55
|
+
if ("error" in v) throw new Error(v.error.message);
|
|
56
|
+
return v;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("chant release verify", () => {
|
|
60
|
+
test.skipIf(!hasSshKeygen)("passes for a release signed by a key the signers file lists at its commit", async () => {
|
|
61
|
+
const { r, alice } = setup();
|
|
62
|
+
const { release } = await cut(r, "v1", "alice", alice);
|
|
63
|
+
const v = await checked(r, "v1");
|
|
64
|
+
expect(v).toMatchObject({ releaseDigest: release.releaseDigest, label: "v1", commit: release.commit, digestMatches: true, status: "verified", verifiedBy: { actor: "alice", key: expect.stringMatching(/^SHA256:/) } });
|
|
65
|
+
expect(v.signers).toEqual({ path: ".chant/allowed_signers", at: release.commit, active: true });
|
|
66
|
+
expect(v.reason).toBe(`signed by alice, checked against .chant/allowed_signers at ${release.commit.slice(0, 12)}`);
|
|
67
|
+
expect(verificationCode(v)).toBeNull();
|
|
68
|
+
// By digest too.
|
|
69
|
+
expect((await checked(r, release.releaseDigest)).status).toBe("verified");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("fails for an unsigned release", async () => {
|
|
73
|
+
const { r } = setup();
|
|
74
|
+
await cut(r, "v1", "alice");
|
|
75
|
+
const v = await checked(r, "v1");
|
|
76
|
+
expect(v.status).toBe("unsigned");
|
|
77
|
+
expect(v.verifiedBy).toBeNull();
|
|
78
|
+
expect(v.cuts).toEqual([expect.objectContaining({ actor: "alice", signatureCheck: { status: "unsigned", key: null, reason: "the cut carries no signature" } })]);
|
|
79
|
+
expect(verificationCode(v)).toBe("release-unsigned");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test.skipIf(!hasSshKeygen)("fails for a release signed by a key the signers file does not list, also once a later commit lists it", async () => {
|
|
83
|
+
const { r, bob } = setup();
|
|
84
|
+
await cut(r, "v1", "bob", bob);
|
|
85
|
+
const v = await checked(r, "v1");
|
|
86
|
+
expect(v.status).toBe("unverified");
|
|
87
|
+
expect(v.reason).toBe(`bob has no key in .chant/allowed_signers at ${v.commit.slice(0, 12)}, so the cut's signature can't count`);
|
|
88
|
+
expect(verificationCode(v)).toBe("release-signature-unverified");
|
|
89
|
+
|
|
90
|
+
// bob's key is listed after the release's commit: the release still reads the file at its own commit.
|
|
91
|
+
r.write(".chant/allowed_signers", `alice ${r.key("alice2").pub}\nbob ${bob.pub}\n`);
|
|
92
|
+
green(r, "list bob");
|
|
93
|
+
expect((await checked(r, "v1")).status).toBe("unverified");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test.skipIf(!hasSshKeygen)("fails for a cut signed with one key in another signer's name", async () => {
|
|
97
|
+
const { r, bob } = setup();
|
|
98
|
+
// bob's key, in alice's name.
|
|
99
|
+
await cut(r, "v1", "alice", bob);
|
|
100
|
+
const v = await checked(r, "v1");
|
|
101
|
+
expect(v.status).toBe("unverified");
|
|
102
|
+
expect(v.reason).toMatch(/the cut's signature does not verify for alice/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("names a release it can't find", async () => {
|
|
106
|
+
const { r } = setup();
|
|
107
|
+
const v = await verifyRelease({ cwd: r.dir, ref: "v9" });
|
|
108
|
+
expect("error" in v && v.error.code).toBe("release-not-found");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant release verify <label|digest>` (#4049): whether a release was
|
|
3
|
+
* signed by a key `.chant/allowed_signers` lists, checked offline.
|
|
4
|
+
*
|
|
5
|
+
* It reads the release and its cuts from the local `chant/lifecycle` branch,
|
|
6
|
+
* and the signers file, its signed history (#2553) and the declaration at the
|
|
7
|
+
* release's own commit, as `chant workspace evidence verify` reads the policy
|
|
8
|
+
* at the revision it is given. It fetches nothing and needs no key. A release
|
|
9
|
+
* verifies when its file's digest is the digest of what it holds and one of
|
|
10
|
+
* its cuts carries a signature in the `chant-release` namespace that verifies
|
|
11
|
+
* for the cut's actor, over the release digest, the label, the actor and the
|
|
12
|
+
* time (`releaseSealPayload`), and, when the declaration at that commit has an
|
|
13
|
+
* `identity.gates.release` entry naming a class, the actor is in it.
|
|
14
|
+
*
|
|
15
|
+
* Reading the signers at the release's commit means a key added later does
|
|
16
|
+
* not make an old release verify, and one removed later does not unmake it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ReleaseSignatureCheck } from "../workspace/identity-gate-rule";
|
|
20
|
+
import type { Release, ReleaseCut } from "./release-store";
|
|
21
|
+
|
|
22
|
+
/** What `release verify --json` prints. */
|
|
23
|
+
export interface ReleaseVerification {
|
|
24
|
+
releaseDigest: string;
|
|
25
|
+
label: string | null;
|
|
26
|
+
member: string | null;
|
|
27
|
+
commit: string;
|
|
28
|
+
/** Where the signers were read: the signers file's path and the commit. */
|
|
29
|
+
signers: { path: string; at: string; active: boolean };
|
|
30
|
+
/** True when the release file's digest is the digest of what it holds. */
|
|
31
|
+
digestMatches: boolean;
|
|
32
|
+
/** `verified` when a cut's signature verifies; `unsigned` when no cut carries one; `unverified` otherwise. */
|
|
33
|
+
status: "verified" | "unsigned" | "unverified";
|
|
34
|
+
/** The cut whose signature verified, or null. */
|
|
35
|
+
verifiedBy: { actor: string; key: string | null } | null;
|
|
36
|
+
reason: string;
|
|
37
|
+
cuts: Array<Pick<ReleaseCut, "label" | "actor" | "timestamp" | "runId"> & { signatureCheck: ReleaseSignatureCheck }>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Why a release could not be checked at all. */
|
|
41
|
+
export interface ReleaseVerifyError {
|
|
42
|
+
code: "release-not-found" | "release-commit-missing" | "trust-policy-unreadable" | "not-a-git-repository";
|
|
43
|
+
message: string;
|
|
44
|
+
fix?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The exit-1 codes of a release that was checked and did not verify. */
|
|
48
|
+
export function verificationCode(v: ReleaseVerification): "release-digest-mismatch" | "release-unsigned" | "release-signature-unverified" | null {
|
|
49
|
+
if (!v.digestMatches) return "release-digest-mismatch";
|
|
50
|
+
if (v.status === "verified") return null;
|
|
51
|
+
return v.status === "unsigned" ? "release-unsigned" : "release-signature-unverified";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check the release `ref` names in the member at `cwd`. Reads only the local repository. */
|
|
55
|
+
export async function verifyRelease(req: { cwd: string; ref: string }): Promise<ReleaseVerification | { error: ReleaseVerifyError }> {
|
|
56
|
+
const { resolveRelease, readCuts, verifyReleaseDigest } = await import("./release-store");
|
|
57
|
+
const { gitRoot } = await import("../workspace/record-source");
|
|
58
|
+
const { resolveBase } = await import("../workspace/trust/provenance");
|
|
59
|
+
const { scopeSource } = await import("../workspace/write-scope");
|
|
60
|
+
const { releaseRuleFrom } = await import("../workspace/identity");
|
|
61
|
+
const { checkReleaseSignature } = await import("../workspace/identity-gate-rule");
|
|
62
|
+
const { cwd, ref } = req;
|
|
63
|
+
|
|
64
|
+
const top = gitRoot(cwd);
|
|
65
|
+
if (!top) return { error: { code: "not-a-git-repository", message: "a release is checked against the signers file at its commit, and this directory is not in a git repository" } };
|
|
66
|
+
const found = await resolveRelease(ref, { cwd });
|
|
67
|
+
if (!found) return { error: { code: "release-not-found", message: `no release is labelled or digested "${ref}" in this member`, fix: "Fetch chant/lifecycle (git fetch origin chant/lifecycle:chant/lifecycle), then list them with: chant release list" } };
|
|
68
|
+
const release: Release = found.release;
|
|
69
|
+
const at = resolveBase(top, release.commit).commit;
|
|
70
|
+
if (!at) {
|
|
71
|
+
return {
|
|
72
|
+
error: {
|
|
73
|
+
code: "release-commit-missing",
|
|
74
|
+
message: `the release's commit ${release.commit} is not in this clone, so the signers file at it can't be read`,
|
|
75
|
+
fix: `Fetch it: git fetch origin ${release.commit}`,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const source = scopeSource(cwd, at);
|
|
80
|
+
if (source.policy.problems.length > 0) {
|
|
81
|
+
return { error: { code: "trust-policy-unreadable", message: `the signers at ${at.slice(0, 12)} can't be read: ${source.policy.problems.join("; ")}` } };
|
|
82
|
+
}
|
|
83
|
+
const read = releaseRuleFrom(source);
|
|
84
|
+
// A declaration read from the working tree is not the commit's, so its class does not apply.
|
|
85
|
+
const rule = source.from === "base" ? read.rule : { ...read.rule, class: null };
|
|
86
|
+
|
|
87
|
+
const own = (await readCuts({ cwd })).cuts.filter((c) => c.releaseDigest === release.releaseDigest);
|
|
88
|
+
// The check words where it read the signers as "at base"; here that is the release's commit.
|
|
89
|
+
const atCommit = (check: ReleaseSignatureCheck): ReleaseSignatureCheck => ({ ...check, reason: check.reason.split(`${rule.signers.signersPath} at base`).join(`${rule.signers.signersPath} at ${at.slice(0, 12)}`) });
|
|
90
|
+
const cuts = own.map((c) => ({
|
|
91
|
+
label: c.label,
|
|
92
|
+
actor: c.actor,
|
|
93
|
+
timestamp: c.timestamp,
|
|
94
|
+
runId: c.runId ?? null,
|
|
95
|
+
signatureCheck: atCommit(checkReleaseSignature(rule, { releaseDigest: c.releaseDigest, label: c.label, actor: c.actor, timestamp: c.timestamp, signature: c.signature })),
|
|
96
|
+
}));
|
|
97
|
+
const verified = cuts.find((c) => c.signatureCheck.status === "verified");
|
|
98
|
+
const digestMatches = verifyReleaseDigest(release);
|
|
99
|
+
const status: ReleaseVerification["status"] = verified ? "verified" : cuts.every((c) => c.signatureCheck.status === "unsigned") ? "unsigned" : "unverified";
|
|
100
|
+
const where = `${rule.signers.signersPath} at ${at.slice(0, 12)}`;
|
|
101
|
+
const reason = !digestMatches
|
|
102
|
+
? `the release file's digest ${release.releaseDigest} is not the digest of what it holds, so it was changed after the cut`
|
|
103
|
+
: verified
|
|
104
|
+
? `${verified.signatureCheck.reason}, checked against ${where}`
|
|
105
|
+
: status === "unsigned"
|
|
106
|
+
? `no cut of the release carries a signature`
|
|
107
|
+
: cuts.map((c) => c.signatureCheck.reason).join("; ");
|
|
108
|
+
return {
|
|
109
|
+
releaseDigest: release.releaseDigest,
|
|
110
|
+
label: found.label,
|
|
111
|
+
member: release.member,
|
|
112
|
+
commit: release.commit,
|
|
113
|
+
signers: { path: rule.signers.signersPath, at, active: rule.signers.active },
|
|
114
|
+
digestMatches,
|
|
115
|
+
status: digestMatches ? status : "unverified",
|
|
116
|
+
verifiedBy: digestMatches && verified ? { actor: verified.actor, key: verified.signatureCheck.key } : null,
|
|
117
|
+
reason,
|
|
118
|
+
cuts,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4027: what a cut reads from the checkout and the environment before it
|
|
3
|
+
* compiles, against temp repos with temp bare remotes. Nothing is pushed
|
|
4
|
+
* anywhere but those remotes, and no tag is pushed at all.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
checkCleanTree,
|
|
14
|
+
checkOnTrunk,
|
|
15
|
+
checkReleaseTag,
|
|
16
|
+
createReleaseTag,
|
|
17
|
+
publicKeyOf,
|
|
18
|
+
releaseTagName,
|
|
19
|
+
resolveCutActor,
|
|
20
|
+
resolveTrunk,
|
|
21
|
+
WorkstationCheckError,
|
|
22
|
+
} from "./release-workstation";
|
|
23
|
+
import { nextCommands, sameWithYes } from "../cli/handlers/release";
|
|
24
|
+
import { hasSshKeygen } from "../workspace/trust/test-repo";
|
|
25
|
+
|
|
26
|
+
function git(dir: string, ...args: string[]): string {
|
|
27
|
+
const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
28
|
+
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
|
|
29
|
+
return r.stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A repo at `<root>/work` with one commit on main, pushed to the bare `<root>/origin.git`. */
|
|
33
|
+
function pushedRepo(root: string): { dir: string; sha: string } {
|
|
34
|
+
const dir = join(root, "work");
|
|
35
|
+
mkdirSync(dir);
|
|
36
|
+
git(root, "init", "-q", "--bare", "-b", "main", "origin.git");
|
|
37
|
+
git(dir, "init", "-q", "-b", "main");
|
|
38
|
+
git(dir, "config", "user.email", "dev@chant.dev");
|
|
39
|
+
git(dir, "config", "user.name", "Dev");
|
|
40
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
41
|
+
writeFileSync(join(dir, ".gitignore"), "dist/\n");
|
|
42
|
+
git(dir, "add", ".");
|
|
43
|
+
git(dir, "commit", "-q", "-m", "init");
|
|
44
|
+
git(dir, "remote", "add", "origin", join(root, "origin.git"));
|
|
45
|
+
git(dir, "push", "-q", "origin", "main");
|
|
46
|
+
git(dir, "fetch", "-q", "origin");
|
|
47
|
+
return { dir, sha: git(dir, "rev-parse", "HEAD") };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const codeOf = (fn: () => unknown): string | undefined => {
|
|
51
|
+
try {
|
|
52
|
+
fn();
|
|
53
|
+
return undefined;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err instanceof WorkstationCheckError) return err.code;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe("trunk and pushed", () => {
|
|
61
|
+
test("trunk is ci.green.branch when declared, else what origin/HEAD names, else main", () =>
|
|
62
|
+
withTestDir(async (root) => {
|
|
63
|
+
const { dir } = pushedRepo(root);
|
|
64
|
+
expect(resolveTrunk(dir, "release")).toEqual({ remote: "origin", branch: "release", from: "ci.green.branch" });
|
|
65
|
+
// A fetch may set origin/HEAD, depending on the git version.
|
|
66
|
+
git(dir, "remote", "set-head", "origin", "-d");
|
|
67
|
+
expect(resolveTrunk(dir, null)).toEqual({ remote: "origin", branch: "main", from: "default" });
|
|
68
|
+
git(dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/trunk");
|
|
69
|
+
expect(resolveTrunk(dir, null)).toEqual({ remote: "origin", branch: "trunk", from: "origin/HEAD" });
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
test("a commit reachable from origin/<trunk> passes; one only on the local branch is not pushed; one off trunk is refused", () =>
|
|
73
|
+
withTestDir(async (root) => {
|
|
74
|
+
const { dir, sha } = pushedRepo(root);
|
|
75
|
+
const trunk = resolveTrunk(dir, null);
|
|
76
|
+
expect(codeOf(() => checkOnTrunk(dir, sha, trunk))).toBeUndefined();
|
|
77
|
+
|
|
78
|
+
writeFileSync(join(dir, "a.txt"), "a\n");
|
|
79
|
+
git(dir, "add", ".");
|
|
80
|
+
git(dir, "commit", "-q", "-m", "local only");
|
|
81
|
+
const local = git(dir, "rev-parse", "HEAD");
|
|
82
|
+
expect(codeOf(() => checkOnTrunk(dir, local, trunk))).toBe("release-commit-not-pushed");
|
|
83
|
+
// An older commit stays pushed whatever the local branch holds.
|
|
84
|
+
expect(codeOf(() => checkOnTrunk(dir, sha, trunk))).toBeUndefined();
|
|
85
|
+
|
|
86
|
+
git(dir, "checkout", "-q", "-b", "feature", sha);
|
|
87
|
+
writeFileSync(join(dir, "b.txt"), "b\n");
|
|
88
|
+
git(dir, "add", ".");
|
|
89
|
+
git(dir, "commit", "-q", "-m", "feature");
|
|
90
|
+
const feature = git(dir, "rev-parse", "HEAD");
|
|
91
|
+
expect(codeOf(() => checkOnTrunk(dir, feature, trunk))).toBe("release-commit-not-on-trunk");
|
|
92
|
+
// The branch ci.green names is trunk even when the commit is pushed elsewhere.
|
|
93
|
+
git(dir, "push", "-q", "origin", "feature");
|
|
94
|
+
git(dir, "fetch", "-q", "origin");
|
|
95
|
+
expect(codeOf(() => checkOnTrunk(dir, feature, trunk))).toBe("release-commit-not-on-trunk");
|
|
96
|
+
expect(codeOf(() => checkOnTrunk(dir, feature, resolveTrunk(dir, "feature")))).toBeUndefined();
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
test("a clone with no origin/<trunk> can't show the commit pushed", () =>
|
|
100
|
+
withTestDir(async (root) => {
|
|
101
|
+
git(root, "init", "-q", "-b", "main", "solo");
|
|
102
|
+
const dir = join(root, "solo");
|
|
103
|
+
git(dir, "-c", "user.email=a@b", "-c", "user.name=a", "commit", "-q", "--allow-empty", "-m", "init");
|
|
104
|
+
const err = (() => {
|
|
105
|
+
try {
|
|
106
|
+
checkOnTrunk(dir, git(dir, "rev-parse", "HEAD"), resolveTrunk(dir, null));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return e as WorkstationCheckError;
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
expect(err?.code).toBe("release-commit-not-pushed");
|
|
112
|
+
expect(err?.message).toMatch(/this clone knows no origin\/main/);
|
|
113
|
+
}));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("a clean tree", () => {
|
|
117
|
+
test("refuses a tracked change and an untracked file, and ignores what .gitignore covers", () =>
|
|
118
|
+
withTestDir(async (root) => {
|
|
119
|
+
const { dir, sha } = pushedRepo(root);
|
|
120
|
+
expect(codeOf(() => checkCleanTree(dir, sha))).toBeUndefined();
|
|
121
|
+
mkdirSync(join(dir, "dist"));
|
|
122
|
+
writeFileSync(join(dir, "dist", "out.json"), "{}\n");
|
|
123
|
+
expect(codeOf(() => checkCleanTree(dir, sha))).toBeUndefined();
|
|
124
|
+
writeFileSync(join(dir, "extra.component.ts"), "export const x = {};\n");
|
|
125
|
+
expect(() => checkCleanTree(dir, sha)).toThrow(/1 untracked file\(s\) such as extra\.component\.ts/);
|
|
126
|
+
git(dir, "add", "extra.component.ts");
|
|
127
|
+
writeFileSync(join(dir, "README.md"), "changed\n");
|
|
128
|
+
expect(() => checkCleanTree(dir, sha)).toThrow(/2 tracked file\(s\) with uncommitted changes/);
|
|
129
|
+
}));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("the actor", () => {
|
|
133
|
+
test("is --actor, else the CI's actor, else git's user.email", () =>
|
|
134
|
+
withTestDir(async (root) => {
|
|
135
|
+
const { dir } = pushedRepo(root);
|
|
136
|
+
expect(resolveCutActor({ cwd: dir, explicit: "carol", env: { GITHUB_ACTOR: "ci" } })).toEqual({ actor: "carol", source: "flag" });
|
|
137
|
+
expect(resolveCutActor({ cwd: dir, env: { GITHUB_ACTOR: "octo", USER: "u" } })).toEqual({ actor: "octo", source: "ci" });
|
|
138
|
+
expect(resolveCutActor({ cwd: dir, env: { GITLAB_USER_LOGIN: "gl", USER: "u" } })).toEqual({ actor: "gl", source: "ci" });
|
|
139
|
+
expect(resolveCutActor({ cwd: dir, env: { USER: "u" } })).toEqual({ actor: "dev@chant.dev", source: "git" });
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
test.skipIf(!hasSshKeygen)("is the principal the signers file lists for the signing key, ahead of the CI and git", () =>
|
|
143
|
+
withTestDir(async (root) => {
|
|
144
|
+
const { dir } = pushedRepo(root);
|
|
145
|
+
const key = join(root, "key-alice");
|
|
146
|
+
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "alice", "-f", key]);
|
|
147
|
+
const pub = publicKeyOf(key);
|
|
148
|
+
expect(pub).toMatch(/^ssh-ed25519 [A-Za-z0-9+/=]+$/);
|
|
149
|
+
expect(publicKeyOf(`${key}.pub`)).toBe(pub);
|
|
150
|
+
const signers = [
|
|
151
|
+
{ principal: "github:alice", key: pub!, line: 1 },
|
|
152
|
+
{ principal: "bob", key: "ssh-ed25519 AAAAother", line: 2 },
|
|
153
|
+
];
|
|
154
|
+
expect(resolveCutActor({ cwd: dir, publicKey: pub, signers, env: { GITHUB_ACTOR: "octo" } })).toEqual({ actor: "github:alice", source: "signer" });
|
|
155
|
+
// A key listed for two principals names no one, so the next source answers.
|
|
156
|
+
const twice = [...signers, { principal: "alice2", key: pub!, line: 3 }];
|
|
157
|
+
expect(resolveCutActor({ cwd: dir, publicKey: pub, signers: twice, env: { GITHUB_ACTOR: "octo" } })).toEqual({ actor: "octo", source: "ci" });
|
|
158
|
+
expect(resolveCutActor({ cwd: dir, explicit: "carol", publicKey: pub, signers })).toEqual({ actor: "carol", source: "flag" });
|
|
159
|
+
}));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("the release tag", () => {
|
|
163
|
+
test("is release/<label>, or release/<member>/<label> in a member, made locally as an annotated tag naming the digest", () =>
|
|
164
|
+
withTestDir(async (root) => {
|
|
165
|
+
const { dir, sha } = pushedRepo(root);
|
|
166
|
+
expect(releaseTagName(null, "v1.2.0")).toBe("release/v1.2.0");
|
|
167
|
+
expect(releaseTagName("services/api", "v1.2.0")).toBe("release/services/api/v1.2.0");
|
|
168
|
+
expect(checkReleaseTag(dir, "release/v1", sha)).toBe("absent");
|
|
169
|
+
createReleaseTag(dir, "release/v1", sha, { releaseDigest: "jcs1-sha256:abc", label: "v1", member: null });
|
|
170
|
+
expect(git(dir, "cat-file", "-t", "release/v1")).toBe("tag");
|
|
171
|
+
expect(git(dir, "tag", "-l", "--format=%(contents)", "release/v1")).toBe("release v1\n\nRelease-Digest: jcs1-sha256:abc");
|
|
172
|
+
expect(checkReleaseTag(dir, "release/v1", sha)).toBe("present");
|
|
173
|
+
expect(git(root, "ls-remote", "--tags", join(root, "origin.git"))).toBe("");
|
|
174
|
+
|
|
175
|
+
writeFileSync(join(dir, "c.txt"), "c\n");
|
|
176
|
+
git(dir, "add", ".");
|
|
177
|
+
git(dir, "commit", "-q", "-m", "next");
|
|
178
|
+
expect(codeOf(() => checkReleaseTag(dir, "release/v1", git(dir, "rev-parse", "HEAD")))).toBe("release-tag-taken");
|
|
179
|
+
expect(codeOf(() => checkReleaseTag(dir, "release/v1..2", sha))).toBe("release-tag-invalid");
|
|
180
|
+
}));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("the next command", () => {
|
|
184
|
+
test("in CI is the publish; on a workstation it pushes the tag first, then publish or deploy", () => {
|
|
185
|
+
expect(nextCommands({ ref: "v1", tag: "release/v1", inCi: true, firstEnv: "dev", noComponents: false })).toEqual([
|
|
186
|
+
"chant release publish v1",
|
|
187
|
+
"chant components promote --release v1 --to dev",
|
|
188
|
+
]);
|
|
189
|
+
expect(nextCommands({ ref: "v1", tag: "release/v1", inCi: false, firstEnv: "dev", noComponents: false })).toEqual([
|
|
190
|
+
"git push origin release/v1",
|
|
191
|
+
"chant release publish v1",
|
|
192
|
+
"chant components promote --release v1 --to dev",
|
|
193
|
+
]);
|
|
194
|
+
expect(nextCommands({ ref: "jcs1-sha256:ab", tag: null, inCi: false, firstEnv: "dev", noComponents: true })).toEqual([
|
|
195
|
+
"chant release publish jcs1-sha256:ab",
|
|
196
|
+
"chant release export jcs1-sha256:ab --env dev",
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("after a dry run is the same command line with --yes, without --json", () => {
|
|
201
|
+
expect(sameWithYes(["release", "cut", "v1", "--build", "npm run build", "--json"])).toBe("chant release cut v1 --build 'npm run build' --yes");
|
|
202
|
+
expect(sameWithYes(["release", "cut", "v1", "--build", "echo 'x'"])).toBe(`chant release cut v1 --build 'echo '\\''x'\\''' --yes`);
|
|
203
|
+
});
|
|
204
|
+
});
|