@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
|
@@ -27,11 +27,41 @@
|
|
|
27
27
|
*
|
|
28
28
|
* The forge is asked only for check runs (`./ci-green-forge.ts`). The tags
|
|
29
29
|
* are fetched from and pushed to the remote with git.
|
|
30
|
+
*
|
|
31
|
+
* Signed tags (#4031): when `ci.green.signedSince` names a commit, a tag on
|
|
32
|
+
* any commit that is not a strict first-parent ancestor of it counts only
|
|
33
|
+
* when its annotation carries a statement signed by a runner key (`./trust/ci-tag-statement.ts`). Every
|
|
34
|
+
* reader here, and the release cut, ignores a tag without a valid one and
|
|
35
|
+
* reports it. A tick given a runner key signs the tags it makes. Without
|
|
36
|
+
* `signedSince` every tag counts, as before.
|
|
37
|
+
*
|
|
38
|
+
* Chant runs as the source (#4044): with `ci.green.source: "runs"` (or
|
|
39
|
+
* `--source runs`) the tick reads chant's own Op runs from the run ledger on
|
|
40
|
+
* `chant/lifecycle` in place of the forge's check runs, so a project whose
|
|
41
|
+
* pipelines run as chant Ops, such as one on GitLab or Forgejo, gets green
|
|
42
|
+
* and revoked tags with no forge check runs. A phase's patterns then match
|
|
43
|
+
* Op names, a run counts for the commit its record names as `gitSha`, and
|
|
44
|
+
* the latest run of each Op decides, by its end time. A run counts only when
|
|
45
|
+
* its record carries a statement signed by a runner key whose principal
|
|
46
|
+
* holds the `runner` role at base (`./trust/op-run-statement.ts`); the tick
|
|
47
|
+
* ignores and reports any other, so it neither greens nor revokes a commit.
|
|
48
|
+
* A tick from runs signs every tag it makes, and each run in the tag's
|
|
49
|
+
* record names the Op run's id and carries its statement.
|
|
30
50
|
*/
|
|
51
|
+
import { judgeCommits, mergeCommits, readWorkIndex } from "./commit-links.js";
|
|
31
52
|
import { execFileSync } from "node:child_process";
|
|
32
53
|
import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError } from "./declaration.js";
|
|
54
|
+
import { readRunLedger } from "../lifecycle/run-ledger.js";
|
|
55
|
+
import { fetchLifecycleStatus, listFilesInDir, listLedgerTrees } from "../lifecycle/git.js";
|
|
56
|
+
import { signOpRunRecord, verifyOpRunStatement } from "./trust/op-run-statement.js";
|
|
33
57
|
import { gitTop } from "./tree.js";
|
|
34
58
|
import { locateWorkspace } from "./which-chant.js";
|
|
59
|
+
import { gitRevisionSource } from "./record-source.js";
|
|
60
|
+
import { emptyPolicy, readTrustPolicy } from "./trust/policy.js";
|
|
61
|
+
import { loadRunnerKey } from "./trust/dsse.js";
|
|
62
|
+
import { signCiTagRecord, verifyCiTagStatement, CI_TAG_SIGNER_ROLE } from "./trust/ci-tag-statement.js";
|
|
63
|
+
/** The environment variable a tick (#4031) and `chant run` (#4044) read a runner key from, as PEM, when `--key` is not given. */
|
|
64
|
+
export const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
|
|
35
65
|
export const CI_GREEN_TAG_PREFIX = "ci/green/";
|
|
36
66
|
export const CI_REVOKED_TAG_PREFIX = "ci/revoked/";
|
|
37
67
|
export const CI_LAST_GREEN_CONTRACT_VERSION = 1;
|
|
@@ -80,7 +110,7 @@ export function evaluatePhase(phase, latest) {
|
|
|
80
110
|
for (const r of matched) {
|
|
81
111
|
if (runs.some((x) => x.name === r.name))
|
|
82
112
|
continue;
|
|
83
|
-
runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r) });
|
|
113
|
+
runs.push({ name: r.name, id: r.id, status: r.status, conclusion: r.conclusion, url: r.url, verdict: runVerdict(phase, r), ...(r.opRun ? { opRun: r.opRun } : {}) });
|
|
84
114
|
}
|
|
85
115
|
}
|
|
86
116
|
const verdict = runs.some((r) => r.verdict === "fail")
|
|
@@ -108,23 +138,36 @@ export function windowMs(window) {
|
|
|
108
138
|
throw new Error(`ci.green.window must be a number of minutes, hours or days, such as 24h, not ${JSON.stringify(window)}`);
|
|
109
139
|
return Number(m[1]) * { m: 60000, h: 3600000, d: 86400000 }[m[2]];
|
|
110
140
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
/**
|
|
142
|
+
* A run as a tag's record holds it. An Op run (#4044) has no forge id; it
|
|
143
|
+
* names the Op run's id, env and signer, and carries the run's statement.
|
|
144
|
+
*/
|
|
145
|
+
const runRecord = (r) => r.opRun
|
|
146
|
+
? { name: r.name, id: null, conclusion: r.conclusion, url: r.url, opRun: { id: r.opRun.id, env: r.opRun.env, signer: r.opRun.signer, statement: r.opRun.statement } }
|
|
147
|
+
: { name: r.name, id: r.id, conclusion: r.conclusion, url: r.url };
|
|
148
|
+
/** `source: "runs"` on a record made from chant runs (#4044); nothing for check runs, so their records read as before. */
|
|
149
|
+
const sourceField = (green) => (green.source === "runs" ? { source: "runs" } : {});
|
|
150
|
+
/** The record as JSON, with the statement that signs it when a signer is given (#4031). */
|
|
151
|
+
function recordJson(record, signer) {
|
|
152
|
+
return JSON.stringify(signer ? { ...record, statement: signCiTagRecord(record, signer) } : record, null, 2);
|
|
153
|
+
}
|
|
154
|
+
/** The annotation of a green tag: a subject line, then the record as JSON, signed when `signer` is given (#4031). */
|
|
155
|
+
export function greenAnnotation(green, sha, verdict, at, signer) {
|
|
114
156
|
const record = {
|
|
115
157
|
schema: 1,
|
|
116
158
|
tag: "green",
|
|
117
159
|
commit: sha,
|
|
118
160
|
branch: green.branch,
|
|
119
161
|
at: iso(at),
|
|
162
|
+
...sourceField(green),
|
|
120
163
|
phases: Object.fromEntries(verdict.phases.map((p) => [p.phase, p.runs.map(runRecord)])),
|
|
121
164
|
};
|
|
122
|
-
return `ci: every required phase passed (${green.require.join(", ")})\n\n${
|
|
165
|
+
return `ci: every required phase passed (${green.require.join(", ")})\n\n${recordJson(record, signer)}\n`;
|
|
123
166
|
}
|
|
124
|
-
/** The annotation of a revoked tag: the phase and the run that failed, and the time. */
|
|
125
|
-
export function revokedAnnotation(green, sha, failure, at) {
|
|
126
|
-
const record = { schema: 1, tag: "revoked", commit: sha, branch: green.branch, at: iso(at), phase: failure.phase, run: runRecord(failure.run) };
|
|
127
|
-
return `ci: phase ${failure.phase} failed in ${failure.run.name} (${failure.run.conclusion ?? failure.run.status})\n\n${
|
|
167
|
+
/** The annotation of a revoked tag: the phase and the run that failed, and the time, signed when `signer` is given (#4031). */
|
|
168
|
+
export function revokedAnnotation(green, sha, failure, at, signer) {
|
|
169
|
+
const record = { schema: 1, tag: "revoked", commit: sha, branch: green.branch, at: iso(at), ...sourceField(green), phase: failure.phase, run: runRecord(failure.run) };
|
|
170
|
+
return `ci: phase ${failure.phase} failed in ${failure.run.name} (${failure.run.conclusion ?? failure.run.status})\n\n${recordJson(record, signer)}\n`;
|
|
128
171
|
}
|
|
129
172
|
/**
|
|
130
173
|
* Decide what a tick does, without touching git: a commit with no green tag
|
|
@@ -137,18 +180,22 @@ export async function planTick(input) {
|
|
|
137
180
|
for (const c of input.commits) {
|
|
138
181
|
const before = input.revokedTags.has(c.sha) ? "revoked" : input.greenTags.has(c.sha) ? "green" : "none";
|
|
139
182
|
if (before === "revoked") {
|
|
140
|
-
|
|
183
|
+
const ignored = input.ignored?.get(c.sha);
|
|
184
|
+
out.push({ ...c, before, made: null, ...(ignored && ignored.length > 0 ? { ignored: [...ignored] } : {}) });
|
|
141
185
|
continue;
|
|
142
186
|
}
|
|
143
187
|
const verdict = evaluateCommit(input.green, await input.checkRuns(c.sha));
|
|
144
188
|
let made = null;
|
|
145
189
|
if (before === "none" && verdict.verdict === "pass") {
|
|
146
|
-
made = { tag: `${CI_GREEN_TAG_PREFIX}${c.sha}`, message: greenAnnotation(input.green, c.sha, verdict, input.now) };
|
|
190
|
+
made = { tag: `${CI_GREEN_TAG_PREFIX}${c.sha}`, message: greenAnnotation(input.green, c.sha, verdict, input.now, input.signer) };
|
|
147
191
|
}
|
|
148
192
|
else if (before === "green" && verdict.verdict === "fail") {
|
|
149
|
-
made = { tag: `${CI_REVOKED_TAG_PREFIX}${c.sha}`, message: revokedAnnotation(input.green, c.sha, verdict.failure, input.now) };
|
|
193
|
+
made = { tag: `${CI_REVOKED_TAG_PREFIX}${c.sha}`, message: revokedAnnotation(input.green, c.sha, verdict.failure, input.now, input.signer) };
|
|
150
194
|
}
|
|
151
|
-
|
|
195
|
+
const ignored = input.ignored?.get(c.sha);
|
|
196
|
+
// A tag is never moved or deleted by chant, so an ignored tag holding the name stops the tick making it.
|
|
197
|
+
const blocked = made !== null && ignored?.some((t) => t.tag === made.tag) ? made.tag : undefined;
|
|
198
|
+
out.push({ ...c, before, verdict, made: blocked ? null : made, ...(ignored && ignored.length > 0 ? { ignored: [...ignored] } : {}), ...(blocked ? { blocked } : {}) });
|
|
152
199
|
}
|
|
153
200
|
return out;
|
|
154
201
|
}
|
|
@@ -169,19 +216,91 @@ function tryGit(top, args) {
|
|
|
169
216
|
return null;
|
|
170
217
|
}
|
|
171
218
|
}
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const
|
|
177
|
-
for (const
|
|
178
|
-
const [tag, date] =
|
|
179
|
-
if (tag
|
|
180
|
-
|
|
219
|
+
/** Every ci tag in the repository, read from local refs. */
|
|
220
|
+
function readCiTagRefs(top) {
|
|
221
|
+
const out = [];
|
|
222
|
+
const format = "%(refname:lstrip=2)%00%(objecttype)%00%(objectname)%00%(*objectname)%00%(taggerdate:iso-strict)%00%(contents)%01";
|
|
223
|
+
const raw = git(top, ["for-each-ref", `--format=${format}`, `refs/tags/${CI_GREEN_TAG_PREFIX}`, `refs/tags/${CI_REVOKED_TAG_PREFIX}`]);
|
|
224
|
+
for (const entry of raw.split("\u0001")) {
|
|
225
|
+
const [tag, type, object, peeled, date, contents] = entry.replace(/^\n/, "").split("\u0000");
|
|
226
|
+
if (!tag)
|
|
227
|
+
continue;
|
|
228
|
+
const annotated = type === "tag";
|
|
229
|
+
let record = null;
|
|
230
|
+
// A lightweight tag has no tagger date, and its "contents" are the commit's message.
|
|
231
|
+
if (annotated && date && contents && contents.includes("{")) {
|
|
232
|
+
try {
|
|
233
|
+
const parsed = JSON.parse(contents.slice(contents.indexOf("{")));
|
|
234
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
235
|
+
record = parsed;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
record = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const green = tag.startsWith(CI_GREEN_TAG_PREFIX);
|
|
242
|
+
out.push({
|
|
243
|
+
kind: green ? "green" : "revoked",
|
|
244
|
+
sha: tag.slice((green ? CI_GREEN_TAG_PREFIX : CI_REVOKED_TAG_PREFIX).length),
|
|
245
|
+
tag,
|
|
246
|
+
tagged: date || null,
|
|
247
|
+
record,
|
|
248
|
+
target: (annotated ? peeled : object) ?? "",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The commits whose tags count unsigned under `signedSince` (#4031): its
|
|
255
|
+
* strict first-parent ancestors, the history the green branch had before the
|
|
256
|
+
* first commit whose tags must be signed. Position in history is something a
|
|
257
|
+
* committer can't set, unlike a commit's or a tag's date (ws-069 d). Throws
|
|
258
|
+
* `declaration-invalid` when `signedSince` names no commit here.
|
|
259
|
+
*/
|
|
260
|
+
export function unsignedHistory(top, signedSince) {
|
|
261
|
+
if (tryGit(top, ["rev-parse", "--verify", "--quiet", `${signedSince}^{commit}`]) === null) {
|
|
262
|
+
throw new WorkspaceReadError("declaration-invalid", `ci.green.signedSince names ${signedSince}, which is not a commit in this repository; it must be the full sha of the first commit on the green branch whose tags must be signed (fetch the branch if it is newer than this clone)`);
|
|
263
|
+
}
|
|
264
|
+
return new Set(git(top, ["rev-list", "--first-parent", `${signedSince}^@`]).split("\n").filter(Boolean));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Read the ci tags and judge each one (#4031). Without `signedSince` in
|
|
268
|
+
* `green` every tag counts, as before. With it, a tag on a strict
|
|
269
|
+
* first-parent ancestor of `signedSince` counts unsigned, and any other tag
|
|
270
|
+
* counts only when it points at the commit its name gives and its annotation
|
|
271
|
+
* carries a statement a runner key at `base` signed for that commit, tag and
|
|
272
|
+
* branch. The runner keys and roles are read from `.chant/trust.json` at
|
|
273
|
+
* `base`, the commit the caller reads the branch at. Throws
|
|
274
|
+
* `declaration-invalid` when `signedSince` names no commit here.
|
|
275
|
+
*/
|
|
276
|
+
export function readCiTags(top, green = null, base = null) {
|
|
277
|
+
const out = { green: new Map(), revoked: new Map(), ignored: new Map() };
|
|
278
|
+
const before = green?.signedSince ? unsignedHistory(top, green.signedSince) : null;
|
|
279
|
+
let policy;
|
|
280
|
+
const policyAtBase = () => (policy ?? (policy = base ? readTrustPolicy(gitRevisionSource(top, base), base) : emptyPolicy(null)));
|
|
281
|
+
for (const t of readCiTagRefs(top)) {
|
|
282
|
+
const counted = (signer) => (t.kind === "green" ? out.green : out.revoked).set(t.sha, { tagged: t.tagged, record: t.record, signer });
|
|
283
|
+
if (before === null) {
|
|
284
|
+
counted(null);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const ignore = (status, reason) => out.ignored.set(t.sha, [...(out.ignored.get(t.sha) ?? []), { tag: t.tag, status, reason }]);
|
|
288
|
+
if (t.target !== t.sha) {
|
|
289
|
+
ignore("mismatch", `${t.tag} points at ${t.target.slice(0, 12) || "nothing"}, not at the commit its name gives`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (before.has(t.sha)) {
|
|
293
|
+
counted(null);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const p = policyAtBase();
|
|
297
|
+
const v = verifyCiTagStatement(t.record, { sha: t.sha, kind: t.kind, branch: green.branch }, p.runners, p.roles);
|
|
298
|
+
if (v.status === "signed")
|
|
299
|
+
counted(v.signer);
|
|
181
300
|
else
|
|
182
|
-
|
|
301
|
+
ignore(v.status, `${v.reason}; ci.green.signedSince is ${green.signedSince}, so a tag on a commit that is not a strict first-parent ancestor of it needs one`);
|
|
183
302
|
}
|
|
184
|
-
return
|
|
303
|
+
return out;
|
|
185
304
|
}
|
|
186
305
|
/** The declaration's `ci.green`, read where `cwd` is, and the git top. */
|
|
187
306
|
function readGreen(cwd) {
|
|
@@ -195,13 +314,44 @@ function readGreen(cwd) {
|
|
|
195
314
|
throw new WorkspaceReadError("not-a-git-repository", "the ci tags are git tags, and this directory is not in a git repository");
|
|
196
315
|
return { green, top, workspace: { name: declaration.name, root: located.root, file: declaration.file } };
|
|
197
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* The signer a runner key makes (#4031): the principal `.chant/trust.json`
|
|
319
|
+
* at `base` lists the key for, who must hold the `runner` role. Throws when
|
|
320
|
+
* the key is not one, so a tick never makes a tag its readers would ignore.
|
|
321
|
+
*/
|
|
322
|
+
export function ciTagSigner(top, base, pem, what = "the tags it signed") {
|
|
323
|
+
let loaded;
|
|
324
|
+
try {
|
|
325
|
+
loaded = loadRunnerKey(pem);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
throw new Error(`the runner key is not an Ed25519 private key in PEM: ${err instanceof Error ? err.message : String(err)}`);
|
|
329
|
+
}
|
|
330
|
+
const policy = readTrustPolicy(gitRevisionSource(top, base), base);
|
|
331
|
+
const at = `.chant/trust.json at ${base.slice(0, 12)}`;
|
|
332
|
+
if (policy.signers.some((s) => s.key === loaded.publicKey)) {
|
|
333
|
+
throw new Error(`the runner key is a signer's key in ${policy.signersPath}; ci tags and runs are signed by a CI identity's key, never by a person's`);
|
|
334
|
+
}
|
|
335
|
+
const listed = policy.runners.find((r) => r.key === loaded.publicKey);
|
|
336
|
+
if (!listed)
|
|
337
|
+
throw new Error(`${at} lists no runner with this key, so ${what} would be ignored. Add it under "runners" first:\n ${loaded.publicKey}`);
|
|
338
|
+
const holds = (policy.roles[CI_TAG_SIGNER_ROLE] ?? []).some((p) => p.normalize("NFKC").trim().toLowerCase() === listed.principal.normalize("NFKC").trim().toLowerCase());
|
|
339
|
+
if (!holds)
|
|
340
|
+
throw new Error(`${listed.principal} holds no ${CI_TAG_SIGNER_ROLE} role in ${at}, so ${what} would be ignored. Grant it: "roles": { "${CI_TAG_SIGNER_ROLE}": ["${listed.principal}"] }`);
|
|
341
|
+
return { principal: listed.principal, key: loaded.key, publicKey: loaded.publicKey };
|
|
342
|
+
}
|
|
198
343
|
/**
|
|
199
344
|
* One tick: fetch the branch and the ci tags from the remote (pruning a ci
|
|
200
345
|
* tag deleted there by hand), judge the first-parent commits within the
|
|
201
346
|
* window, make the tags that are due and push them, never forced.
|
|
202
347
|
*/
|
|
203
348
|
export async function ciTick(req) {
|
|
204
|
-
const
|
|
349
|
+
const read = readGreen(req.cwd);
|
|
350
|
+
const top = read.top;
|
|
351
|
+
const green = { ...read.green, source: req.source ?? read.green.source };
|
|
352
|
+
const forge = green.source === "checks" ? (typeof req.forge === "function" ? req.forge() : req.forge) : undefined;
|
|
353
|
+
if (green.source === "checks" && !forge)
|
|
354
|
+
throw new Error("ci.green.source is checks, so the tick reads check runs and needs a forge");
|
|
205
355
|
const remote = req.remote ?? "origin";
|
|
206
356
|
const now = req.now ?? new Date();
|
|
207
357
|
const tracking = `refs/remotes/${remote}/${green.branch}`;
|
|
@@ -221,15 +371,45 @@ export async function ciTick(req) {
|
|
|
221
371
|
.map((line) => line.split(" "))
|
|
222
372
|
.filter(([, ct]) => Number(ct) >= cutoff)
|
|
223
373
|
.map(([sha, ct]) => ({ sha, committed: iso(new Date(Number(ct) * 1000)) }));
|
|
224
|
-
const
|
|
374
|
+
const base = git(top, ["rev-parse", `${tracking}^{commit}`]);
|
|
375
|
+
if (green.signedSince && req.runnerKey === undefined && !req.dryRun) {
|
|
376
|
+
throw new Error(`ci.green.signedSince is ${green.signedSince}, so every tag the tick makes needs a statement signed by a runner key, and none was given: pass --key <runner key.pem> or set CHANT_RUNNER_KEY`);
|
|
377
|
+
}
|
|
378
|
+
if (green.source === "runs" && req.runnerKey === undefined && !req.dryRun) {
|
|
379
|
+
throw new Error("the tick reads chant runs (source runs), and every tag it makes from them is signed by a runner key, and none was given: pass --key <runner key.pem> or set CHANT_RUNNER_KEY");
|
|
380
|
+
}
|
|
381
|
+
const signer = req.runnerKey !== undefined ? ciTagSigner(top, base, req.runnerKey) : undefined;
|
|
382
|
+
const links = await commitLinks(req.cwd, top, tracking, commits.map((c) => c.sha));
|
|
383
|
+
const tags = readCiTags(top, green, base);
|
|
384
|
+
let runs;
|
|
385
|
+
if (green.source === "runs") {
|
|
386
|
+
// The runs live on chant/lifecycle, which a CI checkout may not have yet.
|
|
387
|
+
const fetched = await fetchLifecycleStatus({ cwd: top });
|
|
388
|
+
if (fetched.status === "diverged" || fetched.status === "unreachable") {
|
|
389
|
+
throw new Error(`could not fetch chant/lifecycle, where the Op runs are recorded, from ${fetched.remote}: ${fetched.status === "diverged" ? "the local branch and the remote's have diverged" : fetched.stderr}`);
|
|
390
|
+
}
|
|
391
|
+
runs = await readOpRunSource(req.cwd, top, base);
|
|
392
|
+
}
|
|
225
393
|
const planned = await planTick({
|
|
226
394
|
green,
|
|
227
395
|
commits,
|
|
228
396
|
greenTags: new Set(tags.green.keys()),
|
|
229
|
-
revokedTags: tags.revoked,
|
|
230
|
-
|
|
397
|
+
revokedTags: new Set(tags.revoked.keys()),
|
|
398
|
+
ignored: tags.ignored,
|
|
399
|
+
checkRuns: async (sha) => (runs ? runs.checkRuns(sha) : forge.checkRuns(sha)),
|
|
231
400
|
now,
|
|
401
|
+
signer,
|
|
232
402
|
});
|
|
403
|
+
if (links)
|
|
404
|
+
for (const c of planned)
|
|
405
|
+
c.work = links.get(c.sha);
|
|
406
|
+
if (runs) {
|
|
407
|
+
for (const c of planned) {
|
|
408
|
+
const ignored = runs.ignored.get(c.sha);
|
|
409
|
+
if (ignored && ignored.length > 0)
|
|
410
|
+
c.ignoredRuns = ignored;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
233
413
|
const due = planned.filter((c) => c.made !== null);
|
|
234
414
|
if (!req.dryRun && due.length > 0) {
|
|
235
415
|
const ident = taggerConfig(top);
|
|
@@ -245,7 +425,72 @@ export async function ciTick(req) {
|
|
|
245
425
|
throw new Error(`could not push ${due.map((c) => c.made.tag).join(", ")} to ${remote}: ${cause ? `${cause}\n` : ""}${stderr}`);
|
|
246
426
|
}
|
|
247
427
|
}
|
|
248
|
-
return { branch: green.branch, window: green.window, ref: tracking, commits: planned, made: due.map((c) => c.made.tag) };
|
|
428
|
+
return { branch: green.branch, window: green.window, ref: tracking, commits: planned, made: due.map((c) => c.made.tag), signer: signer?.principal ?? null, source: green.source };
|
|
429
|
+
}
|
|
430
|
+
const RUN_LEDGER_FILE = /^runs__(.+)\.jsonl$/;
|
|
431
|
+
/**
|
|
432
|
+
* An Op run as a check run (#4044): `ok` passes and `fail` fails once the
|
|
433
|
+
* run has settled; a run stopped at a gate or a decision point is still
|
|
434
|
+
* going. The id is the run's end time in milliseconds, so the latest run of
|
|
435
|
+
* an Op is its latest attempt.
|
|
436
|
+
*/
|
|
437
|
+
export function opRunCheckRun(record, signer) {
|
|
438
|
+
const settled = record.status === "ok" || record.status === "fail";
|
|
439
|
+
const ended = Date.parse(record.ended);
|
|
440
|
+
return {
|
|
441
|
+
id: Number.isFinite(ended) ? ended : 0,
|
|
442
|
+
name: record.op,
|
|
443
|
+
status: settled ? "completed" : record.status,
|
|
444
|
+
conclusion: settled ? (record.status === "ok" ? "success" : "failure") : null,
|
|
445
|
+
completedAt: settled ? record.ended : null,
|
|
446
|
+
url: record.runOrigin?.url ?? null,
|
|
447
|
+
opRun: { id: record.id, env: record.env, signer, statement: record.statement },
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Judge every Op run record that names a commit against `.chant/trust.json`
|
|
452
|
+
* at `base` (#4044), from the run ledger the project at `cwd` writes to. A
|
|
453
|
+
* record with a valid statement becomes a check run on its `gitSha`; any
|
|
454
|
+
* other is kept as an ignored run. Reads local refs only.
|
|
455
|
+
*/
|
|
456
|
+
export async function readOpRunSource(cwd, top, base) {
|
|
457
|
+
const policy = readTrustPolicy(gitRevisionSource(top, base), base);
|
|
458
|
+
const byCommit = new Map();
|
|
459
|
+
const ignored = new Map();
|
|
460
|
+
for (const env of await listLedgerTrees({ cwd })) {
|
|
461
|
+
for (const file of await listFilesInDir(env, { cwd })) {
|
|
462
|
+
const op = RUN_LEDGER_FILE.exec(file)?.[1];
|
|
463
|
+
if (!op)
|
|
464
|
+
continue;
|
|
465
|
+
const { records } = await readRunLedger(env, op, { cwd });
|
|
466
|
+
for (const record of records) {
|
|
467
|
+
if (!record.gitSha)
|
|
468
|
+
continue;
|
|
469
|
+
const v = verifyOpRunStatement(record, policy.runners, policy.roles);
|
|
470
|
+
if (v.status === "signed")
|
|
471
|
+
byCommit.set(record.gitSha, [...(byCommit.get(record.gitSha) ?? []), opRunCheckRun(record, v.signer)]);
|
|
472
|
+
else
|
|
473
|
+
ignored.set(record.gitSha, [...(ignored.get(record.gitSha) ?? []), { run: record.id, op: record.op, env: record.env, status: v.status, reason: v.reason }]);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return { checkRuns: (sha) => byCommit.get(sha) ?? [], ignored };
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* What `chant run <op> --key` signs a run's record with (#4044): the runner
|
|
481
|
+
* key `.chant/trust.json` at the checkout's `HEAD` lists for a principal
|
|
482
|
+
* holding the `runner` role. Throws when the key is not one, or `cwd` is in
|
|
483
|
+
* no git checkout.
|
|
484
|
+
*/
|
|
485
|
+
export function opRunSigner(cwd, pem) {
|
|
486
|
+
const top = gitTop(cwd);
|
|
487
|
+
if (!top)
|
|
488
|
+
throw new Error("a run is signed against .chant/trust.json at HEAD, and this directory is not in a git repository");
|
|
489
|
+
const head = tryGit(top, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
490
|
+
if (head === null)
|
|
491
|
+
throw new Error("a run is signed against .chant/trust.json at HEAD, and this repository has no commit yet");
|
|
492
|
+
const signer = ciTagSigner(top, head, pem, "the runs it signed");
|
|
493
|
+
return (record) => signOpRunRecord(record, signer);
|
|
249
494
|
}
|
|
250
495
|
/**
|
|
251
496
|
* Why the remote refused a tag push, when chant can tell. GitHub refuses a
|
|
@@ -272,11 +517,41 @@ function stderrOf(err) {
|
|
|
272
517
|
const e = err;
|
|
273
518
|
return String(e.stderr ?? e.message ?? err).trim();
|
|
274
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Whether each commit names its work item (#4029), judged against the work
|
|
522
|
+
* records at `ref`. Undefined when the workspace declares no work kind or
|
|
523
|
+
* they can't be read: then no commit has a link state to report.
|
|
524
|
+
*/
|
|
525
|
+
async function commitLinks(cwd, top, ref, shas) {
|
|
526
|
+
if (shas.length === 0)
|
|
527
|
+
return undefined;
|
|
528
|
+
try {
|
|
529
|
+
const index = await readWorkIndex(cwd, top, ref);
|
|
530
|
+
if (!index)
|
|
531
|
+
return undefined;
|
|
532
|
+
return judgeCommits(top, shas, index, mergeCommits(top, shas));
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
// A tag and a green list stand without the link, which only explains a commit.
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** Why a commit's work item is unknown, for the text of `ci tick` and `ci list`; undefined for a commit that names one. */
|
|
540
|
+
export function whyWorkUnknown(work) {
|
|
541
|
+
if (!work)
|
|
542
|
+
return undefined;
|
|
543
|
+
if (work.state === "unlinked")
|
|
544
|
+
return "no Chant-Record or Chant-Lease trailer names a work item";
|
|
545
|
+
if (work.state === "wrong")
|
|
546
|
+
return `the work item named is not there: ${work.problems.map((p) => `${p.trailer} (${p.message})`).join("; ")}`;
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
275
549
|
/** The text `chant ci tick` prints. */
|
|
276
550
|
export function formatTick(r, dryRun = false) {
|
|
277
|
-
const lines = [`${r.branch}, commits within ${r.window}: ${r.commits.length}`];
|
|
551
|
+
const lines = [`${r.branch}, commits within ${r.window}${r.source === "runs" ? ", judged from chant runs" : ""}: ${r.commits.length}`];
|
|
278
552
|
for (const c of r.commits) {
|
|
279
553
|
const short = c.sha.slice(0, 12);
|
|
554
|
+
const before = lines.length;
|
|
280
555
|
if (c.made) {
|
|
281
556
|
lines.push(` ${short} ${dryRun ? "would make" : "made"} ${c.made.tag.slice(0, c.made.tag.lastIndexOf("/"))}${c.verdict?.failure ? `: phase ${c.verdict.failure.phase}, ${c.verdict.failure.run.name} ${c.verdict.failure.run.conclusion}` : ""}`);
|
|
282
557
|
}
|
|
@@ -289,9 +564,29 @@ export function formatTick(r, dryRun = false) {
|
|
|
289
564
|
const what = c.before === "green" ? "green" : v.verdict === "fail" ? `failed: phase ${v.failure.phase}, ${v.failure.run.name} ${v.failure.run.conclusion}` : `undecided: ${waiting.join(", ")}`;
|
|
290
565
|
lines.push(` ${short} ${what}`);
|
|
291
566
|
}
|
|
567
|
+
const why = whyWorkUnknown(c.work);
|
|
568
|
+
if (why)
|
|
569
|
+
lines[before] += `; why unknown: ${why}`;
|
|
570
|
+
for (const t of c.ignored ?? [])
|
|
571
|
+
lines.push(` ignored ${t.tag} (${t.status}): ${t.reason}`);
|
|
572
|
+
for (const r of c.ignoredRuns ?? [])
|
|
573
|
+
lines.push(` ignored run ${r.run} of ${r.op} in ${r.env} (${r.status}): ${r.reason}`);
|
|
574
|
+
if (c.blocked)
|
|
575
|
+
lines.push(` did not make ${c.blocked}: an ignored tag holds the name. Delete it to let the tick tag the commit: git push origin :refs/tags/${c.blocked}`);
|
|
292
576
|
}
|
|
577
|
+
if (r.signer && r.made.length > 0)
|
|
578
|
+
lines.push(`signed as ${r.signer}`);
|
|
293
579
|
return lines.join("\n");
|
|
294
580
|
}
|
|
581
|
+
// ── last-green ───────────────────────────────────────────────────────────────
|
|
582
|
+
/** The ref a reader walks: the remote-tracking branch when there is one, else the local branch. */
|
|
583
|
+
function branchRefOf(top, green, remote = "origin") {
|
|
584
|
+
const candidates = [`refs/remotes/${remote}/${green.branch}`, `refs/heads/${green.branch}`];
|
|
585
|
+
const ref = candidates.find((r) => tryGit(top, ["rev-parse", "--verify", "--quiet", `${r}^{commit}`]) !== null);
|
|
586
|
+
if (!ref)
|
|
587
|
+
throw new CiGreenError("ci-branch-unknown", `this repository has neither ${candidates.join(" nor ")}`);
|
|
588
|
+
return ref;
|
|
589
|
+
}
|
|
295
590
|
/**
|
|
296
591
|
* `chant ci last-green`: the newest first-parent commit of the branch with a
|
|
297
592
|
* green tag and no revoked tag. It reads local refs and tags and never
|
|
@@ -301,21 +596,28 @@ export function lastGreen(req) {
|
|
|
301
596
|
const head = { $schema: CI_LAST_GREEN_SCHEMA_ID, contract: CI_LAST_GREEN_CONTRACT_VERSION, chant: readerVersion() };
|
|
302
597
|
try {
|
|
303
598
|
const { green, top, workspace } = readGreen(req.cwd);
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
if (
|
|
307
|
-
|
|
308
|
-
|
|
599
|
+
const branchRef = branchRefOf(top, green, req.remote);
|
|
600
|
+
let ref = branchRef;
|
|
601
|
+
if (req.from !== undefined) {
|
|
602
|
+
if (req.from.startsWith("-") || tryGit(top, ["rev-parse", "--verify", "--quiet", `${req.from}^{commit}`]) === null) {
|
|
603
|
+
throw new WorkspaceReadError("revision-unknown", `--from ${req.from} names no commit in this repository`);
|
|
604
|
+
}
|
|
605
|
+
ref = req.from;
|
|
606
|
+
}
|
|
607
|
+
// The runner keys are read where the branch is, whichever commit the walk starts from.
|
|
608
|
+
const tags = readCiTags(top, green, git(top, ["rev-parse", `${branchRef}^{commit}`]));
|
|
309
609
|
let commit = null;
|
|
310
|
-
|
|
610
|
+
const ignored = [];
|
|
611
|
+
if (tags.green.size > 0 || tags.ignored.size > 0) {
|
|
311
612
|
for (const sha of git(top, ["rev-list", "--first-parent", ref]).split("\n")) {
|
|
613
|
+
ignored.push(...(tags.ignored.get(sha) ?? []));
|
|
312
614
|
if (!tags.green.has(sha) || tags.revoked.has(sha))
|
|
313
615
|
continue;
|
|
314
|
-
commit = { sha, tag: `${CI_GREEN_TAG_PREFIX}${sha}`, tagged: tags.green.get(sha)
|
|
616
|
+
commit = { sha, tag: `${CI_GREEN_TAG_PREFIX}${sha}`, tagged: tags.green.get(sha).tagged, committed: git(top, ["show", "-s", "--format=%cI", sha]) };
|
|
315
617
|
break;
|
|
316
618
|
}
|
|
317
619
|
}
|
|
318
|
-
return { ...head, workspace, branch: green.branch, ref, commit };
|
|
620
|
+
return { ...head, workspace, branch: green.branch, ref, commit, ignored };
|
|
319
621
|
}
|
|
320
622
|
catch (err) {
|
|
321
623
|
if (err instanceof CiGreenError || err instanceof WorkspaceReadError)
|
|
@@ -323,3 +625,112 @@ export function lastGreen(req) {
|
|
|
323
625
|
throw err;
|
|
324
626
|
}
|
|
325
627
|
}
|
|
628
|
+
// ── list ─────────────────────────────────────────────────────────────────────
|
|
629
|
+
export const CI_LIST_CONTRACT_VERSION = 1;
|
|
630
|
+
export const CI_LIST_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/ci-list/v1/ci-list.schema.json";
|
|
631
|
+
const asRun = (r) => {
|
|
632
|
+
if (!r || typeof r !== "object")
|
|
633
|
+
return null;
|
|
634
|
+
const o = r;
|
|
635
|
+
if (typeof o.name !== "string")
|
|
636
|
+
return null;
|
|
637
|
+
const op = o.opRun;
|
|
638
|
+
const opRun = op && typeof op === "object" && typeof op.id === "string" && typeof op.env === "string" && typeof op.signer === "string" ? { opRun: { id: op.id, env: op.env, signer: op.signer } } : {};
|
|
639
|
+
return { name: o.name, id: typeof o.id === "number" ? o.id : null, conclusion: typeof o.conclusion === "string" ? o.conclusion : null, url: typeof o.url === "string" ? o.url : null, ...opRun };
|
|
640
|
+
};
|
|
641
|
+
/** A judged run as `ci list` shows it. */
|
|
642
|
+
const listRun = (r) => ({ name: r.name, id: r.opRun ? null : r.id, conclusion: r.conclusion, url: r.url, ...(r.opRun ? { opRun: { id: r.opRun.id, env: r.opRun.env, signer: r.opRun.signer } } : {}) });
|
|
643
|
+
/** What the green tag's annotation recorded for a phase: its runs, all of which passed when the tag was made. */
|
|
644
|
+
function recordedPhase(green, phase) {
|
|
645
|
+
const runs = green?.record?.phases?.[phase];
|
|
646
|
+
if (!Array.isArray(runs))
|
|
647
|
+
return { phase, result: "unrecorded", runs: [] };
|
|
648
|
+
return { phase, result: "pass", runs: runs.map(asRun).filter((r) => r !== null) };
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* `chant ci list`: the branch's first-parent commits within the window, each
|
|
652
|
+
* with its state from the ci tags and each required phase's result. Tags
|
|
653
|
+
* give the green and revoked results offline; only undecided commits need
|
|
654
|
+
* the forge, and only when a `forge` is given. It never fetches.
|
|
655
|
+
*/
|
|
656
|
+
export async function ciList(req) {
|
|
657
|
+
const head = { $schema: CI_LIST_SCHEMA_ID, contract: CI_LIST_CONTRACT_VERSION, chant: readerVersion() };
|
|
658
|
+
try {
|
|
659
|
+
const { green, top, workspace } = readGreen(req.cwd);
|
|
660
|
+
const ref = branchRefOf(top, green, req.remote);
|
|
661
|
+
const logArgs = ["log", "--first-parent", "--format=%H %ct"];
|
|
662
|
+
let cutoff = 0;
|
|
663
|
+
if (req.since !== undefined) {
|
|
664
|
+
if (tryGit(top, ["rev-parse", "--verify", "--quiet", `${req.since}^{commit}`]) === null)
|
|
665
|
+
throw new WorkspaceReadError("revision-unknown", `--since ${req.since} names no commit in this repository`);
|
|
666
|
+
logArgs.push(`${req.since}..${ref}`);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
cutoff = Math.floor(((req.now ?? new Date()).getTime() - windowMs(green.window)) / 1000);
|
|
670
|
+
logArgs.push(`--max-age=${cutoff}`, ref);
|
|
671
|
+
}
|
|
672
|
+
let commits = git(top, logArgs)
|
|
673
|
+
.split("\n")
|
|
674
|
+
.filter(Boolean)
|
|
675
|
+
.map((line) => line.split(" "))
|
|
676
|
+
.filter(([, ct]) => Number(ct) >= cutoff)
|
|
677
|
+
.map(([sha, ct]) => ({ sha, committed: iso(new Date(Number(ct) * 1000)) }));
|
|
678
|
+
if (req.limit !== undefined)
|
|
679
|
+
commits = commits.slice(0, req.limit);
|
|
680
|
+
const links = await commitLinks(req.cwd, top, ref, commits.map((c) => c.sha));
|
|
681
|
+
const base = git(top, ["rev-parse", `${ref}^{commit}`]);
|
|
682
|
+
const tags = readCiTags(top, green, base);
|
|
683
|
+
const source = req.source ?? green.source;
|
|
684
|
+
if (source === "runs" && req.forge)
|
|
685
|
+
throw new Error("--forge reads check runs, and the source is chant runs (ci.green.source or --source runs); drop one");
|
|
686
|
+
const runs = source === "runs" ? await readOpRunSource(req.cwd, top, base) : undefined;
|
|
687
|
+
const out = [];
|
|
688
|
+
for (const c of commits) {
|
|
689
|
+
const g = tags.green.get(c.sha);
|
|
690
|
+
const r = tags.revoked.get(c.sha);
|
|
691
|
+
const state = r ? "revoked" : g ? "green" : "undecided";
|
|
692
|
+
let phases = green.require.map((name) => recordedPhase(g, name));
|
|
693
|
+
let revoked = null;
|
|
694
|
+
if (r) {
|
|
695
|
+
const phase = typeof r.record?.phase === "string" ? r.record.phase : null;
|
|
696
|
+
const run = asRun(r.record?.run);
|
|
697
|
+
revoked = { tag: `${CI_REVOKED_TAG_PREFIX}${c.sha}`, tagged: r.tagged, phase, run, signer: r.signer };
|
|
698
|
+
phases = phases.map((p) => (p.phase === phase ? { phase: p.phase, result: "fail", runs: run ? [run] : [] } : p));
|
|
699
|
+
}
|
|
700
|
+
else if (!g && (runs || req.forge)) {
|
|
701
|
+
const verdict = evaluateCommit(green, runs ? runs.checkRuns(c.sha) : await req.forge.checkRuns(c.sha));
|
|
702
|
+
phases = verdict.phases.map((p) => ({ phase: p.phase, result: p.verdict, runs: p.runs.map(listRun) }));
|
|
703
|
+
}
|
|
704
|
+
out.push({
|
|
705
|
+
...c,
|
|
706
|
+
state,
|
|
707
|
+
green: g ? { tag: `${CI_GREEN_TAG_PREFIX}${c.sha}`, tagged: g.tagged, signer: g.signer } : null,
|
|
708
|
+
revoked,
|
|
709
|
+
phases,
|
|
710
|
+
work: links?.get(c.sha) ?? null,
|
|
711
|
+
ignored: tags.ignored.get(c.sha) ?? [],
|
|
712
|
+
...(runs ? { ignoredRuns: runs.ignored.get(c.sha) ?? [] } : {}),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return { ...head, workspace, branch: green.branch, ref, window: req.since === undefined ? green.window : null, since: req.since ?? null, require: [...green.require], commits: out };
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
if (err instanceof CiGreenError || err instanceof WorkspaceReadError)
|
|
719
|
+
return { ...head, error: { code: err.code, message: err instanceof WorkspaceReadError ? err.describe() : err.message } };
|
|
720
|
+
throw err;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** The text `chant ci list` prints: one line per commit. */
|
|
724
|
+
export function formatList(doc) {
|
|
725
|
+
const lines = [`${doc.branch}, ${doc.since ? `commits after ${doc.since}` : `commits within ${doc.window}`}: ${doc.commits.length}`];
|
|
726
|
+
for (const c of doc.commits) {
|
|
727
|
+
const phases = c.phases.map((p) => `${p.phase}=${p.result}`).join(" ");
|
|
728
|
+
const why = whyWorkUnknown(c.work);
|
|
729
|
+
lines.push(` ${c.sha.slice(0, 12)} ${c.state.padEnd(9)} ${phases}${why ? `; why unknown: ${why}` : ""}`);
|
|
730
|
+
for (const t of c.ignored)
|
|
731
|
+
lines.push(` ignored ${t.tag} (${t.status}): ${t.reason}`);
|
|
732
|
+
for (const r of c.ignoredRuns ?? [])
|
|
733
|
+
lines.push(` ignored run ${r.run} of ${r.op} in ${r.env} (${r.status}): ${r.reason}`);
|
|
734
|
+
}
|
|
735
|
+
return lines.join("\n");
|
|
736
|
+
}
|