@intentius/chant 0.80.0 → 0.81.0
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/audit/discover.d.ts +8 -16
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts +5 -0
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +13 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +4 -5
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +25 -9
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +8 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts +6 -0
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/vendor.d.ts +5 -0
- package/dist/cli/commands/vendor.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +7 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +7 -4
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/run-generate.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +4 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/vendor.d.ts +8 -3
- package/dist/cli/handlers/vendor.d.ts.map +1 -1
- package/dist/cli/main.d.ts +12 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +12 -7
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +32 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/fetch.d.ts +10 -0
- package/dist/codegen/fetch.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts +16 -2
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/gate-plan.d.ts +40 -0
- package/dist/components/gate-plan.d.ts.map +1 -0
- package/dist/components/promote.d.ts +60 -7
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/components/sandbox/run.d.ts.map +1 -1
- package/dist/components/verbs/build-archive.d.ts +22 -11
- package/dist/components/verbs/build-archive.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts.map +1 -1
- package/dist/config-static.d.ts +59 -0
- package/dist/config-static.d.ts.map +1 -0
- package/dist/config.d.ts +32 -10
- package/dist/config.d.ts.map +1 -1
- package/dist/content-digest.d.ts +17 -0
- package/dist/content-digest.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +10 -5
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +20 -4
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts +20 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +20 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/walk.d.ts +119 -0
- package/dist/discovery/walk.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +4 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/join-key.d.ts +31 -0
- package/dist/join-key.d.ts.map +1 -0
- package/dist/lexicon-module.d.ts +51 -6
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon.d.ts +58 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts +5 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +20 -5
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +64 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +8 -1
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +41 -8
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +67 -0
- package/dist/lifecycle/member-ledger.d.ts.map +1 -0
- package/dist/lifecycle/plan-digest.d.ts +4 -3
- package/dist/lifecycle/plan-digest.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +18 -2
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +90 -5
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +32 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +75 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +16 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/project-root.d.ts +26 -0
- package/dist/project-root.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.d.ts +37 -0
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts +11 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -0
- package/dist/workspace/checks/generated.d.ts +156 -0
- package/dist/workspace/checks/generated.d.ts.map +1 -0
- package/dist/workspace/checks/ledgers.d.ts +78 -0
- package/dist/workspace/checks/ledgers.d.ts.map +1 -0
- package/dist/workspace/checks/links.d.ts +25 -0
- package/dist/workspace/checks/links.d.ts.map +1 -0
- package/dist/workspace/checks/pipelines.d.ts +76 -0
- package/dist/workspace/checks/pipelines.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +161 -0
- package/dist/workspace/checks.d.ts.map +1 -0
- package/dist/workspace/compose-graph.d.ts +115 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -0
- package/dist/workspace/compose-reports.d.ts +68 -0
- package/dist/workspace/compose-reports.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +220 -0
- package/dist/workspace/declaration.d.ts.map +1 -0
- package/dist/workspace/declaration.schema.json +434 -0
- package/dist/workspace/generated-files.d.ts +65 -0
- package/dist/workspace/generated-files.d.ts.map +1 -0
- package/dist/workspace/glob.d.ts +13 -0
- package/dist/workspace/glob.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +75 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -0
- package/dist/workspace/init.d.ts +96 -0
- package/dist/workspace/init.d.ts.map +1 -0
- package/dist/workspace/jsonc.d.ts +38 -0
- package/dist/workspace/jsonc.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +192 -0
- package/dist/workspace/kinds.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +84 -0
- package/dist/workspace/lineage-check.d.ts.map +1 -0
- package/dist/workspace/lineage-cli.d.ts +31 -0
- package/dist/workspace/lineage-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-init.d.ts +101 -0
- package/dist/workspace/lineage-init.d.ts.map +1 -0
- package/dist/workspace/lineage-lock.d.ts +258 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -0
- package/dist/workspace/lineage-merge.d.ts +14 -0
- package/dist/workspace/lineage-merge.d.ts.map +1 -0
- package/dist/workspace/lineage-migrations.d.ts +178 -0
- package/dist/workspace/lineage-migrations.d.ts.map +1 -0
- package/dist/workspace/lineage-update.d.ts +59 -0
- package/dist/workspace/lineage-update.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts +40 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade.d.ts +146 -0
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -0
- package/dist/workspace/lineage-vendor.d.ts +63 -0
- package/dist/workspace/lineage-vendor.d.ts.map +1 -0
- package/dist/workspace/lineage-version.d.ts +32 -0
- package/dist/workspace/lineage-version.d.ts.map +1 -0
- package/dist/workspace/links.d.ts +150 -0
- package/dist/workspace/links.d.ts.map +1 -0
- package/dist/workspace/ls.d.ts +99 -0
- package/dist/workspace/ls.d.ts.map +1 -0
- package/dist/workspace/member-commands.d.ts +139 -0
- package/dist/workspace/member-commands.d.ts.map +1 -0
- package/dist/workspace/member-handles.d.ts +54 -0
- package/dist/workspace/member-handles.d.ts.map +1 -0
- package/dist/workspace/member-pipeline.d.ts +143 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -0
- package/dist/workspace/member-run.d.ts +67 -0
- package/dist/workspace/member-run.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +65 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +28 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/root-refusal.d.ts +53 -0
- package/dist/workspace/root-refusal.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +150 -0
- package/dist/workspace/status.d.ts.map +1 -0
- package/dist/workspace/template-manifest.d.ts +90 -0
- package/dist/workspace/template-manifest.d.ts.map +1 -0
- package/dist/workspace/tree.d.ts +36 -0
- package/dist/workspace/tree.d.ts.map +1 -0
- package/dist/workspace/trust/attestor.d.ts +67 -0
- package/dist/workspace/trust/attestor.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +111 -0
- package/dist/workspace/trust/policy.d.ts.map +1 -0
- package/dist/workspace/trust/provenance.d.ts +67 -0
- package/dist/workspace/trust/provenance.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +43 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -0
- package/dist/workspace/trust/test-repo.d.ts +42 -0
- package/dist/workspace/trust/test-repo.d.ts.map +1 -0
- package/dist/workspace/trust/verify-cli.d.ts +9 -0
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -0
- package/dist/workspace/trust/verify.d.ts +66 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -0
- package/dist/workspace/which-chant.d.ts +58 -0
- package/dist/workspace/which-chant.d.ts.map +1 -0
- package/dist/workspace/workspace-kinds.schema.json +133 -0
- package/package.json +2 -1
- package/src/audit/discover.ts +66 -78
- package/src/audit/report-html.ts +3 -1
- package/src/audit/report-model.ts +14 -1
- package/src/audit/terraform-state.test.ts +4 -9
- package/src/audit/terraform-state.ts +4 -5
- package/src/cli/commands/audit-truncation.test.ts +167 -0
- package/src/cli/commands/audit.ts +89 -60
- package/src/cli/commands/check-lexicon.ts +24 -0
- package/src/cli/commands/doctor.test.ts +30 -0
- package/src/cli/commands/doctor.ts +18 -0
- package/src/cli/commands/import-agents.ts +6 -2
- package/src/cli/commands/init.ts +22 -3
- package/src/cli/commands/lint.test.ts +17 -23
- package/src/cli/commands/lint.ts +24 -84
- package/src/cli/commands/update.ts +13 -4
- package/src/cli/commands/vendor.ts +11 -6
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +38 -0
- package/src/cli/handlers/build-member.test.ts +127 -0
- package/src/cli/handlers/build.ts +35 -1
- package/src/cli/handlers/components.test.ts +127 -0
- package/src/cli/handlers/components.ts +70 -8
- package/src/cli/handlers/dev.ts +5 -3
- package/src/cli/handlers/fan-out.ts +9 -2
- package/src/cli/handlers/init.ts +54 -1
- package/src/cli/handlers/operator.test.ts +68 -0
- package/src/cli/handlers/operator.ts +37 -9
- package/src/cli/handlers/promote.test.ts +118 -2
- package/src/cli/handlers/promote.ts +82 -16
- package/src/cli/handlers/run-generate-member.test.ts +83 -0
- package/src/cli/handlers/run-generate.test.ts +6 -8
- package/src/cli/handlers/run-generate.ts +36 -1
- package/src/cli/handlers/run.test.ts +47 -0
- package/src/cli/handlers/run.ts +28 -5
- package/src/cli/handlers/vendor.ts +147 -43
- package/src/cli/main.test.ts +105 -1
- package/src/cli/main.ts +230 -17
- package/src/cli/plugins.ts +17 -17
- package/src/cli/registry.ts +32 -0
- package/src/cli/static-config-read.test.ts +208 -0
- package/src/codegen/fetch.test.ts +63 -1
- package/src/codegen/fetch.ts +30 -1
- package/src/components/cli-support.ts +6 -1
- package/src/components/discover.test.ts +27 -57
- package/src/components/discover.ts +13 -68
- package/src/components/driver.test.ts +78 -1
- package/src/components/driver.ts +43 -7
- package/src/components/gate-plan.ts +53 -0
- package/src/components/promote.test.ts +276 -2
- package/src/components/promote.ts +119 -15
- package/src/components/sandbox/driver.ts +8 -8
- package/src/components/sandbox/run.ts +4 -1
- package/src/components/verbs/build-archive.ts +27 -21
- package/src/components/verbs/run-agent.ts +3 -5
- package/src/config-static.test.ts +136 -0
- package/src/config-static.ts +232 -0
- package/src/config.test.ts +2 -2
- package/src/config.ts +48 -15
- package/src/content-digest.test.ts +120 -0
- package/src/content-digest.ts +21 -0
- package/src/discovery/files.test.ts +10 -10
- package/src/discovery/files.ts +25 -81
- package/src/discovery/fold-import.ts +49 -5
- package/src/discovery/index.ts +1 -0
- package/src/discovery/sandbox/driver.ts +73 -35
- package/src/discovery/sandbox/fork-diagnostic.test.ts +136 -13
- package/src/discovery/sandbox/fork.ts +83 -43
- package/src/discovery/sandbox/path-lexicon-multifile.test.ts +285 -0
- package/src/discovery/walk.test.ts +263 -0
- package/src/discovery/walk.ts +334 -0
- package/src/effect-receipt.ts +2 -2
- package/src/graph-ir.ts +4 -1
- package/src/join-key.test.ts +25 -0
- package/src/join-key.ts +39 -0
- package/src/lexicon-module.ts +108 -10
- package/src/lexicon.ts +79 -1
- package/src/lifecycle/build-ledger-store.ts +11 -10
- package/src/lifecycle/digest.ts +9 -5
- package/src/lifecycle/gate-ledger.test.ts +20 -0
- package/src/lifecycle/gate-ledger.ts +28 -6
- package/src/lifecycle/git.test.ts +13 -1
- package/src/lifecycle/git.ts +221 -176
- package/src/lifecycle/lease.ts +25 -10
- package/src/lifecycle/legacy-digest.test.ts +115 -23
- package/src/lifecycle/legacy-digest.ts +60 -19
- package/src/lifecycle/member-ledger.test.ts +319 -0
- package/src/lifecycle/member-ledger.ts +145 -0
- package/src/lifecycle/plan-digest.ts +7 -7
- package/src/lifecycle/release-ledger.test.ts +20 -0
- package/src/lifecycle/release-ledger.ts +23 -7
- package/src/lifecycle/status.test.ts +151 -1
- package/src/lifecycle/status.ts +228 -7
- package/src/op/activities/activity-contracts.ts +37 -0
- package/src/op/activities/index.ts +10 -0
- package/src/op/activities/propose-upgrade.ts +206 -0
- package/src/op/discover.test.ts +14 -4
- package/src/op/discover.ts +19 -27
- package/src/op/gate-name.ts +8 -0
- package/src/op/gate-summary.test.ts +14 -0
- package/src/op/gate-summary.ts +25 -2
- package/src/op/gate.ts +36 -7
- package/src/op/generate-pipeline.test.ts +14 -5
- package/src/project-root.test.ts +66 -1
- package/src/project-root.ts +48 -0
- package/src/workspace/__fixtures__/contract-repo.ts +100 -0
- package/src/workspace/__fixtures__/two-member-workspace.ts +35 -0
- package/src/workspace/behold-kinds.test.ts +162 -0
- package/src/workspace/check-contract.test.ts +127 -0
- package/src/workspace/check.schema.json +347 -0
- package/src/workspace/checks/generated.test.ts +287 -0
- package/src/workspace/checks/generated.ts +475 -0
- package/src/workspace/checks/ledgers.test.ts +139 -0
- package/src/workspace/checks/ledgers.ts +246 -0
- package/src/workspace/checks/links.ts +183 -0
- package/src/workspace/checks/pipelines.test.ts +133 -0
- package/src/workspace/checks/pipelines.ts +225 -0
- package/src/workspace/checks-repo.test.ts +71 -0
- package/src/workspace/checks.test.ts +450 -0
- package/src/workspace/checks.ts +461 -0
- package/src/workspace/compose-graph.test.ts +146 -0
- package/src/workspace/compose-graph.ts +228 -0
- package/src/workspace/compose-reports.test.ts +109 -0
- package/src/workspace/compose-reports.ts +186 -0
- package/src/workspace/declaration.schema.json +434 -0
- package/src/workspace/declaration.test.ts +347 -0
- package/src/workspace/declaration.ts +623 -0
- package/src/workspace/generated-files.test.ts +114 -0
- package/src/workspace/generated-files.ts +94 -0
- package/src/workspace/glob.ts +49 -0
- package/src/workspace/graph-cli.ts +220 -0
- package/src/workspace/graph-contract.test.ts +204 -0
- package/src/workspace/graph.schema.json +662 -0
- package/src/workspace/init.test.ts +223 -0
- package/src/workspace/init.ts +585 -0
- package/src/workspace/jsonc.test.ts +65 -0
- package/src/workspace/jsonc.ts +231 -0
- package/src/workspace/kinds.test.ts +288 -0
- package/src/workspace/kinds.ts +506 -0
- package/src/workspace/lineage-check.ts +264 -0
- package/src/workspace/lineage-cli.ts +114 -0
- package/src/workspace/lineage-init.test.ts +231 -0
- package/src/workspace/lineage-init.ts +365 -0
- package/src/workspace/lineage-lock.test.ts +224 -0
- package/src/workspace/lineage-lock.ts +357 -0
- package/src/workspace/lineage-merge.ts +44 -0
- package/src/workspace/lineage-migrations.test.ts +178 -0
- package/src/workspace/lineage-migrations.ts +343 -0
- package/src/workspace/lineage-update.ts +160 -0
- package/src/workspace/lineage-upgrade-cli.ts +151 -0
- package/src/workspace/lineage-upgrade.test.ts +474 -0
- package/src/workspace/lineage-upgrade.ts +624 -0
- package/src/workspace/lineage-vendor.test.ts +126 -0
- package/src/workspace/lineage-vendor.ts +215 -0
- package/src/workspace/lineage-version.ts +127 -0
- package/src/workspace/links.test.ts +344 -0
- package/src/workspace/links.ts +331 -0
- package/src/workspace/ls-contract.test.ts +200 -0
- package/src/workspace/ls.schema.json +190 -0
- package/src/workspace/ls.ts +254 -0
- package/src/workspace/member-commands.e2e.test.ts +244 -0
- package/src/workspace/member-commands.test.ts +217 -0
- package/src/workspace/member-commands.ts +540 -0
- package/src/workspace/member-handles.test.ts +76 -0
- package/src/workspace/member-handles.ts +228 -0
- package/src/workspace/member-pipeline.test.ts +135 -0
- package/src/workspace/member-pipeline.ts +317 -0
- package/src/workspace/member-run.ts +146 -0
- package/src/workspace/read-contract.test.ts +129 -0
- package/src/workspace/reason-codes.test.ts +113 -0
- package/src/workspace/reason-codes.ts +81 -0
- package/src/workspace/records-cli.ts +86 -14
- package/src/workspace/records-contract.test.ts +5 -0
- package/src/workspace/records.schema.json +32 -2
- package/src/workspace/records.ts +3 -2
- package/src/workspace/root-refusal.ts +97 -0
- package/src/workspace/status-contract.test.ts +308 -0
- package/src/workspace/status.schema.json +210 -0
- package/src/workspace/status.ts +412 -0
- package/src/workspace/template-manifest.test.ts +79 -0
- package/src/workspace/template-manifest.ts +221 -0
- package/src/workspace/tree.ts +132 -0
- package/src/workspace/trust/attestor.ts +93 -0
- package/src/workspace/trust/policy.ts +306 -0
- package/src/workspace/trust/provenance.ts +181 -0
- package/src/workspace/trust/ssh-commit.ts +138 -0
- package/src/workspace/trust/test-repo.ts +138 -0
- package/src/workspace/trust/trust.test.ts +397 -0
- package/src/workspace/trust/verify-cli.ts +56 -0
- package/src/workspace/trust/verify.ts +194 -0
- package/src/workspace/which-chant.test.ts +117 -0
- package/src/workspace/which-chant.ts +151 -0
- package/src/workspace/workspace-kinds.schema.json +133 -0
- package/dist/components/unbound-gate-approval.d.ts +0 -24
- package/dist/components/unbound-gate-approval.d.ts.map +0 -1
- package/dist/discovery/convergence.d.ts +0 -97
- package/dist/discovery/convergence.d.ts.map +0 -1
- package/src/cli/commands/audit-walk-warnings.test.ts +0 -156
- package/src/components/unbound-gate-approval.test.ts +0 -82
- package/src/components/unbound-gate-approval.ts +0 -51
- package/src/discovery/convergence.test.ts +0 -231
- package/src/discovery/convergence.ts +0 -399
- package/src/op/discover-convergence.test.ts +0 -70
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import { describe, expect, it } from "vitest";
|
|
14
14
|
import { CapabilityRegistry, type DeployContext } from "./capability";
|
|
15
15
|
import { stubCapability } from "./verbs/stub";
|
|
16
|
-
import { memoryGateLedgerPort } from "../op/gate";
|
|
16
|
+
import { describeGateMismatch, memoryGateLedgerPort } from "../op/gate";
|
|
17
|
+
import { componentPlanDigest } from "./gate-plan";
|
|
17
18
|
import type { PendingGateInput } from "../lifecycle/gate-ledger";
|
|
18
19
|
import {
|
|
19
20
|
DependencyCycleError,
|
|
@@ -311,14 +312,17 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
|
|
|
311
312
|
|
|
312
313
|
it("a resolution newer than the pending fact passes the gate and carries the approver", async () => {
|
|
313
314
|
const { registry, calls } = registryWithCalls();
|
|
315
|
+
const planDigest = componentPlanDigest({ environment: "dev", component: gatedComponent() });
|
|
314
316
|
const port = memoryGateLedgerPort({
|
|
315
317
|
pending: [{
|
|
316
318
|
version: 1, kind: "pending", op: "neo4j-cluster", gate: "approve-node-1",
|
|
317
319
|
timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2026-09-07T10:00:00.000Z",
|
|
320
|
+
planDigest, environment: "dev",
|
|
318
321
|
}],
|
|
319
322
|
resolutions: [{
|
|
320
323
|
version: 1, op: "neo4j-cluster", gate: "approve-node-1",
|
|
321
324
|
resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z",
|
|
325
|
+
planDigest, environment: "dev",
|
|
322
326
|
}],
|
|
323
327
|
});
|
|
324
328
|
const result = await runComponentDeploy(
|
|
@@ -332,6 +336,79 @@ describe("runComponentDeploy — gate as fact (#2119)", () => {
|
|
|
332
336
|
.toEqual({ gate: "approve-node-1", resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z" });
|
|
333
337
|
});
|
|
334
338
|
|
|
339
|
+
// #2574: a component gate approval is bound to the environment and the plan.
|
|
340
|
+
describe("approvals bound to environment and plan (#2574)", () => {
|
|
341
|
+
const run = (port: ReturnType<typeof memoryGateLedgerPort>, env: string, component = gatedComponent()) =>
|
|
342
|
+
runComponentDeploy(component, { env, component: "neo4j-cluster" }, registryWithCalls().registry, {}, undefined, { port, now: NOW });
|
|
343
|
+
|
|
344
|
+
it("the pending fact records the environment and the plan", async () => {
|
|
345
|
+
const port = memoryGateLedgerPort();
|
|
346
|
+
const result = await run(port, "staging");
|
|
347
|
+
expect(result.gate).toMatchObject({
|
|
348
|
+
environment: "staging",
|
|
349
|
+
planDigest: componentPlanDigest({ environment: "staging", component: gatedComponent() }),
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("an approval given for another environment does not pass the gate", async () => {
|
|
354
|
+
const port = memoryGateLedgerPort();
|
|
355
|
+
const staging = await run(port, "staging");
|
|
356
|
+
const approval = {
|
|
357
|
+
version: 1 as const, op: "neo4j-cluster", gate: "approve-node-1", resolvedBy: "alex",
|
|
358
|
+
timestamp: "2026-09-05T12:30:00.000Z",
|
|
359
|
+
planDigest: staging.gate!.planDigest!, environment: "staging",
|
|
360
|
+
};
|
|
361
|
+
const withApproval = memoryGateLedgerPort({ pending: [staging.gate!], resolutions: [approval] });
|
|
362
|
+
|
|
363
|
+
expect((await run(withApproval, "staging")).status).toBe("ok");
|
|
364
|
+
const prod = await run(withApproval, "prod");
|
|
365
|
+
expect(prod.status).toBe("gated");
|
|
366
|
+
expect(prod.gate).toMatchObject({ environment: "prod" });
|
|
367
|
+
// The staging approval is not this run's business, so it is not reported as a mismatch either.
|
|
368
|
+
expect(prod.gateMismatch).toBeUndefined();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("an approval given for another plan in the same environment is refused and named", async () => {
|
|
372
|
+
const port = memoryGateLedgerPort();
|
|
373
|
+
const before = await run(port, "prod");
|
|
374
|
+
const approval = {
|
|
375
|
+
version: 1 as const, op: "neo4j-cluster", gate: "approve-node-1", resolvedBy: "alex",
|
|
376
|
+
timestamp: "2026-09-05T12:30:00.000Z",
|
|
377
|
+
planDigest: before.gate!.planDigest!, environment: "prod",
|
|
378
|
+
};
|
|
379
|
+
const changed = gatedComponent();
|
|
380
|
+
changed.deploy[1]!.steps.push({ kind: "cfn-deploy", stack: "extra" });
|
|
381
|
+
const result = await run(memoryGateLedgerPort({ pending: [before.gate!], resolutions: [approval] }), "prod", changed);
|
|
382
|
+
|
|
383
|
+
expect(result.status).toBe("gated");
|
|
384
|
+
expect(result.gateMismatch).toMatchObject({
|
|
385
|
+
approved: before.gate!.planDigest, planned: result.gate!.planDigest, environment: "prod", resolvedBy: "alex",
|
|
386
|
+
});
|
|
387
|
+
expect(result.gate!.planDigest).not.toBe(before.gate!.planDigest);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("an approval recorded before the binding is refused, with the command that replaces it", async () => {
|
|
391
|
+
const legacyPending = {
|
|
392
|
+
version: 1 as const, kind: "pending" as const, op: "neo4j-cluster", gate: "approve-node-1",
|
|
393
|
+
timestamp: "2026-09-05T10:00:00.000Z", expiresAt: "2026-09-07T10:00:00.000Z",
|
|
394
|
+
};
|
|
395
|
+
const legacy = {
|
|
396
|
+
version: 1 as const, op: "neo4j-cluster", gate: "approve-node-1",
|
|
397
|
+
resolvedBy: "alex", timestamp: "2026-09-05T11:00:00.000Z",
|
|
398
|
+
};
|
|
399
|
+
const port = memoryGateLedgerPort({ pending: [legacyPending], resolutions: [legacy] });
|
|
400
|
+
const result = await run(port, "prod");
|
|
401
|
+
|
|
402
|
+
expect(result.status).toBe("gated");
|
|
403
|
+
expect(port.appended).toHaveLength(1);
|
|
404
|
+
expect(result.gateMismatch).toMatchObject({ resolvedBy: "alex", environment: "prod" });
|
|
405
|
+
expect(result.gateMismatch!.approved).toBeUndefined();
|
|
406
|
+
const message = describeGateMismatch("neo4j-cluster", "approve-node-1", result.gateMismatch!);
|
|
407
|
+
expect(message).toContain("predates environment- and plan-bound component gates (#2574)");
|
|
408
|
+
expect(message).toContain("chant approve neo4j-cluster approve-node-1 --env prod");
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
335
412
|
it("the neo4j pilot's gated Node 1 phase ends the run pending approval", async () => {
|
|
336
413
|
const registry = new CapabilityRegistry();
|
|
337
414
|
for (const kind of ["cfn-deploy", "code-deploy", "wait-cluster-healthy"]) {
|
package/src/components/driver.ts
CHANGED
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
import { topoSort } from "../codegen/topo-sort";
|
|
50
|
-
import { evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
50
|
+
import { evaluateGate, gitGateLedgerPort, type GateDigestMismatch, type GateLedgerPort } from "../op/gate";
|
|
51
51
|
import { gateName } from "../op/gate-name";
|
|
52
|
-
import {
|
|
52
|
+
import { componentPlanDigest } from "./gate-plan";
|
|
53
53
|
import type { PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
54
54
|
import type { CapabilityRegistry, DeployContext } from "./capability";
|
|
55
55
|
import type { RunProgressEvent, RunProgressStatus } from "./run-progress";
|
|
@@ -168,6 +168,8 @@ export interface DriverComponentResult {
|
|
|
168
168
|
gatePushed?: boolean;
|
|
169
169
|
/** Set when `gatePushed` is false: why, in one line. */
|
|
170
170
|
gatePushWarning?: string;
|
|
171
|
+
/** Present when `status === "gated"` because an approval stands for this gate, but for another plan or none (#2574). */
|
|
172
|
+
gateMismatch?: GateDigestMismatch;
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
export interface DriverRunResult {
|
|
@@ -189,6 +191,8 @@ export interface DriverRunResult {
|
|
|
189
191
|
gatePushed?: boolean;
|
|
190
192
|
/** Set when `gatePushed` is false: why, in one line. */
|
|
191
193
|
gatePushWarning?: string;
|
|
194
|
+
/** Present when `status === "gated"` because an approval stands for another plan or none (#2574). */
|
|
195
|
+
gateMismatch?: GateDigestMismatch;
|
|
192
196
|
/**
|
|
193
197
|
* The accumulated cross-component/cross-stack outputs after the run — each
|
|
194
198
|
* component's `publish` output and, for an applied stack, its `cfn-deploy`
|
|
@@ -371,6 +375,8 @@ class GateStop extends Error {
|
|
|
371
375
|
/** Whether this run's own append reached the remote — see {@link DriverComponentResult.gatePushed} (#2310). */
|
|
372
376
|
public readonly pushed?: boolean,
|
|
373
377
|
public readonly pushWarning?: string,
|
|
378
|
+
/** Set when an approval stands for this gate but not for this plan (#2574). */
|
|
379
|
+
public readonly mismatch?: GateDigestMismatch,
|
|
374
380
|
) {
|
|
375
381
|
super(`gate "${pending.gate}" is pending approval`);
|
|
376
382
|
this.name = "GateStop";
|
|
@@ -386,6 +392,14 @@ function progressStatus(status: DriverComponentResult["status"]): RunProgressSta
|
|
|
386
392
|
export interface GateContext {
|
|
387
393
|
port: GateLedgerPort;
|
|
388
394
|
now?: string;
|
|
395
|
+
/**
|
|
396
|
+
* The recorded digest each component is deployed at, for a promote or a
|
|
397
|
+
* rollback (#2574). It goes into the plan digest a gate approval is bound
|
|
398
|
+
* to, keyed by component name.
|
|
399
|
+
*/
|
|
400
|
+
releases?: ReadonlyMap<string, string>;
|
|
401
|
+
/** This component's plan digest. Set by {@link runComponentDeploy}, never by a caller. */
|
|
402
|
+
planDigest?: string;
|
|
389
403
|
}
|
|
390
404
|
|
|
391
405
|
/** Run a single capability step. Never throws for a capability-run failure; returns a fail record instead. */
|
|
@@ -468,6 +482,9 @@ async function runPhase(
|
|
|
468
482
|
const check = await evaluateGate(gates.port, {
|
|
469
483
|
op: ctx.component,
|
|
470
484
|
gate: gateName(gate),
|
|
485
|
+
// #2574: an approval counts only for this environment and this plan.
|
|
486
|
+
environment: ctx.env,
|
|
487
|
+
...(gates.planDigest !== undefined ? { planDigest: gates.planDigest } : {}),
|
|
471
488
|
...(gate.description ? { description: gate.description } : {}),
|
|
472
489
|
...(gate.timeout ? { timeout: gate.timeout } : {}),
|
|
473
490
|
...(gates.now ? { now: gates.now } : {}),
|
|
@@ -478,9 +495,8 @@ async function runPhase(
|
|
|
478
495
|
for (const skipped of phaseDef.steps.filter((s): s is DriverStep | DriverPhase => !isGateStep(s))) {
|
|
479
496
|
gateRecords.push(skippedRecord(skipped));
|
|
480
497
|
}
|
|
481
|
-
throw new GateStop(gateRecords, [], check.pending, check.pushed, check.pushWarning);
|
|
498
|
+
throw new GateStop(gateRecords, [], check.pending, check.pushed, check.pushWarning, check.mismatch);
|
|
482
499
|
}
|
|
483
|
-
warnOnUnboundComponentApproval(ctx.component, gateName(gate), ctx.env, check.resolution);
|
|
484
500
|
gateRecords.push({
|
|
485
501
|
...base,
|
|
486
502
|
status: "ok" as const,
|
|
@@ -507,6 +523,7 @@ async function runPhase(
|
|
|
507
523
|
pending?: PendingGateRecord;
|
|
508
524
|
pushed?: boolean;
|
|
509
525
|
pushWarning?: string;
|
|
526
|
+
mismatch?: GateDigestMismatch;
|
|
510
527
|
}> => {
|
|
511
528
|
if (isPhaseStep(entry)) {
|
|
512
529
|
try {
|
|
@@ -524,6 +541,7 @@ async function runPhase(
|
|
|
524
541
|
pending: err.pending,
|
|
525
542
|
pushed: err.pushed,
|
|
526
543
|
pushWarning: err.pushWarning,
|
|
544
|
+
mismatch: err.mismatch,
|
|
527
545
|
};
|
|
528
546
|
}
|
|
529
547
|
throw err;
|
|
@@ -572,7 +590,7 @@ async function runPhase(
|
|
|
572
590
|
const executed = results.flatMap((r) => r.executed);
|
|
573
591
|
const withPending = results.find((r) => r.pending);
|
|
574
592
|
if (withPending?.pending) {
|
|
575
|
-
throw new GateStop(records, executed, withPending.pending, withPending.pushed, withPending.pushWarning);
|
|
593
|
+
throw new GateStop(records, executed, withPending.pending, withPending.pushed, withPending.pushWarning, withPending.mismatch);
|
|
576
594
|
}
|
|
577
595
|
if (results.some((r) => r.failed)) throw new StepFailure(records, executed);
|
|
578
596
|
return { records, executed };
|
|
@@ -586,7 +604,7 @@ async function runPhase(
|
|
|
586
604
|
executed.push(...result.executed);
|
|
587
605
|
if (result.pending) {
|
|
588
606
|
for (const skipped of entries.slice(i + 1)) records.push(skippedRecord(skipped));
|
|
589
|
-
throw new GateStop(records, executed, result.pending, result.pushed, result.pushWarning);
|
|
607
|
+
throw new GateStop(records, executed, result.pending, result.pushed, result.pushWarning, result.mismatch);
|
|
590
608
|
}
|
|
591
609
|
if (result.failed) {
|
|
592
610
|
for (const skipped of entries.slice(i + 1)) records.push(skippedRecord(skipped));
|
|
@@ -695,9 +713,22 @@ export async function runComponentDeploy(
|
|
|
695
713
|
registry: CapabilityRegistry,
|
|
696
714
|
componentOutputs: Record<string, Record<string, unknown>>,
|
|
697
715
|
onProgress?: (event: RunProgressEvent) => void,
|
|
698
|
-
|
|
716
|
+
gateContext: GateContext = { port: gitGateLedgerPort() },
|
|
699
717
|
): Promise<DriverComponentResult> {
|
|
700
718
|
const phaseOutputs: Record<string, Record<string, unknown>> = {};
|
|
719
|
+
// #2574: every gate this component reaches binds its approval to this
|
|
720
|
+
// environment and this composition, so an approval for staging or for last
|
|
721
|
+
// week's deploy cannot pass it.
|
|
722
|
+
const release = gateContext.releases?.get(component.name);
|
|
723
|
+
const gates: GateContext = {
|
|
724
|
+
...gateContext,
|
|
725
|
+
planDigest: componentPlanDigest({
|
|
726
|
+
environment: ctx.env,
|
|
727
|
+
component,
|
|
728
|
+
...(ctx.vars ? { vars: ctx.vars } : {}),
|
|
729
|
+
...(release !== undefined ? { release } : {}),
|
|
730
|
+
}),
|
|
731
|
+
};
|
|
701
732
|
const records: DriverStepRecord[] = [];
|
|
702
733
|
const allExecuted: ExecutedStep[] = [];
|
|
703
734
|
|
|
@@ -722,6 +753,7 @@ export async function runComponentDeploy(
|
|
|
722
753
|
gate: err.pending,
|
|
723
754
|
...(err.pushed !== undefined ? { gatePushed: err.pushed } : {}),
|
|
724
755
|
...(err.pushWarning ? { gatePushWarning: err.pushWarning } : {}),
|
|
756
|
+
...(err.mismatch ? { gateMismatch: err.mismatch } : {}),
|
|
725
757
|
};
|
|
726
758
|
}
|
|
727
759
|
|
|
@@ -875,6 +907,8 @@ export interface InterpretRunOptions {
|
|
|
875
907
|
gates?: GateLedgerPort;
|
|
876
908
|
/** ISO-8601 "now" for gate decisions, so a test is deterministic. */
|
|
877
909
|
now?: string;
|
|
910
|
+
/** The recorded digest a promote or rollback deploys, per component (#2574). Part of the plan a gate approval is bound to. */
|
|
911
|
+
releases?: ReadonlyMap<string, string>;
|
|
878
912
|
}
|
|
879
913
|
|
|
880
914
|
/**
|
|
@@ -908,6 +942,7 @@ export async function runInterpretDriver(
|
|
|
908
942
|
const gates: GateContext = {
|
|
909
943
|
port: options.gates ?? gitGateLedgerPort(),
|
|
910
944
|
...(options.now ? { now: options.now } : {}),
|
|
945
|
+
...(options.releases ? { releases: options.releases } : {}),
|
|
911
946
|
};
|
|
912
947
|
|
|
913
948
|
onProgress?.({ type: "run-start", waves });
|
|
@@ -959,6 +994,7 @@ export async function runInterpretDriver(
|
|
|
959
994
|
gate: gated.gate,
|
|
960
995
|
...(gated.gatePushed !== undefined ? { gatePushed: gated.gatePushed } : {}),
|
|
961
996
|
...(gated.gatePushWarning ? { gatePushWarning: gated.gatePushWarning } : {}),
|
|
997
|
+
...(gated.gateMismatch ? { gateMismatch: gated.gateMismatch } : {}),
|
|
962
998
|
}
|
|
963
999
|
: {}),
|
|
964
1000
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan a component gate approval is bound to (#2574).
|
|
3
|
+
*
|
|
4
|
+
* An Op gate binds the digest of the change set its Plan phase produced
|
|
5
|
+
* (#2300). A component has no Plan phase: what it will do is its composition,
|
|
6
|
+
* run against one environment. So the digest is taken over that, with the
|
|
7
|
+
* same `computePlanDigest` Op gates use.
|
|
8
|
+
*
|
|
9
|
+
* ## What it covers
|
|
10
|
+
*
|
|
11
|
+
* - The environment. An approval for staging never answers prod.
|
|
12
|
+
* - The composition as this run executes it: `deploy` and `rollback` phases
|
|
13
|
+
* with every step's authored input. A promote and a rollback run a
|
|
14
|
+
* transformed composition (`./promote.ts`), and a rollback's pins the
|
|
15
|
+
* recorded digest into it, so each differs from a plain deploy's.
|
|
16
|
+
* - The release being deployed, when the caller names one. A promote checks
|
|
17
|
+
* its publish output against a recorded digest that is not in the
|
|
18
|
+
* composition, so it passes that digest here.
|
|
19
|
+
* - `vars`, the environment config the caller resolved ahead of the run.
|
|
20
|
+
*
|
|
21
|
+
* ## What it leaves out
|
|
22
|
+
*
|
|
23
|
+
* Anything decided during the run: outputs of earlier phases (a fresh build's
|
|
24
|
+
* digest differs on every build that is not reproducible, and binding it
|
|
25
|
+
* would make an approval unusable by the run that consumes it) and outputs of
|
|
26
|
+
* upstream components. The run id and the time are left out for the reasons
|
|
27
|
+
* `../lifecycle/plan-digest.ts` gives.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { computePlanDigest } from "../lifecycle/plan-digest";
|
|
31
|
+
import type { DriverComponent } from "./driver";
|
|
32
|
+
|
|
33
|
+
/** What a component gate's plan digest is taken over. */
|
|
34
|
+
export interface ComponentGatePlan {
|
|
35
|
+
environment: string;
|
|
36
|
+
component: DriverComponent;
|
|
37
|
+
vars?: Record<string, unknown>;
|
|
38
|
+
/** The recorded digest a promote or rollback deploys, when the caller has one. */
|
|
39
|
+
release?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The plan digest a component gate records and approves. */
|
|
43
|
+
export function componentPlanDigest(plan: ComponentGatePlan): string {
|
|
44
|
+
const { component } = plan;
|
|
45
|
+
return computePlanDigest("component-deploy", {
|
|
46
|
+
environment: plan.environment,
|
|
47
|
+
component: component.name,
|
|
48
|
+
deploy: component.deploy,
|
|
49
|
+
rollback: component.rollback ?? null,
|
|
50
|
+
vars: plan.vars ?? null,
|
|
51
|
+
release: plan.release ?? null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -4,20 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, test, expect } from "vitest";
|
|
6
6
|
import { CapabilityRegistry, type DeployContext } from "./capability";
|
|
7
|
-
import { memoryGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
7
|
+
import { describeGateMismatch, memoryGateLedgerPort, type GateLedgerPort } from "../op/gate";
|
|
8
|
+
import { componentPlanDigest } from "./gate-plan";
|
|
8
9
|
import type { GateResolutionRecord } from "../lifecycle/gate-ledger";
|
|
9
10
|
import type { DriverComponent } from "./driver";
|
|
10
11
|
import type { ReleaseRecord } from "../lifecycle/release-ledger";
|
|
11
12
|
import {
|
|
12
13
|
planPromotion,
|
|
13
14
|
planRollback,
|
|
15
|
+
planRedeploy,
|
|
14
16
|
rollbackRecord,
|
|
17
|
+
redeployRecord,
|
|
15
18
|
selectRelease,
|
|
16
19
|
withoutBuildSteps,
|
|
17
20
|
runPromotion,
|
|
18
21
|
promotionRecord,
|
|
19
22
|
gateApprover,
|
|
20
23
|
promoteArchivePaths,
|
|
24
|
+
hasPublishStep,
|
|
25
|
+
parseDigestPins,
|
|
21
26
|
type PromotionPlan,
|
|
22
27
|
} from "./promote";
|
|
23
28
|
|
|
@@ -102,6 +107,72 @@ describe("planning", () => {
|
|
|
102
107
|
});
|
|
103
108
|
});
|
|
104
109
|
|
|
110
|
+
describe("pinning each component to a digest (#2602)", () => {
|
|
111
|
+
// api's pinned release is older than its latest one: another pipeline
|
|
112
|
+
// recorded sha256:newer while this one ran.
|
|
113
|
+
const records = [
|
|
114
|
+
rec("api", "staging", "sha256:built", "2026-01-02T00:00:00Z"),
|
|
115
|
+
rec("api", "staging", "sha256:newer", "2026-01-03T00:00:00Z"),
|
|
116
|
+
rec("web", "staging", "sha256:web", "2026-01-02T00:00:00Z"),
|
|
117
|
+
rec("infra", "staging", "sha256:infra", "2026-01-02T00:00:00Z"),
|
|
118
|
+
];
|
|
119
|
+
const declared = ["api", "web", "infra"];
|
|
120
|
+
|
|
121
|
+
test("promotes exactly the pinned components at the pinned digests, not the latest", () => {
|
|
122
|
+
const plan = planPromotion({
|
|
123
|
+
from: "staging", to: "prod", sourceRecords: records, declared,
|
|
124
|
+
pins: { web: "sha256:web", api: "sha256:built" },
|
|
125
|
+
});
|
|
126
|
+
expect("items" in plan).toBe(true);
|
|
127
|
+
const { items, notPromoted } = plan as PromotionPlan;
|
|
128
|
+
expect(items.map((i) => [i.component, i.digest, i.source.timestamp])).toEqual([
|
|
129
|
+
["api", "sha256:built", "2026-01-02T00:00:00Z"],
|
|
130
|
+
["web", "sha256:web", "2026-01-02T00:00:00Z"],
|
|
131
|
+
]);
|
|
132
|
+
expect(notPromoted).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a pinned digest the source never recorded fails", () => {
|
|
136
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared, pins: { api: "sha256:nope" } });
|
|
137
|
+
expect("error" in plan && plan.error).toMatch(/sha256:nope is not recorded for "api" in "staging"/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a pinned component this checkout does not declare fails", () => {
|
|
141
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared, pins: { gone: "sha256:x" } });
|
|
142
|
+
expect("error" in plan && plan.error).toMatch(/"gone" is not declared/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("pins and --component do not mix", () => {
|
|
146
|
+
const plan = planPromotion({ from: "staging", to: "prod", sourceRecords: records, declared, component: "api", pins: { api: "sha256:built" } });
|
|
147
|
+
expect("error" in plan && plan.error).toMatch(/does not take --component/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("parseDigestPins reads <component>=<digest> values", () => {
|
|
151
|
+
expect(parseDigestPins(["api=sha256:a", "web=sha256:w"])).toEqual({ pins: { api: "sha256:a", web: "sha256:w" } });
|
|
152
|
+
expect(parseDigestPins(["api=sha256:a", "api=sha256:a"])).toEqual({ pins: { api: "sha256:a" } });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("parseDigestPins refuses an empty digest, a bare digest, and two digests for one component", () => {
|
|
156
|
+
const err = (values: string[]) => {
|
|
157
|
+
const parsed = parseDigestPins(values);
|
|
158
|
+
return "error" in parsed ? parsed.error : undefined;
|
|
159
|
+
};
|
|
160
|
+
expect(err(["api="])).toMatch(/names no digest; the run that deployed "api" recorded no release/);
|
|
161
|
+
expect(err(["sha256:a"])).toMatch(/needs --component/);
|
|
162
|
+
expect(err(["=sha256:a"])).toMatch(/names no component/);
|
|
163
|
+
expect(err(["api=sha256:a", "api=sha256:b"])).toMatch(/pins "api" twice/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("hasPublishStep finds a publish step, nested phases included", () => {
|
|
167
|
+
expect(hasPublishStep(service("api"))).toBe(true);
|
|
168
|
+
expect(hasPublishStep({ name: "infra", deploy: [{ phase: "Apply", steps: [{ kind: "apply" }] }] })).toBe(false);
|
|
169
|
+
expect(hasPublishStep({
|
|
170
|
+
name: "nested",
|
|
171
|
+
deploy: [{ phase: "Outer", steps: [{ phase: "Inner", steps: [{ kind: "publish-artifact" }] }] }],
|
|
172
|
+
})).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
105
176
|
describe("taking the build out of a composition", () => {
|
|
106
177
|
test("build steps go, publish and apply stay", () => {
|
|
107
178
|
const prepared = withoutBuildSteps(service("api"));
|
|
@@ -216,10 +287,24 @@ describe("running a promotion", () => {
|
|
|
216
287
|
expect(first.gate?.gate).toBe("prod-release");
|
|
217
288
|
expect(ran).not.toContain("prod:api:apply");
|
|
218
289
|
|
|
219
|
-
|
|
290
|
+
// `chant approve` copies the pending fact's environment and plan (#2574).
|
|
291
|
+
const bound = { planDigest: first.gate!.planDigest!, environment: first.gate!.environment! };
|
|
292
|
+
expect(bound.environment).toBe("prod");
|
|
293
|
+
resolutions.push({ version: 1, kind: "resolution", op: "api", gate: "prod-release", resolvedBy: "alice", timestamp: "2026-01-03T00:01:00Z", ...bound });
|
|
220
294
|
const second = await runPromotion({ ...opts, now: "2026-01-03T00:02:00Z" });
|
|
221
295
|
expect(second.status).toBe("ok");
|
|
222
296
|
expect(gateApprover(second.results[0])).toBe("alice");
|
|
297
|
+
|
|
298
|
+
// The same approval does not pass a promote of another release (#2574).
|
|
299
|
+
const other = await runPromotion({
|
|
300
|
+
...opts,
|
|
301
|
+
plan: planFor("api", "sha256:other"),
|
|
302
|
+
registry: fakeRegistry({ api: "sha256:other" }).registry,
|
|
303
|
+
now: "2026-01-03T00:03:00Z",
|
|
304
|
+
});
|
|
305
|
+
expect(other.status).toBe("gated");
|
|
306
|
+
expect(other.gateMismatch).toMatchObject({ approved: bound.planDigest, resolvedBy: "alice", environment: "prod" });
|
|
307
|
+
expect(other.gate?.planDigest).not.toBe(bound.planDigest);
|
|
223
308
|
});
|
|
224
309
|
|
|
225
310
|
test("dependencies outside the promotion do not block it", async () => {
|
|
@@ -300,6 +385,195 @@ describe("choosing the release a rollback restores", () => {
|
|
|
300
385
|
});
|
|
301
386
|
});
|
|
302
387
|
|
|
388
|
+
describe("choosing the release a redeploy puts back (#2604)", () => {
|
|
389
|
+
const history = [
|
|
390
|
+
rec("api", "prod", "sha256:a", "2026-01-01T00:00:00Z"),
|
|
391
|
+
rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2"),
|
|
392
|
+
];
|
|
393
|
+
const plan = (input: Partial<Parameters<typeof planRedeploy>[0]>) =>
|
|
394
|
+
planRedeploy({ env: "prod", records: history, declared: ["api"], component: "api", ...input });
|
|
395
|
+
|
|
396
|
+
test("the release the ledger records as current", () => {
|
|
397
|
+
const p = plan({});
|
|
398
|
+
if ("error" in p) throw new Error(p.error);
|
|
399
|
+
expect(p.from).toBe("prod");
|
|
400
|
+
expect(p.to).toBe("prod");
|
|
401
|
+
expect(p.items).toEqual([{ component: "api", digest: "sha256:b", source: history[1] }]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("a --digest naming the current release is accepted", () => {
|
|
405
|
+
const p = plan({ digest: "sha256:b" });
|
|
406
|
+
expect("items" in p && p.items[0].digest).toBe("sha256:b");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("a --digest naming any other release is refused and points at rollback", () => {
|
|
410
|
+
const p = plan({ digest: "sha256:a" });
|
|
411
|
+
expect("error" in p && p.error).toMatch(/sha256:a is not the current release of "api" in "prod" \(that is sha256:b\)/);
|
|
412
|
+
expect("error" in p && p.error).toMatch(/chant components rollback prod --component api --digest sha256:a/);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("a component with no release in the environment is refused", () => {
|
|
416
|
+
const p = plan({ records: [] });
|
|
417
|
+
expect("error" in p && p.error).toMatch(/no release of "api" is recorded in "prod"/);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("a component this checkout does not declare is refused", () => {
|
|
421
|
+
const p = plan({ declared: ["web"] });
|
|
422
|
+
expect("error" in p && p.error).toMatch(/not declared in this checkout/);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("the record names the release it put back", () => {
|
|
426
|
+
const p = plan({});
|
|
427
|
+
if ("error" in p) throw new Error(p.error);
|
|
428
|
+
const record = redeployRecord(p.items[0], "prod", { runId: "run-5", actor: "bob", timestamp: "2026-01-05T00:00:00Z" });
|
|
429
|
+
expect(record).toMatchObject({
|
|
430
|
+
env: "prod",
|
|
431
|
+
digest: "sha256:b",
|
|
432
|
+
redeploys: { env: "prod", runId: "run-2", timestamp: "2026-01-02T00:00:00Z" },
|
|
433
|
+
});
|
|
434
|
+
expect(record).not.toHaveProperty("restores");
|
|
435
|
+
expect(record).not.toHaveProperty("promotedFrom");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("a refusal from the pinned composition names the redeploy", () => {
|
|
439
|
+
const c = service("api");
|
|
440
|
+
c.deploy[2].steps[0] = { kind: "apply", image: "@Publish.uri" };
|
|
441
|
+
const p = withoutBuildSteps(c, { pinDigest: "sha256:b", verb: "a redeploy" });
|
|
442
|
+
expect("error" in p && p.error).toMatch(/but a redeploy knows only the recorded digest/);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("a redeploy's gate approval is bound like a rollback's (#2574, #2604)", () => {
|
|
447
|
+
/** A gated service, pinned the way `chant components redeploy` pins it. */
|
|
448
|
+
const gatedService = (): DriverComponent => {
|
|
449
|
+
const c = service("api");
|
|
450
|
+
c.deploy[2].steps.unshift({ kind: "gate", gate: "prod-release" });
|
|
451
|
+
return c;
|
|
452
|
+
};
|
|
453
|
+
const pinned = (digest: string): DriverComponent => {
|
|
454
|
+
const p = withoutBuildSteps(gatedService(), { pinDigest: digest, verb: "a redeploy" });
|
|
455
|
+
if ("error" in p) throw new Error(p.error);
|
|
456
|
+
return p.component;
|
|
457
|
+
};
|
|
458
|
+
const redeployOf = (records: ReleaseRecord[]): PromotionPlan => {
|
|
459
|
+
const p = planRedeploy({ env: "prod", records, declared: ["api"], component: "api" });
|
|
460
|
+
if ("error" in p) throw new Error(p.error);
|
|
461
|
+
return p;
|
|
462
|
+
};
|
|
463
|
+
/** A ledger port whose resolutions the test writes, as `chant approve` would. */
|
|
464
|
+
const ledger = () => {
|
|
465
|
+
const resolutions: GateResolutionRecord[] = [];
|
|
466
|
+
const memory = memoryGateLedgerPort();
|
|
467
|
+
const gates: GateLedgerPort = {
|
|
468
|
+
async read(op) {
|
|
469
|
+
const read = await memory.read(op);
|
|
470
|
+
return { ...read, resolutions: [...resolutions] };
|
|
471
|
+
},
|
|
472
|
+
appendPending: (input) => memory.appendPending(input),
|
|
473
|
+
};
|
|
474
|
+
return { resolutions, gates };
|
|
475
|
+
};
|
|
476
|
+
const approval = (bound: Partial<GateResolutionRecord>, timestamp: string): GateResolutionRecord => ({
|
|
477
|
+
version: 1,
|
|
478
|
+
kind: "resolution",
|
|
479
|
+
op: "api",
|
|
480
|
+
gate: "prod-release",
|
|
481
|
+
resolvedBy: "alice",
|
|
482
|
+
timestamp,
|
|
483
|
+
...bound,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("the pending fact records prod and the redeploy's plan, and an approval of it lets the same redeploy through", async () => {
|
|
487
|
+
const { resolutions, gates } = ledger();
|
|
488
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:unused" });
|
|
489
|
+
const plan = redeployOf([rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2")]);
|
|
490
|
+
const opts = { plan, components: [pinned("sha256:b")], registry, gates };
|
|
491
|
+
|
|
492
|
+
const first = await runPromotion({ ...opts, now: "2026-01-05T00:00:00Z" });
|
|
493
|
+
expect(first.status).toBe("gated");
|
|
494
|
+
expect(first.gate?.environment).toBe("prod");
|
|
495
|
+
expect(first.gate?.planDigest).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
496
|
+
expect(ran).toEqual([]);
|
|
497
|
+
|
|
498
|
+
resolutions.push(approval({ planDigest: first.gate!.planDigest!, environment: "prod" }, "2026-01-05T00:01:00Z"));
|
|
499
|
+
const second = await runPromotion({ ...opts, now: "2026-01-05T00:02:00Z" });
|
|
500
|
+
expect(second.status).toBe("ok");
|
|
501
|
+
expect(gateApprover(second.results[0])).toBe("alice");
|
|
502
|
+
expect(ran).toEqual(["prod:api:apply"]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("an approval for another environment, for a plain deploy, or from before #2574 does not pass it", async () => {
|
|
506
|
+
const { resolutions, gates } = ledger();
|
|
507
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:unused" });
|
|
508
|
+
const plan = redeployOf([rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2")]);
|
|
509
|
+
const opts = { plan, components: [pinned("sha256:b")], registry, gates };
|
|
510
|
+
const first = await runPromotion({ ...opts, now: "2026-01-05T00:00:00Z" });
|
|
511
|
+
const planned = first.gate!.planDigest!;
|
|
512
|
+
|
|
513
|
+
// The same plan approved for staging is not read at all in prod.
|
|
514
|
+
resolutions.push(approval({ planDigest: planned, environment: "staging" }, "2026-01-05T00:01:00Z"));
|
|
515
|
+
const staging = await runPromotion({ ...opts, now: "2026-01-05T00:02:00Z" });
|
|
516
|
+
expect(staging.status).toBe("gated");
|
|
517
|
+
expect(staging.gateMismatch).toBeUndefined();
|
|
518
|
+
|
|
519
|
+
// An approval of a plain deploy to prod is for another plan.
|
|
520
|
+
const deployPlan = componentPlanDigest({ environment: "prod", component: gatedService() });
|
|
521
|
+
resolutions.push(approval({ planDigest: deployPlan, environment: "prod" }, "2026-01-05T00:03:00Z"));
|
|
522
|
+
const plain = await runPromotion({ ...opts, now: "2026-01-05T00:04:00Z" });
|
|
523
|
+
expect(plain.status).toBe("gated");
|
|
524
|
+
expect(plain.gateMismatch).toMatchObject({ approved: deployPlan, planned, environment: "prod" });
|
|
525
|
+
|
|
526
|
+
// One recorded before #2574 names neither, and the refusal says how to approve again.
|
|
527
|
+
resolutions.push(approval({}, "2026-01-05T00:05:00Z"));
|
|
528
|
+
const old = await runPromotion({ ...opts, now: "2026-01-05T00:06:00Z" });
|
|
529
|
+
expect(old.status).toBe("gated");
|
|
530
|
+
expect(old.gateMismatch).toMatchObject({ planned, environment: "prod" });
|
|
531
|
+
expect(old.gateMismatch?.approved).toBeUndefined();
|
|
532
|
+
expect(describeGateMismatch("api", "prod-release", old.gateMismatch!)).toMatch(
|
|
533
|
+
/predates environment- and plan-bound component gates \(#2574\).*chant approve api prod-release --env prod$/,
|
|
534
|
+
);
|
|
535
|
+
expect(ran).toEqual([]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("an approval of one redeploy does not pass a redeploy of a release recorded since", async () => {
|
|
539
|
+
const { resolutions, gates } = ledger();
|
|
540
|
+
const { registry } = fakeRegistry({ api: "sha256:unused" });
|
|
541
|
+
const b = rec("api", "prod", "sha256:b", "2026-01-02T00:00:00Z", "run-2");
|
|
542
|
+
const first = await runPromotion({ plan: redeployOf([b]), components: [pinned("sha256:b")], registry, gates, now: "2026-01-05T00:00:00Z" });
|
|
543
|
+
const approved = first.gate!.planDigest!;
|
|
544
|
+
resolutions.push(approval({ planDigest: approved, environment: "prod" }, "2026-01-05T00:01:00Z"));
|
|
545
|
+
|
|
546
|
+
const c = rec("api", "prod", "sha256:c", "2026-01-06T00:00:00Z", "run-3");
|
|
547
|
+
const next = await runPromotion({ plan: redeployOf([b, c]), components: [pinned("sha256:c")], registry, gates, now: "2026-01-06T00:01:00Z" });
|
|
548
|
+
expect(next.status).toBe("gated");
|
|
549
|
+
expect(next.gateMismatch).toMatchObject({ approved, resolvedBy: "alice", environment: "prod" });
|
|
550
|
+
expect(next.gate?.planDigest).not.toBe(approved);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("a current release recorded with a legacy digest (#2514) binds the approval to that digest as recorded", async () => {
|
|
554
|
+
const legacy = `sha256:${"0a1b2c3d".repeat(8)}`;
|
|
555
|
+
const { resolutions, gates } = ledger();
|
|
556
|
+
const { registry, ran } = fakeRegistry({ api: "sha256:unused" });
|
|
557
|
+
const opts = {
|
|
558
|
+
plan: redeployOf([rec("api", "prod", legacy, "2026-01-02T00:00:00Z", "run-2")]),
|
|
559
|
+
components: [pinned(legacy)],
|
|
560
|
+
registry,
|
|
561
|
+
gates,
|
|
562
|
+
};
|
|
563
|
+
const first = await runPromotion({ ...opts, now: "2026-01-05T00:00:00Z" });
|
|
564
|
+
expect(first.status).toBe("gated");
|
|
565
|
+
const planned = first.gate!.planDigest!;
|
|
566
|
+
expect(planned).toBe(componentPlanDigest({ environment: "prod", component: pinned(legacy), release: legacy }));
|
|
567
|
+
expect(planned).not.toBe(legacy);
|
|
568
|
+
|
|
569
|
+
resolutions.push(approval({ planDigest: planned, environment: "prod" }, "2026-01-05T00:01:00Z"));
|
|
570
|
+
const second = await runPromotion({ ...opts, now: "2026-01-05T00:02:00Z" });
|
|
571
|
+
expect(second.status).toBe("ok");
|
|
572
|
+
expect(second.results[0].records.find((r) => r.kind === "apply")?.output).toEqual({ applied: legacy });
|
|
573
|
+
expect(ran).toEqual(["prod:api:apply"]);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
303
577
|
describe("pinning the publish step for a rollback", () => {
|
|
304
578
|
test("the publish step is replaced by the recorded digest", () => {
|
|
305
579
|
const p = withoutBuildSteps(service("api"), { pinDigest: "sha256:b" });
|