@intentius/chant 0.124.26 → 0.124.28
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/README.md +1 -1
- package/dist/__golden__/wave-loop/fan-out-gated.json +8 -0
- package/dist/__golden__/wave-loop/fan-out-no-members.json +4 -0
- package/dist/__golden__/wave-loop/fan-out-only.json +6 -0
- package/dist/__golden__/wave-loop/op-by-apply-hold.json +2 -0
- package/dist/__golden__/wave-loop/op-cost-member.json +3 -0
- package/dist/__golden__/wave-loop/op-held-on-before-apply.json +1 -0
- package/dist/__golden__/wave-loop/op-held-on.json +2 -0
- package/dist/__golden__/wave-loop/op-hooks-held.json +2 -0
- package/dist/__golden__/wave-loop/op-hooks-hold-before-apply.json +2 -0
- package/dist/__golden__/wave-loop/op-hooks-refused-at-base.json +3 -0
- package/dist/__golden__/wave-loop/op-hooks-share.json +3 -0
- package/dist/__golden__/wave-loop/op-per-run.json +7 -0
- package/dist/__golden__/wave-loop/op-pr-review.json +7 -0
- package/dist/__golden__/wave-loop/op-sealed.json +3 -0
- package/dist/__golden__/wave-loop/op-spend.json +3 -0
- package/dist/__golden__/wave-loop/op-wave-commands.json +7 -0
- package/dist/__golden__/wave-loop/op-wide-waiting.json +4 -0
- package/dist/change-set-graph.d.ts +56 -0
- package/dist/change-set-graph.d.ts.map +1 -0
- package/dist/change-set-graph.js +128 -0
- package/dist/change-set.d.ts +52 -3
- package/dist/change-set.d.ts.map +1 -1
- package/dist/change-set.js +35 -4
- package/dist/chant-version.d.ts +9 -0
- package/dist/chant-version.d.ts.map +1 -0
- package/dist/chant-version.js +16 -0
- package/dist/cli/commands/build.d.ts +8 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +9 -8
- package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-docs.js +30 -2
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.js +13 -2
- package/dist/cli/commands/init-lexicon/templates/docs.d.ts +4 -1
- package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/docs.js +6 -6
- package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon.js +17 -8
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +19 -4
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +28 -0
- package/dist/cli/handlers/change-set.d.ts +10 -0
- package/dist/cli/handlers/change-set.d.ts.map +1 -1
- package/dist/cli/handlers/change-set.js +51 -1
- package/dist/cli/handlers/fan-out.d.ts +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +14 -3
- package/dist/cli/handlers/pr.d.ts +8 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +148 -24
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +256 -21
- package/dist/cli/handlers/release.d.ts +34 -0
- package/dist/cli/handlers/release.d.ts.map +1 -0
- package/dist/cli/handlers/release.js +203 -0
- package/dist/cli/handlers/run.d.ts +28 -2
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +170 -13
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +88 -13
- package/dist/cli/mcp/workspace-tools.js +1 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +49 -3
- package/dist/cli/registry.d.ts +29 -3
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/run-events.d.ts +29 -0
- package/dist/cli/run-events.d.ts.map +1 -0
- package/dist/cli/run-events.js +47 -0
- package/dist/cli/version.d.ts +3 -5
- package/dist/cli/version.d.ts.map +1 -1
- package/dist/cli/version.js +3 -13
- package/dist/codegen/docs-in-site.d.ts +71 -0
- package/dist/codegen/docs-in-site.d.ts.map +1 -0
- package/dist/codegen/docs-in-site.js +177 -0
- package/dist/codegen/docs-types.d.ts +14 -1
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +18 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/docs.js +40 -7
- package/dist/codegen/rehype-base-url.mjs +15 -9
- package/dist/components/auto-release.d.ts +4 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +2 -0
- package/dist/components/cli-support.d.ts +6 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +10 -9
- package/dist/components/driver.d.ts +42 -9
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +81 -54
- package/dist/components/fan-out-run.d.ts +7 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +35 -26
- package/dist/components/pr-run.d.ts +11 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +20 -1
- package/dist/components/promote-order.d.ts +38 -0
- package/dist/components/promote-order.d.ts.map +1 -0
- package/dist/components/promote-order.js +62 -0
- package/dist/components/promote.d.ts +15 -2
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +17 -0
- package/dist/components/release-compile.d.ts +133 -0
- package/dist/components/release-compile.d.ts.map +1 -0
- package/dist/components/release-compile.js +376 -0
- package/dist/components/release-gate.d.ts +125 -0
- package/dist/components/release-gate.d.ts.map +1 -0
- package/dist/components/release-gate.js +282 -0
- package/dist/components/release-promote.d.ts +96 -0
- package/dist/components/release-promote.d.ts.map +1 -0
- package/dist/components/release-promote.js +204 -0
- package/dist/components/run-progress.d.ts +7 -0
- package/dist/components/run-progress.d.ts.map +1 -1
- package/dist/components/wave-plan.d.ts +3 -1
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/lifecycle/change-set-store.d.ts +67 -0
- package/dist/lifecycle/change-set-store.d.ts.map +1 -0
- package/dist/lifecycle/change-set-store.js +96 -0
- package/dist/lifecycle/commit-sha.d.ts +13 -0
- package/dist/lifecycle/commit-sha.d.ts.map +1 -0
- package/dist/lifecycle/commit-sha.js +21 -0
- package/dist/lifecycle/gate-change-set.d.ts +69 -0
- package/dist/lifecycle/gate-change-set.d.ts.map +1 -0
- package/dist/lifecycle/gate-change-set.js +106 -0
- package/dist/lifecycle/gate-ledger.d.ts +49 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +18 -0
- package/dist/lifecycle/release-cut.d.ts +107 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -0
- package/dist/lifecycle/release-cut.js +273 -0
- package/dist/lifecycle/release-ledger.d.ts +54 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +53 -20
- package/dist/lifecycle/release-store.d.ts +190 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -0
- package/dist/lifecycle/release-store.js +318 -0
- package/dist/lifecycle/run-ledger.d.ts +4 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +2 -0
- package/dist/op/activities/change-coverage.d.ts.map +1 -1
- package/dist/op/activities/change-coverage.js +4 -2
- package/dist/op/activities/source-release.d.ts.map +1 -1
- package/dist/op/activities/source-release.js +7 -3
- package/dist/op/activities/source-rollback.d.ts.map +1 -1
- package/dist/op/activities/source-rollback.js +7 -3
- package/dist/op/activity-registry.d.ts +6 -3
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.js +7 -5
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/discover.js +14 -7
- package/dist/op/gate-name.d.ts +11 -2
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-name.js +12 -3
- package/dist/op/gate-run-locator.d.ts +22 -0
- package/dist/op/gate-run-locator.d.ts.map +1 -1
- package/dist/op/gate-run-locator.js +31 -0
- package/dist/op/gate.d.ts +27 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +57 -3
- package/dist/op/local-executor.d.ts +36 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +158 -52
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +4 -0
- package/dist/op/run-live.d.ts +2 -0
- package/dist/op/run-live.d.ts.map +1 -1
- package/dist/op/run-live.js +1 -1
- package/dist/op/runtime.d.ts +46 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +12 -2
- package/dist/point-decision.d.ts +2 -0
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/point-decision.js +9 -2
- package/dist/run-event-store.d.ts +77 -0
- package/dist/run-event-store.d.ts.map +1 -0
- package/dist/run-event-store.js +204 -0
- package/dist/run-event-stream.d.ts +132 -0
- package/dist/run-event-stream.d.ts.map +1 -0
- package/dist/run-event-stream.js +238 -0
- package/dist/run-event.d.ts +267 -0
- package/dist/run-event.d.ts.map +1 -0
- package/dist/run-event.js +207 -0
- package/dist/wave-runtime.mjs +225 -242
- package/dist/workspace/__fixtures__/run-event-check.d.ts +19 -0
- package/dist/workspace/__fixtures__/run-event-check.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/run-event-check.js +59 -0
- package/dist/workspace/change-set-show.d.ts +56 -0
- package/dist/workspace/change-set-show.d.ts.map +1 -0
- package/dist/workspace/change-set-show.js +54 -0
- package/dist/workspace/change-set-show.schema.json +102 -0
- package/dist/workspace/change-set.schema.json +28 -1
- package/dist/workspace/changes-cli.d.ts.map +1 -1
- package/dist/workspace/changes-cli.js +10 -1
- package/dist/workspace/changes.d.ts +37 -0
- package/dist/workspace/changes.d.ts.map +1 -1
- package/dist/workspace/changes.js +49 -2
- package/dist/workspace/changes.schema.json +135 -0
- package/dist/workspace/ci-green-cli.d.ts +19 -4
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +75 -12
- package/dist/workspace/ci-green-forge.d.ts +20 -3
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +6 -4
- package/dist/workspace/ci-green-workflow.d.ts +11 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +6 -0
- package/dist/workspace/ci-green.d.ts +273 -13
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +449 -38
- package/dist/workspace/ci-last-green.schema.json +32 -2
- package/dist/workspace/ci-list.schema.json +525 -0
- package/dist/workspace/commit-links.d.ts +85 -0
- package/dist/workspace/commit-links.d.ts.map +1 -0
- package/dist/workspace/commit-links.js +163 -0
- package/dist/workspace/declaration.d.ts +19 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +1 -1
- package/dist/workspace/declaration.schema.json +12 -0
- package/dist/workspace/identity-gate-rule.d.ts +37 -0
- package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
- package/dist/workspace/identity-gate-rule.js +72 -7
- package/dist/workspace/identity.d.ts +12 -0
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/identity.js +13 -1
- package/dist/workspace/intent.d.ts +6 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/intent.js +51 -1
- package/dist/workspace/ls.d.ts +4 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +2 -0
- package/dist/workspace/ls.schema.json +3 -1
- package/dist/workspace/member-pipeline.d.ts +6 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +6 -0
- package/dist/workspace/promotion-order.d.ts +41 -0
- package/dist/workspace/promotion-order.d.ts.map +1 -0
- package/dist/workspace/promotion-order.js +95 -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 +4 -1
- package/dist/workspace/releases.d.ts +72 -0
- package/dist/workspace/releases.d.ts.map +1 -0
- package/dist/workspace/releases.js +94 -0
- package/dist/workspace/releases.schema.json +391 -0
- package/dist/workspace/run-event.schema.json +288 -0
- package/dist/workspace/status-gates.d.ts +26 -1
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status-gates.js +31 -2
- package/dist/workspace/status-stewards.d.ts +4 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status-stewards.js +2 -0
- package/dist/workspace/status.d.ts +39 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +48 -1
- package/dist/workspace/status.schema.json +70 -1
- package/dist/workspace/trust/ci-tag-statement.d.ts +102 -0
- package/dist/workspace/trust/ci-tag-statement.d.ts.map +1 -0
- package/dist/workspace/trust/ci-tag-statement.js +123 -0
- package/dist/workspace/trust/op-run-statement.d.ts +91 -0
- package/dist/workspace/trust/op-run-statement.d.ts.map +1 -0
- package/dist/workspace/trust/op-run-statement.js +143 -0
- package/dist/workspace/trust/rotation.d.ts +18 -2
- package/dist/workspace/trust/rotation.d.ts.map +1 -1
- package/dist/workspace/trust/rotation.js +65 -16
- package/dist/workspace/trust/seal.d.ts +11 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +9 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/trust/ssh-commit.js +10 -10
- package/package.json +1 -1
- package/src/__golden__/wave-loop/fan-out-gated.json +8 -0
- package/src/__golden__/wave-loop/fan-out-no-members.json +4 -0
- package/src/__golden__/wave-loop/fan-out-only.json +6 -0
- package/src/__golden__/wave-loop/op-by-apply-hold.json +2 -0
- package/src/__golden__/wave-loop/op-cost-member.json +3 -0
- package/src/__golden__/wave-loop/op-held-on-before-apply.json +1 -0
- package/src/__golden__/wave-loop/op-held-on.json +2 -0
- package/src/__golden__/wave-loop/op-hooks-held.json +2 -0
- package/src/__golden__/wave-loop/op-hooks-hold-before-apply.json +2 -0
- package/src/__golden__/wave-loop/op-hooks-refused-at-base.json +3 -0
- package/src/__golden__/wave-loop/op-hooks-share.json +3 -0
- package/src/__golden__/wave-loop/op-per-run.json +7 -0
- package/src/__golden__/wave-loop/op-pr-review.json +7 -0
- package/src/__golden__/wave-loop/op-sealed.json +3 -0
- package/src/__golden__/wave-loop/op-spend.json +3 -0
- package/src/__golden__/wave-loop/op-wave-commands.json +7 -0
- package/src/__golden__/wave-loop/op-wide-waiting.json +4 -0
- package/src/change-set-graph.test.ts +72 -0
- package/src/change-set-graph.ts +133 -0
- package/src/change-set.ts +76 -6
- package/src/chant-version.ts +16 -0
- package/src/cli/commands/build.ts +19 -8
- package/src/cli/commands/check-lexicon-docs.test.ts +51 -0
- package/src/cli/commands/check-lexicon-docs.ts +39 -2
- package/src/cli/commands/check-lexicon.ts +14 -2
- package/src/cli/commands/init-lexicon/templates/docs.ts +7 -6
- package/src/cli/commands/init-lexicon.test.ts +25 -0
- package/src/cli/commands/init-lexicon.ts +18 -8
- package/src/cli/compiled-cli.e2e.test.ts +31 -6
- package/src/cli/handlers/approve-release.test.ts +80 -0
- package/src/cli/handlers/approve.ts +18 -2
- package/src/cli/handlers/build.test.ts +52 -0
- package/src/cli/handlers/build.ts +29 -0
- package/src/cli/handlers/change-set.ts +49 -1
- package/src/cli/handlers/fan-out.test.ts +48 -0
- package/src/cli/handlers/fan-out.ts +15 -3
- package/src/cli/handlers/operator.test.ts +37 -0
- package/src/cli/handlers/pr.test.ts +194 -0
- package/src/cli/handlers/pr.ts +168 -34
- package/src/cli/handlers/promote.test.ts +389 -5
- package/src/cli/handlers/promote.ts +279 -20
- package/src/cli/handlers/release-implicit.e2e.test.ts +153 -0
- package/src/cli/handlers/release.e2e.test.ts +85 -0
- package/src/cli/handlers/release.ts +206 -0
- package/src/cli/handlers/run-auto-release.test.ts +71 -0
- package/src/cli/handlers/run-events-cmd.test.ts +92 -0
- package/src/cli/handlers/run.test.ts +30 -0
- package/src/cli/handlers/run.ts +176 -15
- package/src/cli/main.test.ts +7 -0
- package/src/cli/main.ts +88 -13
- package/src/cli/mcp/never-over-mcp.test.ts +2 -0
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/cli/parse-args.ts +37 -3
- package/src/cli/registry.ts +29 -3
- package/src/cli/run-events.ts +54 -0
- package/src/cli/version.ts +3 -13
- package/src/codegen/docs-in-site.test.ts +166 -0
- package/src/codegen/docs-in-site.ts +182 -0
- package/src/codegen/docs-site/AgentPrompt.astro +26 -1
- package/src/codegen/docs-types.ts +14 -1
- package/src/codegen/docs.ts +60 -7
- package/src/codegen/rehype-base-url.d.mts +3 -1
- package/src/codegen/rehype-base-url.mjs +15 -9
- package/src/codegen/rehype-base-url.test.ts +34 -0
- package/src/components/auto-release.test.ts +21 -0
- package/src/components/auto-release.ts +6 -1
- package/src/components/cli-support.test.ts +82 -0
- package/src/components/cli-support.ts +16 -9
- package/src/components/driver.ts +129 -56
- package/src/components/fan-out-run.ts +56 -35
- package/src/components/pr-run.ts +30 -1
- package/src/components/promote-order.test.ts +90 -0
- package/src/components/promote-order.ts +75 -0
- package/src/components/promote.test.ts +10 -1
- package/src/components/promote.ts +26 -1
- package/src/components/release-compile.test.ts +147 -0
- package/src/components/release-compile.ts +445 -0
- package/src/components/release-gate.test.ts +215 -0
- package/src/components/release-gate.ts +341 -0
- package/src/components/release-promote.test.ts +159 -0
- package/src/components/release-promote.ts +232 -0
- package/src/components/run-progress.ts +7 -0
- package/src/components/wave-plan.ts +1 -1
- package/src/config.ts +7 -1
- package/src/index.ts +2 -0
- package/src/lifecycle/change-set-store.test.ts +217 -0
- package/src/lifecycle/change-set-store.ts +112 -0
- package/src/lifecycle/commit-sha.ts +23 -0
- package/src/lifecycle/gate-change-set.e2e.test.ts +122 -0
- package/src/lifecycle/gate-change-set.ts +133 -0
- package/src/lifecycle/gate-ledger.test.ts +33 -0
- package/src/lifecycle/gate-ledger.ts +66 -1
- package/src/lifecycle/release-cut-signed.test.ts +180 -0
- package/src/lifecycle/release-cut.test.ts +411 -0
- package/src/lifecycle/release-cut.ts +367 -0
- package/src/lifecycle/release-ledger.test.ts +14 -0
- package/src/lifecycle/release-ledger.ts +85 -17
- package/src/lifecycle/release-store.ts +423 -0
- package/src/lifecycle/run-ledger.test.ts +10 -0
- package/src/lifecycle/run-ledger.ts +6 -0
- package/src/op/activities/change-coverage.ts +4 -1
- package/src/op/activities/source-release.ts +8 -3
- package/src/op/activities/source-rollback.ts +8 -4
- package/src/op/activity-registry.ts +7 -5
- package/src/op/commit-run-gate-deploy-join.test.ts +117 -0
- package/src/op/discover-root.test.ts +39 -0
- package/src/op/discover.ts +15 -8
- package/src/op/gate-name.ts +13 -3
- package/src/op/gate-release.test.ts +126 -0
- package/src/op/gate-run-locator.test.ts +110 -0
- package/src/op/gate-run-locator.ts +35 -0
- package/src/op/gate.ts +88 -6
- package/src/op/local-executor.ts +207 -52
- package/src/op/op-waves-run.ts +4 -0
- package/src/op/run-events.test.ts +166 -0
- package/src/op/run-live.ts +1 -1
- package/src/op/runtime.ts +39 -0
- package/src/op/runtimes/local.test.ts +16 -0
- package/src/op/runtimes/local.ts +13 -2
- package/src/point-decision.ts +12 -2
- package/src/run-event-store.test.ts +144 -0
- package/src/run-event-store.ts +213 -0
- package/src/run-event-stream.test.ts +175 -0
- package/src/run-event-stream.ts +306 -0
- package/src/run-event.test.ts +277 -0
- package/src/run-event.ts +451 -0
- package/src/wave-loop-golden.test.ts +9 -0
- package/src/workspace/__fixtures__/run-event-check.ts +58 -0
- package/src/workspace/ancestry-index.test.ts +93 -0
- package/src/workspace/change-set-show.schema.json +102 -0
- package/src/workspace/change-set-show.ts +72 -0
- package/src/workspace/change-set.schema.json +28 -1
- package/src/workspace/changes-cli.ts +10 -1
- package/src/workspace/changes.schema.json +135 -0
- package/src/workspace/changes.test.ts +1 -1
- package/src/workspace/changes.ts +84 -2
- package/src/workspace/ci-green-cli.ts +75 -13
- package/src/workspace/ci-green-forge.ts +24 -4
- package/src/workspace/ci-green-runs.test.ts +242 -0
- package/src/workspace/ci-green-signed.test.ts +302 -0
- package/src/workspace/ci-green-workflow.test.ts +16 -0
- package/src/workspace/ci-green-workflow.ts +12 -0
- package/src/workspace/ci-green.test.ts +131 -2
- package/src/workspace/ci-green.ts +624 -42
- package/src/workspace/ci-last-green.schema.json +32 -2
- package/src/workspace/ci-list.schema.json +525 -0
- package/src/workspace/commit-links.test.ts +279 -0
- package/src/workspace/commit-links.ts +183 -0
- package/src/workspace/declaration.schema.json +12 -0
- package/src/workspace/declaration.ts +22 -2
- package/src/workspace/identity-gate-rule.ts +91 -7
- package/src/workspace/identity.ts +14 -1
- package/src/workspace/intent.ts +48 -1
- package/src/workspace/ls.schema.json +3 -1
- package/src/workspace/ls.ts +6 -0
- package/src/workspace/member-pipeline.test.ts +17 -0
- package/src/workspace/member-pipeline.ts +9 -0
- package/src/workspace/promotion-order.ts +97 -0
- package/src/workspace/read-contract.e2e.test.ts +4 -1
- package/src/workspace/reason-codes.test.ts +2 -1
- package/src/workspace/reason-codes.ts +4 -1
- package/src/workspace/releases.schema.json +391 -0
- package/src/workspace/releases.test.ts +108 -0
- package/src/workspace/releases.ts +122 -0
- package/src/workspace/run-event.schema.json +288 -0
- package/src/workspace/status-contract.test.ts +52 -1
- package/src/workspace/status-gates.ts +56 -1
- package/src/workspace/status-stewards.ts +6 -0
- package/src/workspace/status.schema.json +70 -1
- package/src/workspace/status.ts +80 -1
- package/src/workspace/trust/ci-tag-statement.ts +151 -0
- package/src/workspace/trust/op-run-statement.ts +148 -0
- package/src/workspace/trust/rotation.ts +61 -14
- package/src/workspace/trust/seal.ts +10 -0
- package/src/workspace/trust/ssh-commit.ts +10 -9
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -14
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/package.json +0 -16
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/index.mdx +0 -8
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +0 -15
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/tsconfig.json +0 -10
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4031: signed ci tags. With `ci.green.signedSince` declared, a green or
|
|
3
|
+
* revoked tag on a commit made since counts only when its annotation
|
|
4
|
+
* carries a statement signed by a runner key `.chant/trust.json` lists for
|
|
5
|
+
* a principal holding the `runner` role. The tick signs what it makes;
|
|
6
|
+
* `ci tick`, `ci list`, `ci last-green` and the release cut ignore and
|
|
7
|
+
* report a hand-pushed tag. A tag on an older commit counts unsigned.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
|
|
12
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
13
|
+
import { cleanScratch, contract, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
14
|
+
import { ciList, ciTick, formatList, formatTick, greenAnnotation, lastGreen, revokedAnnotation, evaluateCommit, type LastGreenDocument } from "./ci-green";
|
|
15
|
+
import type { CheckRun, CiForge } from "./ci-green-forge";
|
|
16
|
+
import type { CiGreen } from "./declaration";
|
|
17
|
+
import { sshFromEd25519 } from "./trust/dsse";
|
|
18
|
+
import { verifyCiTagStatement } from "./trust/ci-tag-statement";
|
|
19
|
+
import lastGreenSchema from "./ci-last-green.schema.json";
|
|
20
|
+
import listSchema from "./ci-list.schema.json";
|
|
21
|
+
|
|
22
|
+
afterAll(cleanScratch);
|
|
23
|
+
|
|
24
|
+
const NOW = new Date("2026-10-06T12:00:00Z");
|
|
25
|
+
const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
|
|
26
|
+
const GREEN = { branch: "main", phases: { lint: ["lint"], test: ["test"] }, require: ["lint", "test"] };
|
|
27
|
+
const run = (id: number, name: string, conclusion: string): CheckRun => ({ id, name, status: "completed", conclusion, completedAt: null, url: null });
|
|
28
|
+
const PASSING = [run(1, "lint", "success"), run(2, "test", "success")];
|
|
29
|
+
const FAILING = [run(1, "lint", "success"), run(9, "test", "failure")];
|
|
30
|
+
|
|
31
|
+
function fakeForge(runs: Map<string, CheckRun[]>): CiForge {
|
|
32
|
+
return { kind: "github", checkRuns: async (sha) => runs.get(sha) ?? [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A throwaway runner key: its PEM and its ssh public key line. */
|
|
36
|
+
function runnerKey(): { pem: string; ssh: string } {
|
|
37
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
38
|
+
const jwk = createPublicKey(privateKey).export({ format: "jwk" }) as { x: string };
|
|
39
|
+
return { pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), ssh: sshFromEd25519(Buffer.from(jwk.x, "base64url")) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function gitAt(cwd: string, date: string, ...args: string[]): string {
|
|
43
|
+
return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", ...args], {
|
|
44
|
+
cwd,
|
|
45
|
+
encoding: "utf-8",
|
|
46
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
47
|
+
env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
|
|
48
|
+
}).trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function commitAt(root: string, hours: number, file: string): string {
|
|
52
|
+
writeFiles(root, { [file]: `${file} ${hours}\n` });
|
|
53
|
+
gitAt(root, hoursAgo(hours), "add", "-A");
|
|
54
|
+
gitAt(root, hoursAgo(hours), "commit", "-q", "-m", file);
|
|
55
|
+
return git(root, "rev-parse", "HEAD");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const BOT = runnerKey();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A checkout on main with a bare origin, declaring `ci.green` and listing
|
|
62
|
+
* BOT as the runner `ci-bot`, which holds the runner role unless `roles`
|
|
63
|
+
* says otherwise. Unless `unsigned`, history then reads: an init commit 23
|
|
64
|
+
* hours ago, whatever `before` commits, a pivot commit 15 hours ago, and a
|
|
65
|
+
* commit 14 hours ago that sets `signedSince` to the pivot. Tags on the
|
|
66
|
+
* pivot's strict first-parent ancestors count unsigned; tags on the pivot
|
|
67
|
+
* and everything after it need a statement.
|
|
68
|
+
*/
|
|
69
|
+
function workspace(opts: { roles?: Record<string, string[]>; unsigned?: boolean; before?: (root: string) => void } = {}): { root: string; origin: string; pivot: string | null } {
|
|
70
|
+
const origin = scratchDir("chant-ci-signed-origin-");
|
|
71
|
+
git(origin, "init", "-q", "--bare");
|
|
72
|
+
const root = scratchDir("chant-ci-signed-");
|
|
73
|
+
git(root, "init", "-q");
|
|
74
|
+
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
75
|
+
const decl = (green: unknown) => declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green } });
|
|
76
|
+
writeFiles(root, {
|
|
77
|
+
"chant.workspace.json": decl(GREEN),
|
|
78
|
+
".chant/trust.json": JSON.stringify({ schema: 1, runners: [{ principal: "ci-bot", class: "runner", key: BOT.ssh }], roles: opts.roles ?? { runner: ["ci-bot"] } }),
|
|
79
|
+
"app/a.txt": "a\n",
|
|
80
|
+
});
|
|
81
|
+
git(root, "remote", "add", "origin", origin);
|
|
82
|
+
commitAt(root, 23, "app/init.txt");
|
|
83
|
+
if (opts.unsigned) return { root, origin, pivot: null };
|
|
84
|
+
opts.before?.(root);
|
|
85
|
+
const pivot = commitAt(root, 15, "app/pivot.txt");
|
|
86
|
+
writeFiles(root, { "chant.workspace.json": decl({ ...GREEN, signedSince: pivot }) });
|
|
87
|
+
gitAt(root, hoursAgo(14), "commit", "-q", "-am", "require signed ci tags from the pivot on");
|
|
88
|
+
return { root, origin, pivot };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The listed commits among `shas`, in list order. */
|
|
92
|
+
const only = <T extends { sha: string }>(commits: T[], ...shas: string[]) => commits.filter((c) => shas.includes(c.sha));
|
|
93
|
+
|
|
94
|
+
/** Push a tag made by hand, the way someone with push rights could go around CI. */
|
|
95
|
+
function handTag(root: string, tag: string, sha: string, message: string, hours = 0): void {
|
|
96
|
+
gitAt(root, hoursAgo(hours), "tag", "-a", "-m", message, tag, sha);
|
|
97
|
+
git(root, "push", "-q", "origin", `refs/tags/${tag}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const greenOf = (sha: string) => greenAnnotation(GREEN_DECL, sha, evaluateCommit(GREEN_DECL, PASSING), NOW);
|
|
101
|
+
const GREEN_DECL: CiGreen = {
|
|
102
|
+
branch: "main",
|
|
103
|
+
window: "24h",
|
|
104
|
+
phases: [
|
|
105
|
+
{ name: "lint", runs: ["lint"], skipped: "fail", pointer: "" },
|
|
106
|
+
{ name: "test", runs: ["test"], skipped: "fail", pointer: "" },
|
|
107
|
+
],
|
|
108
|
+
require: ["lint", "test"],
|
|
109
|
+
signedSince: null,
|
|
110
|
+
source: "checks",
|
|
111
|
+
pointer: "/ci/green",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
describe("signed ci tags (#4031)", () => {
|
|
115
|
+
const readLast = contract(lastGreenSchema);
|
|
116
|
+
const readList = contract(listSchema);
|
|
117
|
+
|
|
118
|
+
test("a hand-pushed green tag is ignored and reported by ci tick, ci list and ci last-green", async () => {
|
|
119
|
+
const { root } = workspace();
|
|
120
|
+
const a = commitAt(root, 5, "app/a.txt");
|
|
121
|
+
const b = commitAt(root, 3, "app/b.txt");
|
|
122
|
+
git(root, "push", "-q", "origin", "main");
|
|
123
|
+
// b never passed, and someone pushes a green tag for it by hand, with the record a tick would write.
|
|
124
|
+
handTag(root, `ci/green/${b}`, b, greenOf(b));
|
|
125
|
+
const runs = new Map([
|
|
126
|
+
[a, PASSING],
|
|
127
|
+
[b, FAILING],
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const tick = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW, runnerKey: BOT.pem });
|
|
131
|
+
expect(tick.made).toEqual([`ci/green/${a}`]);
|
|
132
|
+
expect(tick.signer).toBe("ci-bot");
|
|
133
|
+
const atB = tick.commits.find((c) => c.sha === b)!;
|
|
134
|
+
expect(atB.before).toBe("none");
|
|
135
|
+
expect(atB.ignored).toEqual([{ tag: `ci/green/${b}`, status: "unsigned", reason: expect.stringContaining("carries no statement signed by a runner key") }]);
|
|
136
|
+
expect(formatTick(tick)).toContain(`ignored ci/green/${b} (unsigned)`);
|
|
137
|
+
|
|
138
|
+
// The tick's own tag carries a statement that verifies for ci-bot.
|
|
139
|
+
const note = git(root, "tag", "-l", "--format=%(contents)", `ci/green/${a}`);
|
|
140
|
+
const record = JSON.parse(note.slice(note.indexOf("{")));
|
|
141
|
+
expect(record.statement.signatures).toHaveLength(1);
|
|
142
|
+
expect(verifyCiTagStatement(record, { sha: a, kind: "green", branch: "main" }, [{ principal: "ci-bot", class: "runner", key: BOT.ssh }], { runner: ["ci-bot"] })).toMatchObject({ status: "signed", signer: "ci-bot" });
|
|
143
|
+
|
|
144
|
+
const last = lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>;
|
|
145
|
+
readLast.expectValid(last);
|
|
146
|
+
expect(last.commit?.sha).toBe(a);
|
|
147
|
+
expect(last.ignored.map((t) => [t.tag, t.status])).toEqual([[`ci/green/${b}`, "unsigned"]]);
|
|
148
|
+
|
|
149
|
+
const list = await ciList({ cwd: root, now: NOW });
|
|
150
|
+
readList.expectValid(list);
|
|
151
|
+
const listed = (list as Extract<typeof list, { commits: unknown }>).commits;
|
|
152
|
+
expect(only(listed, a, b).map((c) => [c.sha, c.state, c.green?.signer ?? null, c.ignored.map((t) => t.status)])).toEqual([
|
|
153
|
+
[b, "undecided", null, ["unsigned"]],
|
|
154
|
+
[a, "green", "ci-bot", []],
|
|
155
|
+
]);
|
|
156
|
+
expect(formatList(list as Extract<typeof list, { commits: unknown }>)).toContain(`ignored ci/green/${b} (unsigned)`);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a tag on a strict first-parent ancestor of ci.green.signedSince is accepted unsigned", async () => {
|
|
160
|
+
let old = "";
|
|
161
|
+
const { root, pivot } = workspace({ before: (r) => void (old = commitAt(r, 20, "app/old.txt")) });
|
|
162
|
+
const fresh = commitAt(root, 2, "app/fresh.txt");
|
|
163
|
+
git(root, "push", "-q", "origin", "main");
|
|
164
|
+
// A tag made the way every tag was made before #4031: no statement.
|
|
165
|
+
handTag(root, `ci/green/${old}`, old, greenOf(old), 19);
|
|
166
|
+
|
|
167
|
+
const last = lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>;
|
|
168
|
+
expect(last.commit?.sha).toBe(old);
|
|
169
|
+
expect(last.ignored).toEqual([]);
|
|
170
|
+
const list = (await ciList({ cwd: root, now: NOW })) as Extract<Awaited<ReturnType<typeof ciList>>, { commits: unknown }>;
|
|
171
|
+
expect(only(list.commits, fresh, pivot!, old).map((c) => [c.sha, c.state, c.green?.signer ?? null])).toEqual([
|
|
172
|
+
[fresh, "undecided", null],
|
|
173
|
+
[pivot, "undecided", null],
|
|
174
|
+
[old, "green", null],
|
|
175
|
+
]);
|
|
176
|
+
// The pivot itself is not a strict ancestor: its unsigned tag is ignored.
|
|
177
|
+
handTag(root, `ci/green/${pivot}`, pivot!, greenOf(pivot!), 14);
|
|
178
|
+
const after = lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>;
|
|
179
|
+
expect(after.commit?.sha).toBe(old);
|
|
180
|
+
expect(after.ignored.map((t) => [t.tag, t.status])).toEqual([[`ci/green/${pivot}`, "unsigned"]]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a backdated commit outside the ancestry, with an unsigned tag, is ignored", async () => {
|
|
184
|
+
let old = "";
|
|
185
|
+
const { root } = workspace({ before: (r) => void (old = commitAt(r, 20, "app/old.txt")) });
|
|
186
|
+
handTag(root, `ci/green/${old}`, old, greenOf(old), 19);
|
|
187
|
+
// Committed after signedSince, but with its author and committer times set 40 hours back, before every other commit.
|
|
188
|
+
const backdated = commitAt(root, 40, "app/backdated.txt");
|
|
189
|
+
git(root, "push", "-q", "origin", "main");
|
|
190
|
+
handTag(root, `ci/green/${backdated}`, backdated, greenOf(backdated), 40);
|
|
191
|
+
|
|
192
|
+
const last = lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>;
|
|
193
|
+
expect(last.commit?.sha).toBe(old);
|
|
194
|
+
expect(last.ignored).toEqual([{ tag: `ci/green/${backdated}`, status: "unsigned", reason: expect.stringContaining("not a strict first-parent ancestor") }]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a signedSince that names no commit here is refused as declaration-invalid", async () => {
|
|
198
|
+
const { root } = workspace({ unsigned: true });
|
|
199
|
+
writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: { ...GREEN, signedSince: "f".repeat(40) } } }) });
|
|
200
|
+
expect(lastGreen({ cwd: root })).toMatchObject({ error: { code: "declaration-invalid", message: expect.stringContaining("is not a commit in this repository") } });
|
|
201
|
+
expect(await ciList({ cwd: root, now: NOW })).toMatchObject({ error: { code: "declaration-invalid" } });
|
|
202
|
+
git(root, "push", "-q", "origin", "main");
|
|
203
|
+
await expect(ciTick({ cwd: root, forge: fakeForge(new Map()), now: NOW, runnerKey: BOT.pem })).rejects.toThrow(/signedSince names f{40}, which is not a commit/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a hand-pushed revoked tag without a valid statement is ignored and reported, and the tick's own revocation is signed", async () => {
|
|
207
|
+
const { root } = workspace();
|
|
208
|
+
const a = commitAt(root, 4, "app/a.txt");
|
|
209
|
+
git(root, "push", "-q", "origin", "main");
|
|
210
|
+
const runs = new Map([[a, PASSING]]);
|
|
211
|
+
await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW, runnerKey: BOT.pem });
|
|
212
|
+
|
|
213
|
+
// Someone revokes the green commit by hand.
|
|
214
|
+
handTag(root, `ci/revoked/${a}`, a, revokedAnnotation(GREEN_DECL, a, { phase: "test", run: { name: "test", id: 9, status: "completed", conclusion: "failure", url: null, verdict: "fail" } }, NOW));
|
|
215
|
+
const last = lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>;
|
|
216
|
+
expect(last.commit?.sha).toBe(a);
|
|
217
|
+
expect(last.ignored.map((t) => [t.tag, t.status])).toEqual([[`ci/revoked/${a}`, "unsigned"]]);
|
|
218
|
+
const list = (await ciList({ cwd: root, now: NOW })) as Extract<Awaited<ReturnType<typeof ciList>>, { commits: unknown }>;
|
|
219
|
+
expect(only(list.commits, a)[0]).toMatchObject({ sha: a, state: "green", revoked: null, ignored: [{ tag: `ci/revoked/${a}`, status: "unsigned" }] });
|
|
220
|
+
|
|
221
|
+
// When a really goes red, the tick can't revoke it while the hand-made tag holds the name, and says so.
|
|
222
|
+
runs.set(a, FAILING);
|
|
223
|
+
const blocked = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW, runnerKey: BOT.pem });
|
|
224
|
+
expect(blocked.made).toEqual([]);
|
|
225
|
+
expect(blocked.commits[0]).toMatchObject({ sha: a, before: "green", blocked: `ci/revoked/${a}` });
|
|
226
|
+
expect(formatTick(blocked)).toContain(`did not make ci/revoked/${a}: an ignored tag holds the name`);
|
|
227
|
+
|
|
228
|
+
// Once a person deletes it, the tick revokes a with a signed tag, which counts.
|
|
229
|
+
git(root, "push", "-q", "origin", `:refs/tags/ci/revoked/${a}`);
|
|
230
|
+
const revoked = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW, runnerKey: BOT.pem });
|
|
231
|
+
expect(revoked.made).toEqual([`ci/revoked/${a}`]);
|
|
232
|
+
const why = git(root, "tag", "-l", "--format=%(contents)", `ci/revoked/${a}`);
|
|
233
|
+
expect(JSON.parse(why.slice(why.indexOf("{"))).statement).toBeDefined();
|
|
234
|
+
expect((lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>).commit).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("a statement by another key, by a runner without the role, or for another commit does not count", async () => {
|
|
238
|
+
const { root } = workspace({ roles: { runner: ["ci-bot"] } });
|
|
239
|
+
const a = commitAt(root, 5, "app/a.txt");
|
|
240
|
+
const b = commitAt(root, 4, "app/b.txt");
|
|
241
|
+
const c = commitAt(root, 3, "app/c.txt");
|
|
242
|
+
git(root, "push", "-q", "origin", "main");
|
|
243
|
+
const mallory = runnerKey();
|
|
244
|
+
const signer = (key: { pem: string; ssh: string }, principal: string) => ({ principal, key: createPrivateKey(key.pem), publicKey: key.ssh });
|
|
245
|
+
// a: signed by a key trust.json does not list.
|
|
246
|
+
handTag(root, `ci/green/${a}`, a, greenAnnotation(GREEN_DECL, a, evaluateCommit(GREEN_DECL, PASSING), NOW, signer(mallory, "ci-bot")));
|
|
247
|
+
// b: ci-bot's real signature over c's record, copied onto b's tag.
|
|
248
|
+
const forC = greenAnnotation(GREEN_DECL, c, evaluateCommit(GREEN_DECL, PASSING), NOW, signer(BOT, "ci-bot"));
|
|
249
|
+
handTag(root, `ci/green/${b}`, b, forC);
|
|
250
|
+
const list = (await ciList({ cwd: root, now: NOW })) as Extract<Awaited<ReturnType<typeof ciList>>, { commits: unknown }>;
|
|
251
|
+
expect(only(list.commits, a, b, c).map((x) => [x.sha, x.state, x.ignored.map((t) => t.status)])).toEqual([
|
|
252
|
+
[c, "undecided", []],
|
|
253
|
+
[b, "undecided", ["mismatch"]],
|
|
254
|
+
[a, "undecided", ["untrusted"]],
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
// A runner the trust file lists but grants no runner role is not trusted either.
|
|
258
|
+
const unroled = workspace({ roles: {} });
|
|
259
|
+
const d = commitAt(unroled.root, 2, "app/d.txt");
|
|
260
|
+
git(unroled.root, "push", "-q", "origin", "main");
|
|
261
|
+
handTag(unroled.root, `ci/green/${d}`, d, greenAnnotation(GREEN_DECL, d, evaluateCommit(GREEN_DECL, PASSING), NOW, signer(BOT, "ci-bot")));
|
|
262
|
+
const last = lastGreen({ cwd: unroled.root }) as Extract<LastGreenDocument, { branch: string }>;
|
|
263
|
+
expect(last.commit).toBeNull();
|
|
264
|
+
expect(last.ignored[0]).toMatchObject({ status: "untrusted", reason: expect.stringContaining("holds no runner role") });
|
|
265
|
+
// And the tick refuses to sign with it, before it tags anything.
|
|
266
|
+
await expect(ciTick({ cwd: unroled.root, forge: fakeForge(new Map([[d, PASSING]])), now: NOW, runnerKey: BOT.pem })).rejects.toThrow(/holds no runner role/);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("the tick needs a runner key once signedSince is set, and refuses one trust.json does not list", async () => {
|
|
270
|
+
const { root } = workspace();
|
|
271
|
+
const a = commitAt(root, 2, "app/a.txt");
|
|
272
|
+
git(root, "push", "-q", "origin", "main");
|
|
273
|
+
const forge = fakeForge(new Map([[a, PASSING]]));
|
|
274
|
+
await expect(ciTick({ cwd: root, forge, now: NOW })).rejects.toThrow(/needs a statement signed by a runner key, and none was given/);
|
|
275
|
+
await expect(ciTick({ cwd: root, forge, now: NOW, runnerKey: runnerKey().pem })).rejects.toThrow(/lists no runner with this key/);
|
|
276
|
+
expect(git(root, "tag", "-l")).toBe("");
|
|
277
|
+
// A dry run decides without a key.
|
|
278
|
+
expect((await ciTick({ cwd: root, forge, now: NOW, dryRun: true })).made).toEqual([`ci/green/${a}`]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("without signedSince every tag counts, as before", async () => {
|
|
282
|
+
const { root } = workspace({ unsigned: true });
|
|
283
|
+
const a = commitAt(root, 2, "app/a.txt");
|
|
284
|
+
git(root, "push", "-q", "origin", "main");
|
|
285
|
+
git(root, "tag", `ci/green/${a}`, a);
|
|
286
|
+
const last = lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>;
|
|
287
|
+
expect(last.commit?.sha).toBe(a);
|
|
288
|
+
expect(last.ignored).toEqual([]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("last-green --from walks from another revision", async () => {
|
|
292
|
+
const { root } = workspace();
|
|
293
|
+
const a = commitAt(root, 4, "app/a.txt");
|
|
294
|
+
const b = commitAt(root, 3, "app/b.txt");
|
|
295
|
+
git(root, "push", "-q", "origin", "main");
|
|
296
|
+
await ciTick({ cwd: root, forge: fakeForge(new Map([[a, PASSING], [b, PASSING]])), now: NOW, runnerKey: BOT.pem });
|
|
297
|
+
expect((lastGreen({ cwd: root }) as Extract<LastGreenDocument, { branch: string }>).commit?.sha).toBe(b);
|
|
298
|
+
const from = lastGreen({ cwd: root, from: a }) as Extract<LastGreenDocument, { branch: string }>;
|
|
299
|
+
expect(from).toMatchObject({ ref: a, commit: { sha: a } });
|
|
300
|
+
expect(lastGreen({ cwd: root, from: "nope" })).toMatchObject({ error: { code: "revision-unknown" } });
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -135,6 +135,14 @@ describe("the token the tick pushes its tags with (#3573)", () => {
|
|
|
135
135
|
expect(text.replace(" token: ${{ secrets.CI_GREEN_TOKEN || github.token }}\n", "")).toBe(plain);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
test("--runner-key-secret hands the tick its runner key from the secret, and nothing else changes (#4031)", () => {
|
|
139
|
+
const plain = renderCiGreenWorkflow(base);
|
|
140
|
+
const text = renderCiGreenWorkflow({ ...base, runnerKeySecret: "CHANT_RUNNER_KEY" });
|
|
141
|
+
const steps = (parseYAMLDocument(text) as Yaml).jobs.tick.steps;
|
|
142
|
+
expect(steps[2].env).toMatchObject({ GITHUB_TOKEN: "${{ github.token }}", CHANT_RUNNER_KEY: "${{ secrets.CHANT_RUNNER_KEY }}" });
|
|
143
|
+
expect(text.replace(" CHANT_RUNNER_KEY: ${{ secrets.CHANT_RUNNER_KEY }}\n", "")).toBe(plain);
|
|
144
|
+
});
|
|
145
|
+
|
|
138
146
|
test("--app-id-var and --app-key-secret mint an App token before the checkout and check out with it", () => {
|
|
139
147
|
const steps = (parseYAMLDocument(renderCiGreenWorkflow({ ...base, push: { appIdVar: "CI_GREEN_APP_ID", appKeySecret: "CI_GREEN_APP_KEY" } })) as Yaml).jobs.tick.steps;
|
|
140
148
|
expect(steps[0]).toMatchObject({
|
|
@@ -237,6 +245,12 @@ describe("chant ci workflow (#3573)", () => {
|
|
|
237
245
|
`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --app-id-var CI_GREEN_APP_ID --app-key-secret CI_GREEN_APP_KEY (in the workspace root)`,
|
|
238
246
|
);
|
|
239
247
|
|
|
248
|
+
// #4031: the runner key's secret goes into the env of the tick and the header.
|
|
249
|
+
expect(await runCiWorkflow({ args: { ciTokenSecret: "CI_GREEN_TOKEN", ciRunnerKeySecret: "CI_RUNNER_KEY" } } as unknown as CommandContext)).toBe(0);
|
|
250
|
+
const withKey = readFileSync(file, "utf-8");
|
|
251
|
+
expect(withKey.split("\n")[0]).toBe(`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --token-secret CI_GREEN_TOKEN --runner-key-secret CI_RUNNER_KEY (in the workspace root)`);
|
|
252
|
+
expect((parseYAMLDocument(withKey) as Yaml).jobs.tick.steps[2].env.CHANT_RUNNER_KEY).toBe("${{ secrets.CI_RUNNER_KEY }}");
|
|
253
|
+
|
|
240
254
|
// Dropping the flag writes the bytes chant 0.108.0 wrote.
|
|
241
255
|
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
|
|
242
256
|
expect(readFileSync(file, "utf-8")).toBe(plain);
|
|
@@ -262,6 +276,8 @@ describe("chant ci workflow (#3573)", () => {
|
|
|
262
276
|
expect(errors.pop()).toMatch(/one or the other/);
|
|
263
277
|
expect(await run({ ciTokenSecret: "ci-green token" })).toBe(1);
|
|
264
278
|
expect(errors.pop()).toMatch(/--token-secret takes a secret or variable name/);
|
|
279
|
+
expect(await run({ ciRunnerKeySecret: "runner key" })).toBe(1);
|
|
280
|
+
expect(errors.pop()).toMatch(/--runner-key-secret takes a secret or variable name/);
|
|
265
281
|
expect(existsSync(join(root, CI_GREEN_WORKFLOW_FILE))).toBe(false);
|
|
266
282
|
} finally {
|
|
267
283
|
cwd.mockRestore();
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
* tick's own `GITHUB_TOKEN`, which reads check runs, stays the Actions token:
|
|
35
35
|
* a fine-grained personal access token can't call the Checks API.
|
|
36
36
|
*
|
|
37
|
+
* `--runner-key-secret <NAME>` (#4031) hands the tick the runner key it
|
|
38
|
+
* signs its tags with, from `secrets.<NAME>` as `CHANT_RUNNER_KEY`. A
|
|
39
|
+
* declaration that sets `ci.green.signedSince` needs it: a tag outside that
|
|
40
|
+
* commit's strict first-parent ancestry without a statement is ignored.
|
|
41
|
+
*
|
|
37
42
|
* Like a member's pipelines (./member-pipeline.ts), the file starts with the
|
|
38
43
|
* generated-file header naming the command, and the member whose directory
|
|
39
44
|
* the command runs in records it in its generated-file record, so `WSP081`
|
|
@@ -158,6 +163,12 @@ export interface WorkflowInput {
|
|
|
158
163
|
workspaceRoot: string;
|
|
159
164
|
/** How the tag push authenticates, when not with the Actions token. */
|
|
160
165
|
push?: PushToken;
|
|
166
|
+
/**
|
|
167
|
+
* The repository secret holding the runner key the tick signs its tags
|
|
168
|
+
* with, as PEM (#4031). The tick step reads it from `CHANT_RUNNER_KEY`.
|
|
169
|
+
* The key's principal needs the `runner` role in `.chant/trust.json`.
|
|
170
|
+
*/
|
|
171
|
+
runnerKeySecret?: string;
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
/**
|
|
@@ -237,6 +248,7 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
|
|
|
237
248
|
" GITHUB_TOKEN: ${{ github.token }}",
|
|
238
249
|
" GIT_COMMITTER_NAME: github-actions[bot]",
|
|
239
250
|
" GIT_COMMITTER_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com",
|
|
251
|
+
...(input.runnerKeySecret ? [` CHANT_RUNNER_KEY: \${{ secrets.${input.runnerKeySecret} }}`] : []),
|
|
240
252
|
` run: ${tick}`,
|
|
241
253
|
];
|
|
242
254
|
return lines.join("\n") + "\n";
|
|
@@ -10,10 +10,11 @@ import { join } from "node:path";
|
|
|
10
10
|
import { afterAll, describe, expect, test, vi } from "vitest";
|
|
11
11
|
import { parseDeclaration, type CiPhase } from "./declaration";
|
|
12
12
|
import { cleanScratch, contract, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
13
|
-
import { ciTick, pushRefusalCause, evaluateCommit, evaluatePhase, latestAttempts, lastGreen, matchesCheckRun, windowMs, type LastGreenDocument } from "./ci-green";
|
|
13
|
+
import { ciList, ciTick, pushRefusalCause, evaluateCommit, evaluatePhase, latestAttempts, lastGreen, matchesCheckRun, windowMs, type LastGreenDocument } from "./ci-green";
|
|
14
14
|
import type { CheckRun, CiForge } from "./ci-green-forge";
|
|
15
|
-
import { runCiLastGreen } from "./ci-green-cli";
|
|
15
|
+
import { runCiLastGreen, runCiList } from "./ci-green-cli";
|
|
16
16
|
import lastGreenSchema from "./ci-last-green.schema.json";
|
|
17
|
+
import listSchema from "./ci-list.schema.json";
|
|
17
18
|
import lsSchema from "./ls.schema.json";
|
|
18
19
|
import { listWorkspace, type LsDocument } from "./ls";
|
|
19
20
|
import type { CommandContext } from "../cli/registry";
|
|
@@ -44,8 +45,11 @@ describe("the ci.green block (#3573)", () => {
|
|
|
44
45
|
{ name: "macos", runs: ["macos (test)"], skipped: "pass", pointer: "/ci/green/phases/macos" },
|
|
45
46
|
],
|
|
46
47
|
require: ["lint", "test", "macos"],
|
|
48
|
+
signedSince: null,
|
|
49
|
+
source: "checks",
|
|
47
50
|
pointer: "/ci/green",
|
|
48
51
|
});
|
|
52
|
+
expect(parseDeclaration(decl({ green: { ...GREEN, signedSince: "a".repeat(40) } }), "chant.workspace.json").ci!.green!.signedSince).toBe("a".repeat(40));
|
|
49
53
|
expect(parseDeclaration(decl({ green: { ...GREEN, window: "90m" } }), "chant.workspace.json").ci!.green!.window).toBe("90m");
|
|
50
54
|
expect(parseDeclaration(decl({}), "chant.workspace.json").ci).toEqual({ green: null });
|
|
51
55
|
expect(parseDeclaration(JSON.stringify({ name: "w", schema: 1, members: [] }), "chant.workspace.json").ci).toBeNull();
|
|
@@ -61,6 +65,9 @@ describe("the ci.green block (#3573)", () => {
|
|
|
61
65
|
{ ...GREEN, require: [] },
|
|
62
66
|
{ phases: GREEN.phases, require: GREEN.require },
|
|
63
67
|
{ ...GREEN, branch: "has space" },
|
|
68
|
+
{ ...GREEN, signedSince: "yesterday" },
|
|
69
|
+
{ ...GREEN, signedSince: "2026-10-16T00:00:00Z" },
|
|
70
|
+
{ ...GREEN, signedSince: "abc1234" },
|
|
64
71
|
]) {
|
|
65
72
|
expect(() => parseDeclaration(decl({ green: bad }), "chant.workspace.json"), JSON.stringify(bad)).toThrow();
|
|
66
73
|
}
|
|
@@ -399,3 +406,125 @@ describe("chant ci last-green (#3573)", () => {
|
|
|
399
406
|
});
|
|
400
407
|
});
|
|
401
408
|
});
|
|
409
|
+
|
|
410
|
+
// ── list ─────────────────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
describe("chant ci list (#4021)", () => {
|
|
413
|
+
const readList = contract(listSchema);
|
|
414
|
+
type Listed = Extract<Awaited<ReturnType<typeof ciList>>, { branch: string }>;
|
|
415
|
+
|
|
416
|
+
/** a green, b revoked (test went red), c undecided, d and e outside the 24h window. */
|
|
417
|
+
async function scenario() {
|
|
418
|
+
const { root, origin } = workspace();
|
|
419
|
+
const e = commitAt(root, 40, "app/e.txt");
|
|
420
|
+
const d = commitAt(root, 30, "app/d.txt");
|
|
421
|
+
const a = commitAt(root, 5, "app/a.txt");
|
|
422
|
+
const b = commitAt(root, 4, "app/b.txt");
|
|
423
|
+
const c = commitAt(root, 1, "app/c.txt");
|
|
424
|
+
push(root);
|
|
425
|
+
const runs = new Map([
|
|
426
|
+
[a, PASSING],
|
|
427
|
+
[b, PASSING],
|
|
428
|
+
[c, [run(1, "lint", "success")]],
|
|
429
|
+
]);
|
|
430
|
+
await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
431
|
+
runs.set(b, [...PASSING, run(9, "test", "failure")]);
|
|
432
|
+
await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
|
|
433
|
+
return { root, origin, a, b, c, d, e, runs };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
test("lists the window newest first with each commit's state and phase results, from local tags alone", async () => {
|
|
437
|
+
const { root, a, b, c } = await scenario();
|
|
438
|
+
const doc = (await ciList({ cwd: root, now: NOW })) as Listed;
|
|
439
|
+
readList.expectValid(doc);
|
|
440
|
+
expect(doc).toMatchObject({ contract: 1, branch: "main", ref: "refs/remotes/origin/main", window: "24h", since: null, require: ["lint", "test", "macos"] });
|
|
441
|
+
expect(doc.commits.map((x) => [x.sha, x.state])).toEqual([
|
|
442
|
+
[c, "undecided"],
|
|
443
|
+
[b, "revoked"],
|
|
444
|
+
[a, "green"],
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
const green = doc.commits[2];
|
|
448
|
+
expect(green.committed).toBe(hoursAgo(5).replace(/\.\d{3}Z$/, "Z"));
|
|
449
|
+
expect(green.green).toMatchObject({ tag: `ci/green/${a}` });
|
|
450
|
+
expect(green.green!.tagged).toMatch(/^\d{4}-\d\d-\d\dT/);
|
|
451
|
+
expect(green.revoked).toBeNull();
|
|
452
|
+
expect(green.phases.map((p) => [p.phase, p.result])).toEqual([["lint", "pass"], ["test", "pass"], ["macos", "pass"]]);
|
|
453
|
+
expect(green.phases[1].runs.map((r) => r.name)).toEqual(["test", "e2e (chromium)"]);
|
|
454
|
+
|
|
455
|
+
// The revoked commit keeps what its green tag recorded, and shows the failed phase and run.
|
|
456
|
+
const revoked = doc.commits[1];
|
|
457
|
+
expect(revoked.green).not.toBeNull();
|
|
458
|
+
expect(revoked.revoked).toMatchObject({ tag: `ci/revoked/${b}`, phase: "test", run: { name: "test", id: 9, conclusion: "failure" } });
|
|
459
|
+
expect(revoked.phases.map((p) => [p.phase, p.result])).toEqual([["lint", "pass"], ["test", "fail"], ["macos", "pass"]]);
|
|
460
|
+
expect(revoked.phases[1].runs).toEqual([expect.objectContaining({ name: "test", id: 9 })]);
|
|
461
|
+
|
|
462
|
+
// No tag, no forge: nothing is recorded, and nothing is guessed.
|
|
463
|
+
expect(doc.commits[0].phases.map((p) => [p.phase, p.result, p.runs.length])).toEqual([["lint", "unrecorded", 0], ["test", "unrecorded", 0], ["macos", "unrecorded", 0]]);
|
|
464
|
+
expect(doc.commits[0]).toMatchObject({ green: null, revoked: null });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("--forge judges the undecided commits only, and --limit and --since narrow the walk", async () => {
|
|
468
|
+
const { root, a, b, c, d, e, runs } = await scenario();
|
|
469
|
+
const forge = fakeForge(runs);
|
|
470
|
+
const live = (await ciList({ cwd: root, now: NOW, forge })) as Listed;
|
|
471
|
+
readList.expectValid(live);
|
|
472
|
+
expect(forge.asked).toEqual([c]);
|
|
473
|
+
expect(live.commits[0].phases.map((p) => [p.phase, p.result])).toEqual([["lint", "pass"], ["test", "pending"], ["macos", "pending"]]);
|
|
474
|
+
expect(live.commits[0].phases[0].runs).toEqual([expect.objectContaining({ name: "lint", id: 1, conclusion: "success" })]);
|
|
475
|
+
|
|
476
|
+
const two = (await ciList({ cwd: root, now: NOW, limit: 2 })) as Listed;
|
|
477
|
+
expect(two.commits.map((x) => x.sha)).toEqual([c, b]);
|
|
478
|
+
|
|
479
|
+
const after = (await ciList({ cwd: root, now: NOW, since: a })) as Listed;
|
|
480
|
+
readList.expectValid(after);
|
|
481
|
+
expect(after).toMatchObject({ window: null, since: a });
|
|
482
|
+
expect(after.commits.map((x) => x.sha)).toEqual([c, b]);
|
|
483
|
+
// --since replaces the window, so a commit older than 24h is listed when the range reaches it.
|
|
484
|
+
const all = (await ciList({ cwd: root, now: NOW, since: e })) as Listed;
|
|
485
|
+
expect(all.commits.map((x) => x.sha)).toEqual([c, b, a, d]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("an unknown --since, no ci.green and an unknown branch are failures in the schema", async () => {
|
|
489
|
+
const { root } = await scenario();
|
|
490
|
+
const bad = await ciList({ cwd: root, now: NOW, since: "no-such-rev" });
|
|
491
|
+
readList.expectValid(bad);
|
|
492
|
+
expect(bad).toMatchObject({ error: { code: "revision-unknown" } });
|
|
493
|
+
|
|
494
|
+
const plain = scratchDir("chant-ci-plain-");
|
|
495
|
+
git(plain, "init", "-q");
|
|
496
|
+
writeFiles(plain, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "x" }]), "app/a.txt": "a\n" });
|
|
497
|
+
const undeclared = await ciList({ cwd: plain });
|
|
498
|
+
readList.expectValid(undeclared);
|
|
499
|
+
expect(undeclared).toMatchObject({ error: { code: "ci-green-undeclared" } });
|
|
500
|
+
|
|
501
|
+
const none = workspace();
|
|
502
|
+
readList.expectValid(await ciList({ cwd: none.root }));
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("the command prints the document under --json and a line per commit otherwise", async () => {
|
|
506
|
+
const { root, a, b, c, e } = await scenario();
|
|
507
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
508
|
+
try {
|
|
509
|
+
// The command reads the real clock, so these commits sit outside its window: --since names them.
|
|
510
|
+
const text = await captured(() => runCiList({ args: { limit: 3, since: e } } as unknown as CommandContext));
|
|
511
|
+
expect(text.code).toBe(0);
|
|
512
|
+
expect(text.out).toContain(`${c.slice(0, 12)} undecided lint=unrecorded test=unrecorded macos=unrecorded`);
|
|
513
|
+
expect(text.out).toContain(`${b.slice(0, 12)} revoked lint=pass test=fail macos=pass`);
|
|
514
|
+
expect(text.out).toContain(`${a.slice(0, 12)} green lint=pass test=pass macos=pass`);
|
|
515
|
+
|
|
516
|
+
const json = await captured(() => runCiList({ args: { json: true, since: a } } as unknown as CommandContext));
|
|
517
|
+
expect(json.code).toBe(0);
|
|
518
|
+
readList.expectValid(JSON.parse(json.out));
|
|
519
|
+
expect((JSON.parse(json.out) as Listed).commits).toHaveLength(2);
|
|
520
|
+
|
|
521
|
+
expect((await captured(() => runCiList({ args: { limit: 0 } } as unknown as CommandContext))).code).toBe(1);
|
|
522
|
+
const refused = await captured(() => runCiList({ args: { json: true, since: "no-such-rev" } } as unknown as CommandContext));
|
|
523
|
+
expect(refused.code).toBe(1);
|
|
524
|
+
expect(JSON.parse(refused.out)).toMatchObject({ error: { code: "revision-unknown" } });
|
|
525
|
+
expect((await captured(() => runCiList({ args: { forge: "gitlab" } } as unknown as CommandContext))).code).toBe(1);
|
|
526
|
+
} finally {
|
|
527
|
+
cwd.mockRestore();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
});
|