@intentius/chant 0.124.30 → 0.124.32
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/bin/chant +7 -2
- package/dist/cli/commands/check-lexicon-plugin.d.ts +7 -4
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-plugin.js +10 -7
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.js +18 -12
- package/dist/cli/commands/import.d.ts +10 -0
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/import.js +37 -0
- package/dist/cli/commands/init-lexicon/templates/project.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/project.js +4 -3
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/onboard.js +8 -4
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +2 -1
- package/dist/cli/dependencies.d.ts +18 -0
- package/dist/cli/dependencies.d.ts.map +1 -0
- package/dist/cli/dependencies.js +120 -0
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +23 -4
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +12 -4
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +16 -6
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lint.js +6 -0
- package/dist/cli/handlers/operator.d.ts +2 -2
- package/dist/cli/handlers/operator.js +3 -3
- package/dist/cli/handlers/pr.d.ts +14 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +488 -32
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +3 -1
- package/dist/cli/handlers/run.d.ts +13 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +38 -15
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +74 -12
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +65 -0
- package/dist/cli/registry.d.ts +29 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/registry.js +6 -2
- package/dist/codegen/generate-post-synth-barrel.js +2 -2
- package/dist/codegen/lexicon-regen.d.ts +4 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.js +1 -1
- package/dist/codegen/pinned-upgrade.js +2 -2
- package/dist/components/capability.d.ts +44 -3
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +21 -0
- package/dist/components/changed-files.d.ts +7 -1
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/changed-files.js +9 -1
- package/dist/components/driver.d.ts +38 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +47 -6
- package/dist/components/fan-out-record.d.ts +6 -1
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/fan-out-record.js +24 -0
- package/dist/components/fan-out-run.d.ts +20 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +21 -1
- package/dist/components/pr-pipeline.d.ts +65 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +107 -15
- package/dist/components/pr-run.d.ts +127 -29
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +290 -70
- package/dist/components/wave-plan.d.ts +5 -2
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +12 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-import.js +16 -6
- package/dist/discovery/sandbox/bundle.d.ts.map +1 -1
- package/dist/discovery/sandbox/bundle.js +6 -0
- package/dist/discovery/specifier-resolve.d.ts.map +1 -1
- package/dist/discovery/specifier-resolve.js +8 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/fold.js +68 -1
- package/dist/fold/subset.d.ts +37 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/fold/subset.js +88 -0
- package/dist/lexicon-module.d.ts +10 -0
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +53 -8
- package/dist/lexicon-plugin-helpers.d.ts.map +1 -1
- package/dist/lexicon-plugin-helpers.js +6 -4
- package/dist/lexicon.d.ts +2 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/affected.d.ts.map +1 -1
- package/dist/lifecycle/affected.js +7 -1
- package/dist/lifecycle/release-ledger.d.ts +21 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +24 -0
- package/dist/lifecycle/work-dir.d.ts +45 -0
- package/dist/lifecycle/work-dir.d.ts.map +1 -0
- package/dist/lifecycle/work-dir.js +78 -0
- package/dist/lint/discover.d.ts +17 -9
- package/dist/lint/discover.d.ts.map +1 -1
- package/dist/lint/discover.js +62 -54
- package/dist/module-source.d.ts +57 -0
- package/dist/module-source.d.ts.map +1 -0
- package/dist/module-source.js +104 -0
- package/dist/op/activities/factory.js +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/discover.js +13 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/local-executor.d.ts +12 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +22 -3
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +20 -5
- package/dist/op/operator.d.ts +2 -2
- package/dist/op/operator.js +2 -2
- package/dist/op/redact.d.ts +34 -0
- package/dist/op/redact.d.ts.map +1 -0
- package/dist/op/redact.js +94 -0
- package/dist/op/step-console.d.ts +2 -0
- package/dist/op/step-console.d.ts.map +1 -1
- package/dist/op/step-console.js +5 -2
- package/dist/op/steward.d.ts +6 -4
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/op/steward.js +5 -3
- package/dist/op/work-lease-run.d.ts +20 -7
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/op/work-lease-run.js +56 -12
- package/dist/pr-forge.d.ts +27 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +51 -6
- package/dist/pr-loop.d.ts +141 -4
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +122 -6
- package/dist/pr-report-url.d.ts +10 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +52 -6
- package/dist/ts-loader.d.ts +38 -4
- package/dist/ts-loader.d.ts.map +1 -1
- package/dist/ts-loader.js +190 -16
- package/dist/wave-runtime.mjs +208 -208
- package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
- package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -1
- package/dist/workspace/chant-migrations.js +2 -0
- package/dist/workspace/checks/boxes.d.ts +6 -6
- package/dist/workspace/checks/boxes.js +9 -9
- package/dist/workspace/ci-green-forge.d.ts +6 -4
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +28 -12
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +49 -21
- package/dist/workspace/ci-list.schema.json +12 -6
- package/dist/workspace/ci-report-url.d.ts +18 -0
- package/dist/workspace/ci-report-url.d.ts.map +1 -0
- package/dist/workspace/ci-report-url.js +33 -0
- package/dist/workspace/ci-run-index.d.ts +108 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -0
- package/dist/workspace/ci-run-index.js +164 -0
- package/dist/workspace/ci-run-index.schema.json +52 -0
- package/dist/workspace/ci-run.d.ts +159 -9
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +419 -50
- package/dist/workspace/ci-runner-key.d.ts +133 -0
- package/dist/workspace/ci-runner-key.d.ts.map +1 -0
- package/dist/workspace/ci-runner-key.js +237 -0
- package/dist/workspace/ci-secrets.d.ts +65 -0
- package/dist/workspace/ci-secrets.d.ts.map +1 -0
- package/dist/workspace/ci-secrets.js +211 -0
- package/dist/workspace/ci-serve.d.ts +67 -4
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +240 -35
- package/dist/workspace/declaration.d.ts +8 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +11 -2
- package/dist/workspace/declaration.schema.json +7 -2
- package/dist/workspace/export.d.ts +1 -1
- package/dist/workspace/export.d.ts.map +1 -1
- package/dist/workspace/export.js +7 -3
- package/dist/workspace/ls.d.ts +6 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -0
- package/dist/workspace/ls.schema.json +7 -1
- package/dist/workspace/pin-integrity.d.ts.map +1 -1
- package/dist/workspace/pin-integrity.js +3 -2
- package/dist/workspace/pr-report.schema.json +74 -7
- package/dist/workspace/reason-codes.d.ts +7 -3
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +9 -4
- package/dist/workspace/run-event.schema.json +3 -1
- package/dist/workspace/status.schema.json +3 -1
- package/dist/workspace/trust/trust-write.d.ts +109 -0
- package/dist/workspace/trust/trust-write.d.ts.map +1 -0
- package/dist/workspace/trust/trust-write.js +320 -0
- package/dist/workspace/trust-write.schema.json +265 -0
- package/dist/workspace/write-scope.d.ts +9 -0
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -1
- package/package.json +1 -1
- package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
- package/src/cli/commands/audit.test.ts +2 -2
- package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
- package/src/cli/commands/check-lexicon-plugin.ts +11 -8
- package/src/cli/commands/check-lexicon.test.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +16 -11
- package/src/cli/commands/import.ts +40 -0
- package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
- package/src/cli/commands/init-lexicon.test.ts +8 -5
- package/src/cli/commands/onboard.test.ts +45 -0
- package/src/cli/commands/onboard.ts +8 -4
- package/src/cli/commands/update.test.ts +6 -0
- package/src/cli/commands/update.ts +4 -1
- package/src/cli/compiled-cli.e2e.test.ts +76 -11
- package/src/cli/dependencies.e2e.test.ts +101 -0
- package/src/cli/dependencies.test.ts +93 -0
- package/src/cli/dependencies.ts +122 -0
- package/src/cli/handlers/build.test.ts +69 -0
- package/src/cli/handlers/build.ts +20 -4
- package/src/cli/handlers/fan-out.test.ts +73 -0
- package/src/cli/handlers/fan-out.ts +11 -4
- package/src/cli/handlers/graph.test.ts +76 -0
- package/src/cli/handlers/graph.ts +19 -12
- package/src/cli/handlers/lint.test.ts +42 -0
- package/src/cli/handlers/lint.ts +6 -0
- package/src/cli/handlers/operator.ts +3 -3
- package/src/cli/handlers/pr-waves.test.ts +449 -0
- package/src/cli/handlers/pr.test.ts +134 -0
- package/src/cli/handlers/pr.ts +523 -31
- package/src/cli/handlers/promote.test.ts +26 -1
- package/src/cli/handlers/promote.ts +3 -2
- package/src/cli/handlers/run-components-exit.test.ts +99 -0
- package/src/cli/handlers/run-signer.test.ts +79 -0
- package/src/cli/handlers/run.ts +34 -16
- package/src/cli/main.test.ts +19 -0
- package/src/cli/main.ts +73 -12
- package/src/cli/parse-args.ts +48 -0
- package/src/cli/registry.ts +35 -4
- package/src/codegen/generate-post-synth-barrel.ts +2 -2
- package/src/codegen/lexicon-regen.ts +1 -1
- package/src/codegen/pinned-upgrade.ts +2 -2
- package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
- package/src/components/capability.ts +51 -3
- package/src/components/changed-files.test.ts +16 -1
- package/src/components/changed-files.ts +9 -1
- package/src/components/driver.test.ts +36 -1
- package/src/components/driver.ts +76 -4
- package/src/components/fan-out-record.ts +29 -1
- package/src/components/fan-out-run.test.ts +35 -0
- package/src/components/fan-out-run.ts +40 -1
- package/src/components/pr-pipeline.test.ts +100 -0
- package/src/components/pr-pipeline.ts +132 -17
- package/src/components/pr-run.test.ts +323 -18
- package/src/components/pr-run.ts +354 -75
- package/src/components/wave-plan.ts +16 -4
- package/src/discovery/fold-host-method.test.ts +87 -0
- package/src/discovery/fold-import.ts +15 -5
- package/src/discovery/fold-package-composite.test.ts +27 -0
- package/src/discovery/fold-specifiers.test.ts +8 -0
- package/src/discovery/sandbox/bundle.ts +6 -0
- package/src/discovery/specifier-resolve.ts +8 -2
- package/src/fold/fold.test.ts +72 -0
- package/src/fold/fold.ts +71 -0
- package/src/fold/subset.test.ts +31 -0
- package/src/fold/subset.ts +99 -0
- package/src/lexicon-dist.e2e.test.ts +254 -0
- package/src/lexicon-module.test.ts +47 -0
- package/src/lexicon-module.ts +49 -7
- package/src/lexicon-plugin-helpers.ts +6 -4
- package/src/lexicon.ts +2 -0
- package/src/lifecycle/affected.test.ts +7 -0
- package/src/lifecycle/affected.ts +6 -1
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lifecycle/work-dir.test.ts +77 -0
- package/src/lifecycle/work-dir.ts +82 -0
- package/src/lint/discover.test.ts +46 -2
- package/src/lint/discover.ts +55 -49
- package/src/module-source.test.ts +85 -0
- package/src/module-source.ts +112 -0
- package/src/op/activities/factory.ts +1 -1
- package/src/op/discover-root.test.ts +31 -2
- package/src/op/discover.ts +14 -4
- package/src/op/gate.test.ts +30 -0
- package/src/op/gate.ts +3 -0
- package/src/op/local-executor.ts +31 -3
- package/src/op/op-waves-run.ts +20 -5
- package/src/op/op-waves.test.ts +49 -0
- package/src/op/operator.ts +2 -2
- package/src/op/redact.ts +93 -0
- package/src/op/step-console.ts +7 -2
- package/src/op/steward.ts +6 -4
- package/src/op/work-lease-run.test.ts +88 -2
- package/src/op/work-lease-run.ts +52 -12
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.test.ts +71 -0
- package/src/pr-forge.ts +70 -6
- package/src/pr-loop-bundle.test.ts +2 -1
- package/src/pr-loop.test.ts +117 -1
- package/src/pr-loop.ts +252 -10
- package/src/pr-report-url.ts +58 -6
- package/src/terraform/__fixtures__/advise.test.ts +21 -2
- package/src/ts-loader.test.ts +89 -1
- package/src/ts-loader.ts +180 -13
- package/src/workspace/changes.test.ts +1 -1
- package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
- package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
- package/src/workspace/chant-migrations.e2e.test.ts +5 -0
- package/src/workspace/chant-migrations.ts +2 -0
- package/src/workspace/checks/boxes.ts +9 -9
- package/src/workspace/ci-green-forge.ts +6 -4
- package/src/workspace/ci-green-runs.test.ts +53 -11
- package/src/workspace/ci-green.test.ts +1 -1
- package/src/workspace/ci-green.ts +50 -22
- package/src/workspace/ci-list.schema.json +12 -6
- package/src/workspace/ci-report-url.test.ts +220 -0
- package/src/workspace/ci-report-url.ts +33 -0
- package/src/workspace/ci-run-index.schema.json +52 -0
- package/src/workspace/ci-run-index.test.ts +392 -0
- package/src/workspace/ci-run-index.ts +206 -0
- package/src/workspace/ci-run.test.ts +520 -42
- package/src/workspace/ci-run.ts +483 -51
- package/src/workspace/ci-runner-key.test.ts +204 -0
- package/src/workspace/ci-runner-key.ts +275 -0
- package/src/workspace/ci-secrets.test.ts +106 -0
- package/src/workspace/ci-secrets.ts +213 -0
- package/src/workspace/ci-serve.test.ts +282 -39
- package/src/workspace/ci-serve.ts +231 -34
- package/src/workspace/declaration.schema.json +7 -2
- package/src/workspace/declaration.test.ts +23 -0
- package/src/workspace/declaration.ts +19 -3
- package/src/workspace/export.ts +6 -3
- package/src/workspace/ls.schema.json +7 -1
- package/src/workspace/ls.ts +7 -2
- package/src/workspace/pin-integrity.test.ts +5 -0
- package/src/workspace/pin-integrity.ts +3 -2
- package/src/workspace/pr-report.schema.json +74 -7
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +9 -4
- package/src/workspace/run-event.schema.json +3 -1
- package/src/workspace/status.schema.json +3 -1
- package/src/workspace/trust/trust-write.test.ts +179 -0
- package/src/workspace/trust/trust-write.ts +377 -0
- package/src/workspace/trust-write.schema.json +265 -0
- package/src/workspace/write-scope.test.ts +29 -1
- package/src/workspace/write-scope.ts +12 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chant runner's secrets file (#4096, epic #4101): the values a job gets
|
|
3
|
+
* from the host, such as cloud credentials and the endpoint, which a forge
|
|
4
|
+
* job would get from the forge's secrets.
|
|
5
|
+
*
|
|
6
|
+
* `chant ci run` and `chant ci serve` read it from `--secrets <file>`, or
|
|
7
|
+
* the file `CHANT_CI_SECRETS` names. It is a dotenv file of `NAME=value`
|
|
8
|
+
* lines, or a JSON object:
|
|
9
|
+
*
|
|
10
|
+
* - a map of name to value, `{ "AWS_SECRET_ACCESS_KEY": "..." }`;
|
|
11
|
+
* - or `{ "secrets": { ... }, "jobs": { "plan": [...], "apply": [...] } }`,
|
|
12
|
+
* where `jobs` lists, for a job, the names it gets. A job `jobs` doesn't
|
|
13
|
+
* name gets every secret.
|
|
14
|
+
*
|
|
15
|
+
* The allowlist lives in this file on the host, not in the repository, so a
|
|
16
|
+
* pull request can't widen what its plan gets.
|
|
17
|
+
*
|
|
18
|
+
* The file must belong to the user reading it and give group and others no
|
|
19
|
+
* permission at all, as ssh asks of a private key (`chmod 600`). A file
|
|
20
|
+
* that does is refused before any job runs.
|
|
21
|
+
*
|
|
22
|
+
* Every value in the file, and every value of the job's environment whose
|
|
23
|
+
* name looks secret ({@link SECRET_NAME}), is redacted from what the run
|
|
24
|
+
* writes about itself (../op/redact.ts).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync, statSync } from "node:fs";
|
|
28
|
+
import { redactorOf, type Redact } from "../op/redact";
|
|
29
|
+
import { RUNNER_KEY_ENV } from "./ci-green";
|
|
30
|
+
import type { CiJobName } from "./ci-run";
|
|
31
|
+
|
|
32
|
+
/** The variable that names the secrets file when `--secrets` is not given. */
|
|
33
|
+
export const CI_SECRETS_ENV = "CHANT_CI_SECRETS";
|
|
34
|
+
|
|
35
|
+
/** A variable whose name matches this is taken as secret, and its value redacted. */
|
|
36
|
+
export const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD/i;
|
|
37
|
+
|
|
38
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
39
|
+
|
|
40
|
+
const JOBS: readonly CiJobName[] = ["plan", "apply"];
|
|
41
|
+
|
|
42
|
+
/** The secrets file, read. */
|
|
43
|
+
export interface CiSecrets {
|
|
44
|
+
/** Where it was read from. */
|
|
45
|
+
file: string;
|
|
46
|
+
/** Every secret, by name. */
|
|
47
|
+
values: Record<string, string>;
|
|
48
|
+
/** For a job the file names, the secrets it gets. A job missing here gets every one. */
|
|
49
|
+
jobs: Partial<Record<CiJobName, string[]>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class CiSecretsError extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "CiSecretsError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refuse a secrets file anyone but its owner can read or write, or that
|
|
61
|
+
* belongs to another user, as ssh refuses a loose key. Not checked on
|
|
62
|
+
* Windows, whose files carry no such mode.
|
|
63
|
+
*/
|
|
64
|
+
export function checkCiSecretsMode(file: string): void {
|
|
65
|
+
let st: ReturnType<typeof statSync>;
|
|
66
|
+
try {
|
|
67
|
+
st = statSync(file);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new CiSecretsError(`the secrets file ${file} can't be read: ${(err as NodeJS.ErrnoException).code ?? String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!st.isFile()) throw new CiSecretsError(`the secrets file ${file} is not a file`);
|
|
72
|
+
if (process.platform === "win32") return;
|
|
73
|
+
const loose = Number(st.mode) & 0o077;
|
|
74
|
+
if (loose !== 0) {
|
|
75
|
+
const mode = (Number(st.mode) & 0o777).toString(8).padStart(4, "0");
|
|
76
|
+
throw new CiSecretsError(
|
|
77
|
+
`the secrets file ${file} has mode ${mode}, so its group or other users can ${loose & 0o044 ? "read" : "change"} it; a secrets file must be readable only by its owner, as ssh asks of a key: chmod 600 ${file}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
81
|
+
if (uid !== undefined && Number(st.uid) !== uid) {
|
|
82
|
+
throw new CiSecretsError(`the secrets file ${file} belongs to uid ${st.uid}, not to this user (uid ${uid}); a secrets file must be its reader's own`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A dotenv text's `NAME=value` pairs. Errors name the line, never a value. */
|
|
87
|
+
export function parseDotenv(text: string, file: string): Record<string, string> {
|
|
88
|
+
const out: Record<string, string> = {};
|
|
89
|
+
let i = 0;
|
|
90
|
+
let line = 1;
|
|
91
|
+
const fail = (why: string): never => {
|
|
92
|
+
throw new CiSecretsError(`${file}:${line}: ${why}`);
|
|
93
|
+
};
|
|
94
|
+
while (i < text.length) {
|
|
95
|
+
// One line: blank, a comment, or NAME=value.
|
|
96
|
+
const eol = text.indexOf("\n", i);
|
|
97
|
+
const rest = text.slice(i, eol === -1 ? text.length : eol);
|
|
98
|
+
const trimmed = rest.trim();
|
|
99
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
100
|
+
i = eol === -1 ? text.length : eol + 1;
|
|
101
|
+
line++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/.exec(rest);
|
|
105
|
+
if (!m) fail("expected NAME=value");
|
|
106
|
+
const name = m![1]!;
|
|
107
|
+
let j = i + m![0].length;
|
|
108
|
+
const startLine = line;
|
|
109
|
+
let value = "";
|
|
110
|
+
const q = text[j];
|
|
111
|
+
if (q === '"' || q === "'") {
|
|
112
|
+
j++;
|
|
113
|
+
for (;;) {
|
|
114
|
+
if (j >= text.length) {
|
|
115
|
+
line = startLine;
|
|
116
|
+
fail(`the value of ${name} has no closing ${q}`);
|
|
117
|
+
}
|
|
118
|
+
const c = text[j]!;
|
|
119
|
+
if (c === q) break;
|
|
120
|
+
if (c === "\n") line++;
|
|
121
|
+
if (q === '"' && c === "\\" && j + 1 < text.length) {
|
|
122
|
+
const n = text[j + 1]!;
|
|
123
|
+
value += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? "\t" : n;
|
|
124
|
+
j += 2;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
value += c;
|
|
128
|
+
j++;
|
|
129
|
+
}
|
|
130
|
+
j++;
|
|
131
|
+
const end = text.indexOf("\n", j);
|
|
132
|
+
const after = text.slice(j, end === -1 ? text.length : end).trim();
|
|
133
|
+
if (after !== "" && !after.startsWith("#")) fail(`unexpected text after the quoted value of ${name}`);
|
|
134
|
+
i = end === -1 ? text.length : end + 1;
|
|
135
|
+
} else {
|
|
136
|
+
const end = text.indexOf("\n", j);
|
|
137
|
+
value = text.slice(j, end === -1 ? text.length : end).replace(/\s+#.*$/, "").trim();
|
|
138
|
+
i = end === -1 ? text.length : end + 1;
|
|
139
|
+
}
|
|
140
|
+
if (Object.prototype.hasOwnProperty.call(out, name)) fail(`${name} is set twice`);
|
|
141
|
+
out[name] = value;
|
|
142
|
+
line++;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function checkName(name: string, file: string): void {
|
|
148
|
+
if (!ENV_NAME.test(name)) throw new CiSecretsError(`${file} names a secret ${JSON.stringify(name)}, which is not a variable name`);
|
|
149
|
+
if (name === RUNNER_KEY_ENV) throw new CiSecretsError(`${file} sets ${RUNNER_KEY_ENV}, and the runner key is never passed to a job; give it to ci run or ci serve with --key or ${RUNNER_KEY_ENV}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The secrets in a file's text: JSON when it starts with `{`, else dotenv. */
|
|
153
|
+
export function parseCiSecrets(text: string, file: string): Omit<CiSecrets, "file"> {
|
|
154
|
+
if (!text.trimStart().startsWith("{")) {
|
|
155
|
+
const values = parseDotenv(text, file);
|
|
156
|
+
for (const name of Object.keys(values)) checkName(name, file);
|
|
157
|
+
return { values, jobs: {} };
|
|
158
|
+
}
|
|
159
|
+
let raw: unknown;
|
|
160
|
+
try {
|
|
161
|
+
raw = JSON.parse(text);
|
|
162
|
+
} catch {
|
|
163
|
+
// The parser's message quotes the text near the error, which may be a secret.
|
|
164
|
+
throw new CiSecretsError(`${file} starts with {, and is not valid JSON`);
|
|
165
|
+
}
|
|
166
|
+
const doc = raw as Record<string, unknown>;
|
|
167
|
+
const structured = doc.secrets !== null && typeof doc.secrets === "object" && !Array.isArray(doc.secrets);
|
|
168
|
+
const map = (structured ? doc.secrets : doc) as Record<string, unknown>;
|
|
169
|
+
const values: Record<string, string> = {};
|
|
170
|
+
for (const [name, value] of Object.entries(map)) {
|
|
171
|
+
checkName(name, file);
|
|
172
|
+
if (typeof value !== "string") throw new CiSecretsError(`${file}: the secret ${name} must be a string`);
|
|
173
|
+
values[name] = value;
|
|
174
|
+
}
|
|
175
|
+
const jobs: CiSecrets["jobs"] = {};
|
|
176
|
+
if (structured) {
|
|
177
|
+
for (const key of Object.keys(doc)) if (key !== "secrets" && key !== "jobs") throw new CiSecretsError(`${file} has ${JSON.stringify(key)} beside secrets and jobs`);
|
|
178
|
+
const given = doc.jobs ?? {};
|
|
179
|
+
if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map plan and apply to lists of secret names`);
|
|
180
|
+
for (const [job, names] of Object.entries(given as Record<string, unknown>)) {
|
|
181
|
+
if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are plan and apply`);
|
|
182
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw new CiSecretsError(`${file}: jobs.${job} must be a list of secret names`);
|
|
183
|
+
for (const n of names as string[]) if (!Object.prototype.hasOwnProperty.call(values, n)) throw new CiSecretsError(`${file}: jobs.${job} names ${n}, which the file does not set`);
|
|
184
|
+
jobs[job as CiJobName] = [...(names as string[])];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { values, jobs };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Read the secrets file, refusing one others can read. */
|
|
191
|
+
export function readCiSecrets(file: string): CiSecrets {
|
|
192
|
+
checkCiSecretsMode(file);
|
|
193
|
+
return { file, ...parseCiSecrets(readFileSync(file, "utf-8"), file) };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The secrets `job` gets: those `jobs` lists for it, else every one. */
|
|
197
|
+
export function ciSecretsFor(secrets: CiSecrets | undefined, job: CiJobName): Record<string, string> {
|
|
198
|
+
if (!secrets) return {};
|
|
199
|
+
const names = secrets.jobs[job] ?? Object.keys(secrets.values);
|
|
200
|
+
return Object.fromEntries(names.map((n) => [n, secrets.values[n]!]));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The redactor for a job: every value in the secrets file, whether the job
|
|
205
|
+
* gets it or not, and the value of each variable in the job's environment
|
|
206
|
+
* whose name looks secret, such as `GITHUB_TOKEN` or a `--pass-env
|
|
207
|
+
* AWS_SECRET_ACCESS_KEY`. Undefined when there is nothing to redact.
|
|
208
|
+
*/
|
|
209
|
+
export function ciRedactor(jobEnv: Record<string, string>, secrets?: CiSecrets): Redact | undefined {
|
|
210
|
+
const values = Object.values(secrets?.values ?? {});
|
|
211
|
+
for (const [name, value] of Object.entries(jobEnv)) if (SECRET_NAME.test(name)) values.push(value);
|
|
212
|
+
return redactorOf(values);
|
|
213
|
+
}
|
|
@@ -10,31 +10,35 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
|
-
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { afterAll, describe, expect, test } from "vitest";
|
|
16
|
-
import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
16
|
+
import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
17
17
|
import { ciServeInterval, ciServeKey, ciServePass, formatCiServeEvent, readCiServeState, type CiServeEvent, type CiServeForge, type CiServeOptions } from "./ci-serve";
|
|
18
|
+
import { ciRun, type CiRunResult } from "./ci-run";
|
|
18
19
|
import { acquireLease, releaseLease } from "../lifecycle/lease";
|
|
19
20
|
import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
|
|
20
21
|
import { parseArgs } from "../cli/parse-args";
|
|
21
|
-
import type
|
|
22
|
+
import { forgejoForge, githubForge, type ForgeFetch, type OpenPullRequest } from "../pr-forge";
|
|
22
23
|
|
|
23
24
|
afterAll(cleanScratch);
|
|
24
25
|
|
|
25
|
-
function commit(root: string, file: string): string {
|
|
26
|
+
function commit(root: string, file: string, message = file): string {
|
|
26
27
|
writeFiles(root, { [file]: `${file} ${Math.random()}\n` });
|
|
27
|
-
for (const args of [["add", "-A"], ["commit", "-q", "-m",
|
|
28
|
+
for (const args of [["add", "-A"], ["commit", "-q", "-m", message]]) {
|
|
28
29
|
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: root, stdio: "ignore" });
|
|
29
30
|
}
|
|
30
31
|
return git(root, "rev-parse", "HEAD");
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
const
|
|
34
|
+
/** The repository every test starts from, built once: a checkout on main with a bare origin and member `app`. */
|
|
35
|
+
const workspaceTemplate = repoFixture(() => {
|
|
36
|
+
const top = scratchDir("chant-ci-serve-template-");
|
|
37
|
+
const origin = join(top, "origin");
|
|
38
|
+
const root = join(top, "root");
|
|
39
|
+
mkdirSync(origin);
|
|
40
|
+
mkdirSync(root);
|
|
36
41
|
git(origin, "init", "-q", "--bare");
|
|
37
|
-
const root = scratchDir("chant-ci-serve-");
|
|
38
42
|
git(root, "init", "-q");
|
|
39
43
|
git(root, "symbolic-ref", "HEAD", "refs/heads/main");
|
|
40
44
|
writeFiles(root, {
|
|
@@ -43,9 +47,18 @@ function workspace(): { root: string; app: string; main: string } {
|
|
|
43
47
|
"app/a.txt": "a\n",
|
|
44
48
|
});
|
|
45
49
|
git(root, "remote", "add", "origin", origin);
|
|
46
|
-
|
|
50
|
+
commit(root, "app/init.txt");
|
|
47
51
|
git(root, "push", "-q", "origin", "main");
|
|
48
|
-
return
|
|
52
|
+
return top;
|
|
53
|
+
}, "chant-ci-serve-");
|
|
54
|
+
|
|
55
|
+
/** A copy of the template, its origin pointed at the copy of the bare one. */
|
|
56
|
+
function workspace(): { root: string; app: string; main: string; origin: string } {
|
|
57
|
+
const top = workspaceTemplate();
|
|
58
|
+
const root = join(top, "root");
|
|
59
|
+
const origin = join(top, "origin");
|
|
60
|
+
git(root, "remote", "set-url", "origin", origin);
|
|
61
|
+
return { root, app: join(root, "app"), main: git(root, "rev-parse", "HEAD"), origin };
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/** A pull request's head commit, on a branch off main, pushed to the forge's refs/pull/<n>/head. */
|
|
@@ -86,9 +99,17 @@ function fakeChant(): string {
|
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
/** A forge whose open pull requests and branch head the test sets. */
|
|
89
|
-
function fakeForge(head: string): CiServeForge & { head: string; prs: OpenPullRequest[]; polls: number } {
|
|
102
|
+
function fakeForge(head: string): CiServeForge & { head: string; prs: OpenPullRequest[]; polls: number; merges: Map<string, number | "throw">; asked: string[] } {
|
|
90
103
|
const f = {
|
|
91
104
|
head,
|
|
105
|
+
merges: new Map<string, number | "throw">(),
|
|
106
|
+
asked: [] as string[],
|
|
107
|
+
async pullRequestFor(sha: string) {
|
|
108
|
+
f.asked.push(sha);
|
|
109
|
+
const n = f.merges.get(sha);
|
|
110
|
+
if (n === "throw") throw new Error("forge unavailable");
|
|
111
|
+
return n ?? null;
|
|
112
|
+
},
|
|
92
113
|
prs: [] as OpenPullRequest[],
|
|
93
114
|
polls: 0,
|
|
94
115
|
async openPullRequests(base: string) {
|
|
@@ -130,6 +151,29 @@ function serve(app: string, client: CiServeForge, extra: Partial<CiServeOptions>
|
|
|
130
151
|
};
|
|
131
152
|
}
|
|
132
153
|
|
|
154
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
155
|
+
|
|
156
|
+
/** A commit on no branch, with `parent` when given, as a ledger commit stands in. */
|
|
157
|
+
const ledgerCommit = (root: string, message: string, parent?: string): string => git(root, "commit-tree", EMPTY_TREE, ...(parent ? ["-p", parent] : []), "-m", message);
|
|
158
|
+
|
|
159
|
+
/** Seed `chant/lifecycle` in the clone and on the forge, as an earlier apply left it. */
|
|
160
|
+
function seedLedger(root: string): string {
|
|
161
|
+
const seed = ledgerCommit(root, "seed");
|
|
162
|
+
git(root, "update-ref", "refs/heads/chant/lifecycle", seed);
|
|
163
|
+
git(root, "push", "-q", "origin", "chant/lifecycle");
|
|
164
|
+
expect(git(root, "rev-parse", "refs/remotes/origin/chant/lifecycle")).toBe(seed);
|
|
165
|
+
return seed;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const isAncestor = (cwd: string, a: string, b: string): boolean => {
|
|
169
|
+
try {
|
|
170
|
+
git(cwd, "merge-base", "--is-ancestor", a, b);
|
|
171
|
+
return true;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
133
177
|
const argsOf = (report: string, stage: string): string[] => readFileSync(join(report, `${stage}.args`), "utf-8").trimEnd().split("\n");
|
|
134
178
|
const ofType = <T extends CiServeEvent["type"]>(events: CiServeEvent[], type: T) => events.filter((e): e is Extract<CiServeEvent, { type: T }> => e.type === type);
|
|
135
179
|
|
|
@@ -185,6 +229,70 @@ describe("chant ci serve (#4095)", () => {
|
|
|
185
229
|
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
186
230
|
});
|
|
187
231
|
|
|
232
|
+
test("an apply gets the number of the pull request whose merge commit it is (#4112)", async () => {
|
|
233
|
+
const { root, app, main } = workspace();
|
|
234
|
+
const forge = fakeForge(main);
|
|
235
|
+
const seen: Array<number | undefined> = [];
|
|
236
|
+
const { opts } = serve(app, forge, { run: (req) => (seen.push(req.pr), ciRun(req)) });
|
|
237
|
+
await ciServePass(opts);
|
|
238
|
+
const merged = commit(root, "app/merged.txt");
|
|
239
|
+
git(root, "push", "-q", "origin", "main");
|
|
240
|
+
forge.head = merged;
|
|
241
|
+
forge.merges.set(merged, 17);
|
|
242
|
+
|
|
243
|
+
const pass = await ciServePass(opts);
|
|
244
|
+
expect(forge.asked).toEqual([merged]);
|
|
245
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, pr: 17, status: "ok" })]);
|
|
246
|
+
expect(seen).toEqual([17]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/** A serve over a repository whose baseline pass is done, with the `pr` each run was asked for in `seen`. */
|
|
250
|
+
async function mergeWithoutPr() {
|
|
251
|
+
const { root, app, main } = workspace();
|
|
252
|
+
const forge = fakeForge(main);
|
|
253
|
+
const seen: Array<number | undefined> = [];
|
|
254
|
+
const { opts } = serve(app, forge, { run: (req) => (seen.push(req.pr), ciRun(req)) });
|
|
255
|
+
await ciServePass(opts);
|
|
256
|
+
return { root, forge, seen, opts };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
test("an apply with no pull request found runs without --pr (#4112)", async () => {
|
|
260
|
+
const { root, forge, seen, opts } = await mergeWithoutPr();
|
|
261
|
+
const direct = commit(root, "app/direct.txt");
|
|
262
|
+
git(root, "push", "-q", "origin", "main");
|
|
263
|
+
forge.head = direct;
|
|
264
|
+
const pass = await ciServePass(opts);
|
|
265
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: direct, status: "ok" })]);
|
|
266
|
+
expect(pass.jobs[0]!.pr).toBeUndefined();
|
|
267
|
+
expect(seen).toEqual([undefined]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("an apply whose pull request lookup fails runs without --pr (#4112)", async () => {
|
|
271
|
+
const { root, forge, seen, opts } = await mergeWithoutPr();
|
|
272
|
+
const broken = commit(root, "app/broken.txt");
|
|
273
|
+
git(root, "push", "-q", "origin", "main");
|
|
274
|
+
forge.head = broken;
|
|
275
|
+
forge.merges.set(broken, "throw");
|
|
276
|
+
const pass = await ciServePass(opts);
|
|
277
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: broken, status: "ok" })]);
|
|
278
|
+
expect(pass.jobs[0]!.pr).toBeUndefined();
|
|
279
|
+
expect(seen).toEqual([undefined]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("the forge clients look a merge commit up with one GET each (#4112)", async () => {
|
|
283
|
+
const sha = "c".repeat(40);
|
|
284
|
+
const calls: string[] = [];
|
|
285
|
+
const fetch = (body: unknown): ForgeFetch => async (url, init) => {
|
|
286
|
+
calls.push(`${init.method} ${new URL(url).pathname}`);
|
|
287
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(body) };
|
|
288
|
+
};
|
|
289
|
+
const gh: CiServeForge = githubForge({ apiBase: "https://api.github.com", repo: "demo/estate", token: "t", fetch: fetch([{ number: 5, merged_at: "2026-10-01T00:00:00Z", merge_commit_sha: sha }]) }) as unknown as CiServeForge;
|
|
290
|
+
expect(await gh.pullRequestFor!(sha)).toBe(5);
|
|
291
|
+
const fj: CiServeForge = forgejoForge({ apiBase: "http://forgejo.localhost/api/v1", host: "forgejo.localhost", repo: "demo/estate", token: "t", fetch: fetch({ number: 6 }) }) as unknown as CiServeForge;
|
|
292
|
+
expect(await fj.pullRequestFor!(sha)).toBe(6);
|
|
293
|
+
expect(calls).toEqual([`GET /repos/demo/estate/commits/${sha}/pulls`, `GET /api/v1/repos/demo/estate/commits/${sha}/pull`]);
|
|
294
|
+
});
|
|
295
|
+
|
|
188
296
|
test("the group's lease held elsewhere holds the apply until it is released", async () => {
|
|
189
297
|
const { root, app, main } = workspace();
|
|
190
298
|
const forge = fakeForge(main);
|
|
@@ -209,33 +317,48 @@ describe("chant ci serve (#4095)", () => {
|
|
|
209
317
|
expect((await acquireLease(group, "after", { cwd: app, mode: "claim" })).acquired).toBe(true);
|
|
210
318
|
});
|
|
211
319
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
320
|
+
describe("a failed apply", () => {
|
|
321
|
+
// The failing apply runs once in beforeAll, and the two tests read what it left, so no test
|
|
322
|
+
// runs two jobs. A file's beforeAll is not counted in the unit budget (#2817).
|
|
323
|
+
let w: ReturnType<typeof workspace>;
|
|
324
|
+
let forge: ReturnType<typeof fakeForge>;
|
|
325
|
+
let failing: ReturnType<typeof serve>;
|
|
326
|
+
let merged: string;
|
|
327
|
+
let failed: Awaited<ReturnType<typeof ciServePass>>;
|
|
328
|
+
let record: string;
|
|
329
|
+
|
|
330
|
+
beforeAll(async () => {
|
|
331
|
+
w = workspace();
|
|
332
|
+
forge = fakeForge(w.main);
|
|
333
|
+
failing = serve(w.app, forge, { hostEnv: { FAKE_EXIT: "3", FAKE_ATTEMPT: "1" } });
|
|
334
|
+
await ciServePass(failing.opts);
|
|
335
|
+
merged = commit(w.root, "app/merged.txt");
|
|
336
|
+
git(w.root, "push", "-q", "origin", "main");
|
|
337
|
+
forge.head = merged;
|
|
338
|
+
failed = await ciServePass(failing.opts);
|
|
339
|
+
record = join(failing.state, "resume", `${prApplyRecordKey("prod", merged, "app")}.json`);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("stays failed, and keeps its resume record", async () => {
|
|
343
|
+
expect(failed.jobs).toEqual([expect.objectContaining({ job: "apply", status: "fail", attempts: 1, failure: expect.stringContaining("command-failed") })]);
|
|
344
|
+
expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "1" });
|
|
345
|
+
expect(existsSync(join(failed.jobs[0]!.report!, "resume-in.json"))).toBe(false);
|
|
220
346
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "2" });
|
|
237
|
-
// A passed apply is not run again, --rerun-failed or not.
|
|
238
|
-
expect((await ciServePass(retry.opts)).jobs).toEqual([]);
|
|
347
|
+
// Without --rerun-failed the failed apply is not run again.
|
|
348
|
+
expect((await ciServePass(failing.opts)).jobs).toEqual([]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("--rerun-failed runs it again with its resume record", async () => {
|
|
352
|
+
const retry = serve(w.app, forge, { stateDir: failing.state, rerunFailed: true, hostEnv: { FAKE_EXIT: "0", FAKE_ATTEMPT: "2" } });
|
|
353
|
+
const again = await ciServePass(retry.opts);
|
|
354
|
+
expect(again.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: w.main, status: "ok", attempts: 2 })]);
|
|
355
|
+
expect(ofType(retry.events, "started")[0]).toMatchObject({ attempt: 2, resume: record });
|
|
356
|
+
// The job read the first attempt's record, and left its own.
|
|
357
|
+
expect(JSON.parse(readFileSync(join(again.jobs[0]!.report!, "resume-in.json"), "utf-8"))).toEqual({ attempt: "1" });
|
|
358
|
+
expect(JSON.parse(readFileSync(record, "utf-8"))).toEqual({ attempt: "2" });
|
|
359
|
+
// A passed apply is not run again, --rerun-failed or not.
|
|
360
|
+
expect((await ciServePass(retry.opts)).jobs).toEqual([]);
|
|
361
|
+
});
|
|
239
362
|
});
|
|
240
363
|
|
|
241
364
|
test("--max-jobs holds the jobs over the limit for the next poll", async () => {
|
|
@@ -266,3 +389,123 @@ describe("chant ci serve (#4095)", () => {
|
|
|
266
389
|
expect(() => ciServeInterval("0")).toThrow(/a second or more/);
|
|
267
390
|
});
|
|
268
391
|
});
|
|
392
|
+
|
|
393
|
+
describe("ci serve follows the forge's chant/lifecycle (#4121)", () => {
|
|
394
|
+
/** A serve past its baseline pass, with one merge waiting to apply, and each run's result in `results`. */
|
|
395
|
+
async function mergeWaiting() {
|
|
396
|
+
const w = workspace();
|
|
397
|
+
const seed = seedLedger(w.root);
|
|
398
|
+
const forge = fakeForge(w.main);
|
|
399
|
+
const results: CiRunResult[] = [];
|
|
400
|
+
const { opts, events } = serve(w.app, forge, { run: async (req) => { const r = await ciRun(req); results.push(r); return r; } });
|
|
401
|
+
await ciServePass(opts);
|
|
402
|
+
const merged = commit(w.root, "app/merged.txt");
|
|
403
|
+
git(w.root, "push", "-q", "origin", "main");
|
|
404
|
+
forge.head = merged;
|
|
405
|
+
return { ...w, seed, opts, events, results };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
test("a ledger the forge deleted is deleted here, and the next apply runs clean", async () => {
|
|
409
|
+
const { root, origin, seed, opts, events, results } = await mergeWaiting();
|
|
410
|
+
git(origin, "update-ref", "-d", "refs/heads/chant/lifecycle");
|
|
411
|
+
|
|
412
|
+
const pass = await ciServePass(opts);
|
|
413
|
+
expect(ofType(events, "ledger")).toEqual([{ type: "ledger", action: "deleted", local: seed }]);
|
|
414
|
+
expect(formatCiServeEvent(ofType(events, "ledger")[0]!)).toContain("was deleted");
|
|
415
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
|
|
416
|
+
expect(results[0]).toMatchObject({ pushed: true });
|
|
417
|
+
const tip = git(origin, "rev-parse", "refs/heads/chant/lifecycle");
|
|
418
|
+
expect(git(root, "rev-parse", "chant/lifecycle")).toBe(tip);
|
|
419
|
+
expect(isAncestor(origin, seed, tip)).toBe(false);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("a ledger the forge rewrote is reset to the forge's and not pushed back", async () => {
|
|
423
|
+
const { root, origin, seed, opts, events, results } = await mergeWaiting();
|
|
424
|
+
const rewritten = ledgerCommit(root, "rewritten");
|
|
425
|
+
git(root, "push", "-q", "origin", `${rewritten}:refs/scratch/rewritten`);
|
|
426
|
+
git(origin, "update-ref", "refs/heads/chant/lifecycle", rewritten);
|
|
427
|
+
|
|
428
|
+
const pass = await ciServePass(opts);
|
|
429
|
+
expect(ofType(events, "ledger")).toEqual([{ type: "ledger", action: "reset", local: seed, forge: rewritten }]);
|
|
430
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
|
|
431
|
+
expect(results[0]).toMatchObject({ pushed: true });
|
|
432
|
+
const tip = git(origin, "rev-parse", "refs/heads/chant/lifecycle");
|
|
433
|
+
expect(isAncestor(origin, rewritten, tip)).toBe(true);
|
|
434
|
+
expect(isAncestor(root, seed, tip)).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("a ledger ahead of the forge's keeps its facts, and the apply pushes them", async () => {
|
|
438
|
+
const { root, origin, seed, opts, events, results } = await mergeWaiting();
|
|
439
|
+
const ahead = ledgerCommit(root, "unpushed fact", seed);
|
|
440
|
+
git(root, "update-ref", "refs/heads/chant/lifecycle", ahead);
|
|
441
|
+
|
|
442
|
+
const pass = await ciServePass(opts);
|
|
443
|
+
expect(ofType(events, "ledger")).toEqual([]);
|
|
444
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
|
|
445
|
+
expect(results[0]).toMatchObject({ pushed: true });
|
|
446
|
+
expect(isAncestor(origin, ahead, git(origin, "rev-parse", "refs/heads/chant/lifecycle"))).toBe(true);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe("ci serve applies fast-forwards only, and honours [skip ci] (#4122)", () => {
|
|
451
|
+
test("a force push back to an older commit is recorded as seen and not applied, and the next fast-forward applies", async () => {
|
|
452
|
+
const { root, app, main } = workspace();
|
|
453
|
+
const later = commit(root, "app/later.txt");
|
|
454
|
+
git(root, "push", "-q", "origin", "main");
|
|
455
|
+
const forge = fakeForge(later);
|
|
456
|
+
const { opts, events, state } = serve(app, forge);
|
|
457
|
+
await ciServePass(opts);
|
|
458
|
+
expect(ofType(events, "baseline")).toEqual([{ type: "baseline", commit: later }]);
|
|
459
|
+
|
|
460
|
+
git(root, "reset", "-q", "--hard", main);
|
|
461
|
+
git(root, "push", "-q", "-f", "origin", "main");
|
|
462
|
+
forge.head = main;
|
|
463
|
+
const back = await ciServePass(opts);
|
|
464
|
+
expect(back.jobs).toEqual([]);
|
|
465
|
+
expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "apply", commit: main, reason: "not-fast-forward", branch: "main", from: later }]);
|
|
466
|
+
expect(formatCiServeEvent(ofType(events, "not-run")[0]!)).toContain("not a fast-forward");
|
|
467
|
+
const file = join(state, `${prApplyGroup("prod", "app")}.json`);
|
|
468
|
+
expect(readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" }).target).toBe(main);
|
|
469
|
+
|
|
470
|
+
const next = commit(root, "app/next.txt");
|
|
471
|
+
git(root, "push", "-q", "origin", "main");
|
|
472
|
+
forge.head = next;
|
|
473
|
+
const pass = await ciServePass(opts);
|
|
474
|
+
expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: next, base: main, status: "ok" })]);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("a target head whose message says [skip ci] is recorded as seen and not applied", async () => {
|
|
478
|
+
const { root, app, main } = workspace();
|
|
479
|
+
const forge = fakeForge(main);
|
|
480
|
+
const { opts, events, state } = serve(app, forge);
|
|
481
|
+
await ciServePass(opts);
|
|
482
|
+
const skip = commit(root, "app/tidy.txt", "tidy the app [skip ci]");
|
|
483
|
+
git(root, "push", "-q", "origin", "main");
|
|
484
|
+
forge.head = skip;
|
|
485
|
+
|
|
486
|
+
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
487
|
+
expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "apply", commit: skip, reason: "skip-ci" }]);
|
|
488
|
+
expect(formatCiServeEvent(ofType(events, "not-run")[0]!)).toContain("says [skip ci]");
|
|
489
|
+
const file = join(state, `${prApplyGroup("prod", "app")}.json`);
|
|
490
|
+
expect(readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" }).target).toBe(skip);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("a pull request head whose message says [ci skip] is not planned", async () => {
|
|
494
|
+
const { root, app, main } = workspace();
|
|
495
|
+
git(root, "checkout", "-q", "-b", "pr-4");
|
|
496
|
+
const head = commit(root, "app/pr-4.txt", "draft [ci skip]");
|
|
497
|
+
git(root, "push", "-q", "origin", `${head}:refs/pull/4/head`);
|
|
498
|
+
git(root, "checkout", "-q", "main");
|
|
499
|
+
const forge = fakeForge(main);
|
|
500
|
+
forge.prs = [pr(4, head, main)];
|
|
501
|
+
const { opts, events, state } = serve(app, forge);
|
|
502
|
+
|
|
503
|
+
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
504
|
+
expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "plan", commit: head, pr: 4, reason: "skip-ci" }]);
|
|
505
|
+
const file = join(state, `${prApplyGroup("prod", "app")}.json`);
|
|
506
|
+
expect(readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" }).skipped).toEqual({ [ciServeKey({ job: "plan", pr: 4, commit: head })]: "skip-ci" });
|
|
507
|
+
// Seen once: the next poll says nothing of it.
|
|
508
|
+
expect((await ciServePass(opts)).jobs).toEqual([]);
|
|
509
|
+
expect(ofType(events, "not-run")).toHaveLength(1);
|
|
510
|
+
});
|
|
511
|
+
});
|