@intentius/chant 0.124.40 → 0.124.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__fixtures__/oidc-emulator.d.ts +49 -0
- package/dist/__fixtures__/oidc-emulator.d.ts.map +1 -0
- package/dist/__fixtures__/oidc-emulator.js +89 -0
- package/dist/ci-handover.d.ts +117 -0
- package/dist/ci-handover.d.ts.map +1 -0
- package/dist/ci-handover.js +217 -0
- package/dist/ci-identity.d.ts +190 -0
- package/dist/ci-identity.d.ts.map +1 -0
- package/dist/ci-identity.js +219 -0
- package/dist/cli/handlers/approve.d.ts +2 -1
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -6
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +18 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +42 -5
- package/dist/cli/handlers/gate-resume.d.ts +3 -0
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.js +15 -2
- package/dist/cli/handlers/lock.d.ts +8 -0
- package/dist/cli/handlers/lock.d.ts.map +1 -0
- package/dist/cli/handlers/lock.js +197 -0
- package/dist/cli/handlers/pr-env.d.ts +34 -0
- package/dist/cli/handlers/pr-env.d.ts.map +1 -0
- package/dist/cli/handlers/pr-env.js +436 -0
- package/dist/cli/handlers/pr.d.ts +76 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +615 -55
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +9 -0
- package/dist/cli/handlers/query.d.ts +6 -0
- package/dist/cli/handlers/query.d.ts.map +1 -0
- package/dist/cli/handlers/query.js +80 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +11 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +49 -4
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +46 -2
- package/dist/cli/registry.d.ts +31 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability.d.ts +47 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +44 -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 +8 -1
- package/dist/components/driver.d.ts +10 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +52 -2
- package/dist/components/fan-out-run.d.ts +37 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +44 -4
- package/dist/components/gate-plan.d.ts +2 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +1 -0
- package/dist/components/pr-environment.d.ts +298 -0
- package/dist/components/pr-environment.d.ts.map +1 -0
- package/dist/components/pr-environment.js +406 -0
- package/dist/components/pr-hooks.d.ts +66 -0
- package/dist/components/pr-hooks.d.ts.map +1 -0
- package/dist/components/pr-hooks.js +119 -0
- package/dist/components/pr-pipeline.d.ts +92 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +150 -21
- package/dist/components/pr-policy.d.ts +109 -0
- package/dist/components/pr-policy.d.ts.map +1 -0
- package/dist/components/pr-policy.js +150 -0
- package/dist/components/pr-run.d.ts +6 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +8 -3
- package/dist/components/wave-plan.d.ts +14 -1
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +34 -2
- package/dist/gate-decision.d.ts +15 -2
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +17 -2
- package/dist/gate-policy.d.ts +140 -0
- package/dist/gate-policy.d.ts.map +1 -0
- package/dist/gate-policy.js +242 -0
- package/dist/lifecycle/gate-checkpoint.d.ts +1 -1
- package/dist/lifecycle/gate-checkpoint.js +1 -1
- package/dist/{workspace/trust → lifecycle}/gate-seal.d.ts +1 -1
- package/dist/lifecycle/gate-seal.d.ts.map +1 -0
- package/dist/lifecycle/query.d.ts +68 -0
- package/dist/lifecycle/query.d.ts.map +1 -0
- package/dist/lifecycle/query.js +246 -0
- package/dist/lifecycle/run-ledger.d.ts +13 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +16 -0
- package/dist/lifecycle/target-lock.d.ts +97 -0
- package/dist/lifecycle/target-lock.d.ts.map +1 -0
- package/dist/lifecycle/target-lock.js +286 -0
- package/dist/op/activities/activity-contracts.d.ts +30 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +26 -0
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/index.js +4 -0
- package/dist/op/activities/pr-refs.d.ts +10 -0
- package/dist/op/activities/pr-refs.d.ts.map +1 -0
- package/dist/op/activities/pr-refs.js +26 -0
- package/dist/op/activities/predict-behaviour.d.ts +2 -4
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.js +2 -21
- package/dist/op/activities/review-agent.d.ts +137 -0
- package/dist/op/activities/review-agent.d.ts.map +1 -0
- package/dist/op/activities/review-agent.js +266 -0
- package/dist/op/builders.d.ts +9 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +11 -0
- package/dist/op/cron.d.ts +8 -0
- package/dist/op/cron.d.ts.map +1 -1
- package/dist/op/cron.js +51 -0
- package/dist/op/gate-approval.d.ts +20 -13
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +13 -18
- package/dist/op/gate-quorum.d.ts +131 -0
- package/dist/op/gate-quorum.d.ts.map +1 -0
- package/dist/op/gate-quorum.js +183 -0
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-resume.js +5 -4
- package/dist/op/gate-review.js +1 -1
- package/dist/op/gate.d.ts +32 -4
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +40 -24
- package/dist/op/index.d.ts +6 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +4 -3
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +9 -3
- package/dist/op/op-waves-run.d.ts +28 -43
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +170 -120
- package/dist/op/op-waves.d.ts +55 -3
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +63 -8
- package/dist/op/schedule-overdue.d.ts +57 -0
- package/dist/op/schedule-overdue.d.ts.map +1 -0
- package/dist/op/schedule-overdue.js +138 -0
- package/dist/op/stage-hooks.d.ts +132 -0
- package/dist/op/stage-hooks.d.ts.map +1 -0
- package/dist/op/stage-hooks.js +142 -0
- package/dist/override-gate.d.ts +2 -2
- package/dist/override-gate.d.ts.map +1 -1
- package/dist/override-gate.js +1 -1
- package/dist/pr-forge.d.ts +64 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +331 -44
- package/dist/pr-loop.d.ts +107 -1
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +84 -3
- package/dist/pr-post.d.ts +72 -0
- package/dist/pr-post.d.ts.map +1 -0
- package/dist/pr-post.js +134 -0
- package/dist/pr-requires.d.ts +38 -0
- package/dist/pr-requires.d.ts.map +1 -0
- package/dist/pr-requires.js +48 -0
- package/dist/wave-blocked.d.ts +79 -0
- package/dist/wave-blocked.d.ts.map +1 -0
- package/dist/wave-blocked.js +245 -0
- package/dist/wave-engine.d.ts +6 -0
- package/dist/wave-engine.d.ts.map +1 -1
- package/dist/wave-engine.js +2 -0
- package/dist/wave-runtime.mjs +243 -243
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts +1 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/two-member-workspace.js +1 -1
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +19 -4
- package/dist/workspace/ci-branches.d.ts +51 -0
- package/dist/workspace/ci-branches.d.ts.map +1 -0
- package/dist/workspace/ci-branches.js +69 -0
- package/dist/workspace/ci-cloud-identity.d.ts +11 -0
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -0
- package/dist/workspace/ci-cloud-identity.js +24 -0
- package/dist/workspace/ci-init.d.ts.map +1 -1
- package/dist/workspace/ci-init.js +5 -4
- package/dist/workspace/ci-locks.d.ts +12 -0
- package/dist/workspace/ci-locks.d.ts.map +1 -0
- package/dist/workspace/ci-locks.js +6 -0
- package/dist/workspace/ci-run.d.ts +17 -1
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +66 -4
- package/dist/workspace/ci-workflow-at.d.ts +14 -0
- package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
- package/dist/workspace/ci-workflow-at.js +17 -0
- package/dist/workspace/ci-workflow.d.ts +11 -3
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +10 -2
- package/dist/workspace/declaration.d.ts +69 -3
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +72 -1
- package/dist/workspace/declaration.schema.json +149 -2
- package/dist/workspace/identity-gate-rule.d.ts +1 -1
- package/dist/workspace/identity-gate-rule.d.ts.map +1 -1
- package/dist/workspace/identity-gate-rule.js +1 -1
- package/dist/workspace/identity.d.ts +1 -1
- package/dist/workspace/identity.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +8 -5
- 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 +23 -0
- package/dist/workspace/pr-report.schema.json +94 -3
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +1 -1
- package/dist/workspace/security.d.ts.map +1 -1
- package/dist/workspace/security.js +4 -0
- package/dist/workspace/status-gates.d.ts +3 -2
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status-gates.js +8 -7
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/status.js +4 -1
- package/dist/workspace/trust/seal.d.ts +2 -2
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +2 -2
- package/package.json +1 -1
- package/src/__fixtures__/oidc-emulator.ts +118 -0
- package/src/ci-handover.test.ts +209 -0
- package/src/ci-handover.ts +237 -0
- package/src/ci-identity.test.ts +201 -0
- package/src/ci-identity.ts +310 -0
- package/src/cli/handlers/approve.ts +18 -5
- package/src/cli/handlers/build.ts +18 -1
- package/src/cli/handlers/fan-out.ts +43 -6
- package/src/cli/handlers/gate-resume.ts +16 -2
- package/src/cli/handlers/lock.test.ts +98 -0
- package/src/cli/handlers/lock.ts +202 -0
- package/src/cli/handlers/operator.test.ts +3 -1
- package/src/cli/handlers/pr-before-merge.test.ts +276 -0
- package/src/cli/handlers/pr-env.ts +462 -0
- package/src/cli/handlers/pr-waves-blocked.test.ts +247 -0
- package/src/cli/handlers/pr.test.ts +184 -0
- package/src/cli/handlers/pr.ts +605 -61
- package/src/cli/handlers/promote.ts +9 -0
- package/src/cli/handlers/query.ts +78 -0
- package/src/cli/handlers/run.ts +10 -0
- package/src/cli/main.ts +49 -4
- package/src/cli/parse-args.ts +34 -2
- package/src/cli/registry.ts +31 -2
- package/src/components/capability-targets.test.ts +162 -0
- package/src/components/capability.ts +71 -0
- package/src/components/cli-support.ts +14 -1
- package/src/components/driver.ts +54 -3
- package/src/components/fan-out-run.ts +58 -3
- package/src/components/fan-out-waves.test.ts +45 -1
- package/src/components/gate-plan.ts +3 -0
- package/src/components/pr-environment.test.ts +249 -0
- package/src/components/pr-environment.ts +538 -0
- package/src/components/pr-hooks.test.ts +118 -0
- package/src/components/pr-hooks.ts +157 -0
- package/src/components/pr-pipeline.ts +194 -21
- package/src/components/pr-policy.test.ts +145 -0
- package/src/components/pr-policy.ts +211 -0
- package/src/components/pr-run.ts +14 -2
- package/src/components/wave-plan.ts +38 -3
- package/src/gate-decision-bundle.test.ts +4 -3
- package/src/gate-decision.ts +39 -2
- package/src/gate-policy-bundle.test.ts +28 -0
- package/src/gate-policy.test.ts +162 -0
- package/src/gate-policy.ts +298 -0
- package/src/lifecycle/gate-checkpoint.ts +1 -1
- package/src/{workspace/trust → lifecycle}/gate-seal.ts +1 -1
- package/src/lifecycle/query.test.ts +91 -0
- package/src/lifecycle/query.ts +315 -0
- package/src/lifecycle/run-ledger.ts +18 -0
- package/src/lifecycle/target-lock.test.ts +195 -0
- package/src/lifecycle/target-lock.ts +347 -0
- package/src/op/activities/activity-contracts.ts +31 -0
- package/src/op/activities/index.ts +6 -0
- package/src/op/activities/pr-refs.ts +28 -0
- package/src/op/activities/predict-behaviour.ts +3 -23
- package/src/op/activities/review-agent.test.ts +240 -0
- package/src/op/activities/review-agent.ts +355 -0
- package/src/op/builders.ts +13 -0
- package/src/op/cron.ts +44 -0
- package/src/op/gate-alias.test.ts +2 -2
- package/src/op/gate-approval.test.ts +30 -9
- package/src/op/gate-approval.ts +23 -26
- package/src/op/gate-quorum.test.ts +139 -0
- package/src/op/gate-quorum.ts +248 -0
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +1 -1
- package/src/op/gate.ts +64 -23
- package/src/op/index.ts +13 -1
- package/src/op/local-executor.ts +9 -2
- package/src/op/op-waves-hook-targets.test.ts +126 -0
- package/src/op/op-waves-policy-hold.test.ts +66 -0
- package/src/op/op-waves-quorum.test.ts +101 -0
- package/src/op/op-waves-run.ts +197 -183
- package/src/op/op-waves.ts +83 -10
- package/src/op/schedule-overdue.test.ts +220 -0
- package/src/op/schedule-overdue.ts +161 -0
- package/src/op/stage-hooks.ts +251 -0
- package/src/override-gate-bundle.test.ts +1 -1
- package/src/override-gate.ts +2 -2
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.test.ts +33 -0
- package/src/pr-forge.ts +347 -35
- package/src/pr-loop-bundle.test.ts +2 -2
- package/src/pr-loop.test.ts +58 -0
- package/src/pr-loop.ts +170 -4
- package/src/pr-post.test.ts +141 -0
- package/src/pr-post.ts +159 -0
- package/src/pr-requires.test.ts +184 -0
- package/src/pr-requires.ts +79 -0
- package/src/review-agent-bundle.test.ts +35 -0
- package/src/stage-hooks-bundle.test.ts +38 -0
- package/src/wave-blocked-bundle.test.ts +25 -0
- package/src/wave-blocked.test.ts +146 -0
- package/src/wave-blocked.ts +271 -0
- package/src/wave-engine.ts +8 -0
- package/src/workspace/__fixtures__/two-member-workspace.ts +2 -2
- package/src/workspace/checks/ci-workflow.test.ts +9 -0
- package/src/workspace/checks/ci-workflow.ts +18 -4
- package/src/workspace/ci-branches.test.ts +62 -0
- package/src/workspace/ci-branches.ts +79 -0
- package/src/workspace/ci-cloud-identity.test.ts +53 -0
- package/src/workspace/ci-cloud-identity.ts +23 -0
- package/src/workspace/ci-init.ts +5 -4
- package/src/workspace/ci-locks.ts +16 -0
- package/src/workspace/ci-run-index.test.ts +2 -1
- package/src/workspace/ci-run.test.ts +107 -8
- package/src/workspace/ci-run.ts +74 -5
- package/src/workspace/ci-workflow-at.ts +23 -0
- package/src/workspace/ci-workflow.ts +13 -2
- package/src/workspace/declaration.schema.json +149 -2
- package/src/workspace/declaration.test.ts +76 -0
- package/src/workspace/declaration.ts +140 -5
- package/src/workspace/identity-gate-rule.ts +1 -1
- package/src/workspace/identity.ts +1 -1
- package/src/workspace/lineage-upgrade.ts +11 -8
- package/src/workspace/member-pipeline.test.ts +29 -0
- package/src/workspace/member-pipeline.ts +30 -0
- package/src/workspace/pr-report.schema.json +94 -3
- package/src/workspace/reason-codes.ts +1 -1
- package/src/workspace/security.ts +4 -0
- package/src/workspace/status-gates.ts +9 -6
- package/src/workspace/status.ts +4 -1
- package/src/workspace/trust/gate-seal.test.ts +1 -1
- package/src/workspace/trust/seal.ts +2 -2
- package/dist/workspace/trust/gate-seal.d.ts.map +0 -1
- /package/dist/{workspace/trust → lifecycle}/gate-seal.js +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local emulator of every service the OIDC trade (#4296, ../ci-identity.ts)
|
|
3
|
+
* talks to, for tests: the forge's token request URL, AWS STS, Google's STS
|
|
4
|
+
* and IAM credentials, and Entra's token endpoint. It answers with test
|
|
5
|
+
* values only and keeps every request, so a test can say which identity
|
|
6
|
+
* was asked for and how many tokens were minted.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createServer, type IncomingMessage, type Server } from "node:http";
|
|
10
|
+
import type { AddressInfo } from "node:net";
|
|
11
|
+
import type { CloudTradeSpec } from "../ci-identity";
|
|
12
|
+
|
|
13
|
+
/** What the emulator was asked. */
|
|
14
|
+
export interface EmulatorLog {
|
|
15
|
+
/** Each OIDC token minted: its audience. */
|
|
16
|
+
minted: string[];
|
|
17
|
+
/** Each AWS role assumed, with the token it was assumed with. */
|
|
18
|
+
assumed: Array<{ role: string; token: string }>;
|
|
19
|
+
/** Each GCP service account impersonated. */
|
|
20
|
+
impersonated: string[];
|
|
21
|
+
/** Each Azure client a token was traded for, with the token. */
|
|
22
|
+
clients: Array<{ clientId: string; token: string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OidcEmulator {
|
|
26
|
+
url: string;
|
|
27
|
+
log: EmulatorLog;
|
|
28
|
+
/** The forge's token request variables, as a GitHub or Forgejo job with `id-token: write` has them. */
|
|
29
|
+
requestEnv: { ACTIONS_ID_TOKEN_REQUEST_URL: string; ACTIONS_ID_TOKEN_REQUEST_TOKEN: string };
|
|
30
|
+
/** `spec` with each cloud pointed at the emulator. */
|
|
31
|
+
pointAt(spec: CloudTradeSpec): CloudTradeSpec;
|
|
32
|
+
close(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The request token the emulator accepts, and the prefix of every token it mints. */
|
|
36
|
+
export const EMULATED_REQUEST_TOKEN = "emulated-oidc-request-token-4296";
|
|
37
|
+
export const EMULATED_JWT_PREFIX = "eyJemulated4296";
|
|
38
|
+
|
|
39
|
+
/** The test credentials the emulator hands out for an identity: they name it, so a test can tell which was traded. */
|
|
40
|
+
export function emulatedCredentials(identity: string): { accessKeyId: string; secret: string; token: string } {
|
|
41
|
+
const tag = identity.replace(/[^A-Za-z0-9]/g, "").slice(-24);
|
|
42
|
+
return { accessKeyId: `ASIATEST${tag}`, secret: `secret-${tag}`, token: `session-${tag}` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const body = (req: IncomingMessage): Promise<string> =>
|
|
46
|
+
new Promise((ok) => {
|
|
47
|
+
let b = "";
|
|
48
|
+
req.on("data", (c) => (b += c));
|
|
49
|
+
req.on("end", () => ok(b));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Start the emulator on a free local port. */
|
|
53
|
+
export async function startOidcEmulator(): Promise<OidcEmulator> {
|
|
54
|
+
const log: EmulatorLog = { minted: [], assumed: [], impersonated: [], clients: [] };
|
|
55
|
+
const server: Server = createServer(async (req, res) => {
|
|
56
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
57
|
+
const text = await body(req);
|
|
58
|
+
const send = (status: number, payload: unknown, type = "application/json") => {
|
|
59
|
+
res.writeHead(status, { "content-type": type });
|
|
60
|
+
res.end(typeof payload === "string" ? payload : JSON.stringify(payload));
|
|
61
|
+
};
|
|
62
|
+
if (url.pathname === "/oidc") {
|
|
63
|
+
if (req.headers.authorization !== `bearer ${EMULATED_REQUEST_TOKEN}`) return send(401, { message: "bad request token" });
|
|
64
|
+
const aud = url.searchParams.get("audience") ?? "";
|
|
65
|
+
log.minted.push(aud);
|
|
66
|
+
return send(200, { value: `${EMULATED_JWT_PREFIX}.${Buffer.from(aud).toString("base64url")}.${log.minted.length}` });
|
|
67
|
+
}
|
|
68
|
+
if (url.pathname === "/aws/") {
|
|
69
|
+
const form = new URLSearchParams(text);
|
|
70
|
+
const role = form.get("RoleArn") ?? "";
|
|
71
|
+
const token = form.get("WebIdentityToken") ?? "";
|
|
72
|
+
if (form.get("Action") !== "AssumeRoleWithWebIdentity" || !token.startsWith("eyJ")) return send(400, "<Error>bad request</Error>", "text/xml");
|
|
73
|
+
log.assumed.push({ role, token });
|
|
74
|
+
const c = emulatedCredentials(role);
|
|
75
|
+
return send(
|
|
76
|
+
200,
|
|
77
|
+
`<AssumeRoleWithWebIdentityResponse><AssumeRoleWithWebIdentityResult><Credentials><AccessKeyId>${c.accessKeyId}</AccessKeyId><SecretAccessKey>${c.secret}</SecretAccessKey><SessionToken>${c.token}</SessionToken><Expiration>2026-10-22T23:00:00Z</Expiration></Credentials></AssumeRoleWithWebIdentityResult></AssumeRoleWithWebIdentityResponse>`,
|
|
78
|
+
"text/xml",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (url.pathname === "/gcp-sts/v1/token") {
|
|
82
|
+
const j = JSON.parse(text) as { subjectToken?: string };
|
|
83
|
+
if (!j.subjectToken?.startsWith("eyJ")) return send(400, { error: "bad subject token" });
|
|
84
|
+
return send(200, { access_token: "federated-test-token", token_type: "Bearer" });
|
|
85
|
+
}
|
|
86
|
+
const sa = /^\/gcp-iam\/v1\/projects\/-\/serviceAccounts\/([^/:]+):generateAccessToken$/.exec(url.pathname);
|
|
87
|
+
if (sa) {
|
|
88
|
+
if (req.headers.authorization !== "Bearer federated-test-token") return send(401, { error: "bad federated token" });
|
|
89
|
+
const name = decodeURIComponent(sa[1]!);
|
|
90
|
+
log.impersonated.push(name);
|
|
91
|
+
return send(200, { accessToken: `ya29.${emulatedCredentials(name).token}`, expireTime: "2026-10-22T23:00:00Z" });
|
|
92
|
+
}
|
|
93
|
+
if (/^\/azure\/[^/]+\/oauth2\/v2\.0\/token$/.test(url.pathname)) {
|
|
94
|
+
const form = new URLSearchParams(text);
|
|
95
|
+
const clientId = form.get("client_id") ?? "";
|
|
96
|
+
const token = form.get("client_assertion") ?? "";
|
|
97
|
+
if (form.get("grant_type") !== "client_credentials" || !token.startsWith("eyJ")) return send(400, { error: "bad grant" });
|
|
98
|
+
log.clients.push({ clientId, token });
|
|
99
|
+
return send(200, { access_token: `azure.${emulatedCredentials(clientId).token}`, token_type: "Bearer" });
|
|
100
|
+
}
|
|
101
|
+
send(404, { message: `no route ${url.pathname}` });
|
|
102
|
+
});
|
|
103
|
+
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
|
|
104
|
+
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
105
|
+
return {
|
|
106
|
+
url,
|
|
107
|
+
log,
|
|
108
|
+
requestEnv: { ACTIONS_ID_TOKEN_REQUEST_URL: `${url}/oidc?api-version=2.0`, ACTIONS_ID_TOKEN_REQUEST_TOKEN: EMULATED_REQUEST_TOKEN },
|
|
109
|
+
pointAt(spec) {
|
|
110
|
+
return {
|
|
111
|
+
...(spec.aws ? { aws: { ...spec.aws, endpoint: `${url}/aws` } } : {}),
|
|
112
|
+
...(spec.gcp ? { gcp: { ...spec.gcp, stsEndpoint: `${url}/gcp-sts`, iamEndpoint: `${url}/gcp-iam` } } : {}),
|
|
113
|
+
...(spec.azure ? { azure: { ...spec.azure, authority: `${url}/azure` } } : {}),
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
close: () => new Promise<void>((ok) => server.close(() => ok())),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4295: the forge token handover. A step that runs the pull request's code
|
|
3
|
+
* starts again without the forge token variables, the report it writes goes
|
|
4
|
+
* to the next job as data, and the checkout keeps no credentials.
|
|
5
|
+
*
|
|
6
|
+
* Each test runs the generated shell for real, with `sh`, in a temp
|
|
7
|
+
* directory, with every token variable set to a value it then looks for.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { gzipSync } from "node:zlib";
|
|
15
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
16
|
+
import {
|
|
17
|
+
CHECKOUT_TOKEN_ENV,
|
|
18
|
+
checkoutScript,
|
|
19
|
+
dropForgeTokens,
|
|
20
|
+
FORGE_TOKEN_VARS,
|
|
21
|
+
GITLAB_DROP_FORGE_TOKENS,
|
|
22
|
+
HANDOVER_LIMITS,
|
|
23
|
+
handoverKeys,
|
|
24
|
+
handoverTrap,
|
|
25
|
+
holdsForgeToken,
|
|
26
|
+
packJs,
|
|
27
|
+
UNPACK_JS,
|
|
28
|
+
withoutForgeTokens,
|
|
29
|
+
} from "./ci-handover";
|
|
30
|
+
|
|
31
|
+
const dirs: string[] = [];
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
function scratch(prefix: string): string {
|
|
36
|
+
const d = mkdtempSync(join(tmpdir(), prefix));
|
|
37
|
+
dirs.push(d);
|
|
38
|
+
return d;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const SECRET = "ghs_handover_secret_4295";
|
|
42
|
+
/** Every forge token variable, set to a value a leak would show. */
|
|
43
|
+
const TOKENS = Object.fromEntries(FORGE_TOKEN_VARS.map((v) => [v, `${SECRET}_${v}`]));
|
|
44
|
+
const BASE = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" };
|
|
45
|
+
|
|
46
|
+
/** Run `body` after the drop line as a script file, as a forge runs a step, and return what `env` printed in it. */
|
|
47
|
+
function stepEnv(body: string, extra: Record<string, string> = {}): { env: string; status: number | null } {
|
|
48
|
+
const dir = scratch("chant-handover-step-");
|
|
49
|
+
const out = join(dir, "env.txt");
|
|
50
|
+
const file = join(dir, "step.sh");
|
|
51
|
+
writeFileSync(file, `${dropForgeTokens("sh")}\n${body.replaceAll("$OUT", out)}\n`);
|
|
52
|
+
const r = spawnSync("sh", ["-e", file], { cwd: dir, env: { ...BASE, ...TOKENS, ...extra }, encoding: "utf-8" });
|
|
53
|
+
return { env: existsSync(out) ? readFileSync(out, "utf-8") : "", status: r.status };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("a step that runs the change's code holds no forge token (#4295)", () => {
|
|
57
|
+
test("the step's shell and every process it starts see none of the token variables", () => {
|
|
58
|
+
const { env, status } = stepEnv('env > "$OUT"; sh -c \'env\' >> "$OUT"');
|
|
59
|
+
expect(status).toBe(0);
|
|
60
|
+
expect(env).not.toContain(SECRET);
|
|
61
|
+
for (const name of FORGE_TOKEN_VARS) expect(env).not.toMatch(new RegExp(`^${name}=`, "m"));
|
|
62
|
+
// It restarted once, which the marker says, so it does not loop.
|
|
63
|
+
expect(env).toMatch(/^CHANT_NO_FORGE_TOKEN=1$/m);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test.skipIf(process.platform !== "linux")("the restart leaves no token in the shell's own initial environment, which a child reads from /proc", () => {
|
|
67
|
+
const { env } = stepEnv('tr "\\0" "\\n" < /proc/$$/environ > "$OUT"; sh -c \'tr "\\0" "\\n" < /proc/$PPID/environ\' >> "$OUT"');
|
|
68
|
+
expect(env).toContain("CHANT_NO_FORGE_TOKEN=1");
|
|
69
|
+
expect(env).not.toContain(SECRET);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a step's exit code is its own after the restart", () => {
|
|
73
|
+
expect(stepEnv("exit 3").status).toBe(3);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a shell that cannot restart, such as sh -c, unsets them", () => {
|
|
77
|
+
const r = spawnSync("sh", ["-c", `${dropForgeTokens("sh")}\nenv`], { env: { ...BASE, ...TOKENS }, encoding: "utf-8" });
|
|
78
|
+
expect(r.status).toBe(0);
|
|
79
|
+
expect(r.stdout).not.toContain(SECRET);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("bash restarts with pipefail", () => {
|
|
83
|
+
const dir = scratch("chant-handover-bash-");
|
|
84
|
+
const file = join(dir, "step.sh");
|
|
85
|
+
writeFileSync(file, `${dropForgeTokens("bash")}\nenv\n`);
|
|
86
|
+
const r = spawnSync("bash", ["-e", file], { env: { ...BASE, ...TOKENS }, encoding: "utf-8" });
|
|
87
|
+
expect(r.stdout).not.toContain(SECRET);
|
|
88
|
+
expect(r.stdout).toContain("CHANT_NO_FORGE_TOKEN=1");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("GitLab's line unsets them for every line after it in the job's one shell", () => {
|
|
92
|
+
const r = spawnSync("sh", ["-c", `${GITLAB_DROP_FORGE_TOKENS}\nenv`], { env: { ...BASE, ...TOKENS }, encoding: "utf-8" });
|
|
93
|
+
expect(r.stdout).not.toContain(SECRET);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("withoutForgeTokens and holdsForgeToken cover chant's, GitHub's, Forgejo's and GitLab's names", () => {
|
|
97
|
+
const env = { ...TOKENS, AWS_ACCESS_KEY_ID: "floci", GITHUB_REPOSITORY: "acme/estate" };
|
|
98
|
+
expect(withoutForgeTokens(env)).toEqual({ AWS_ACCESS_KEY_ID: "floci", GITHUB_REPOSITORY: "acme/estate" });
|
|
99
|
+
expect(holdsForgeToken(env)).toBe(true);
|
|
100
|
+
expect(holdsForgeToken({ GITHUB_TOKEN: "" })).toBe(false);
|
|
101
|
+
for (const name of ["GITHUB_TOKEN", "GH_TOKEN", "CHANT_FORGE_TOKEN", "FORGEJO_TOKEN", "GITLAB_TOKEN", "CI_JOB_TOKEN", "ACTIONS_RUNTIME_TOKEN"]) {
|
|
102
|
+
expect(FORGE_TOKEN_VARS).toContain(name);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** Run the plan step's trap with `files` in its report directory, and return the outputs it wrote. */
|
|
108
|
+
function handOn(files: Record<string, string>, forge: "github" | "forgejo" = "github"): string {
|
|
109
|
+
const dir = scratch("chant-handover-pack-");
|
|
110
|
+
for (const [name, body] of Object.entries(files)) {
|
|
111
|
+
mkdirSync(join(dir, ".chant/pr", name, ".."), { recursive: true });
|
|
112
|
+
writeFileSync(join(dir, ".chant/pr", name), body);
|
|
113
|
+
}
|
|
114
|
+
const output = join(dir, "github-output");
|
|
115
|
+
const file = join(dir, "step.sh");
|
|
116
|
+
writeFileSync(file, [dropForgeTokens("sh"), ...handoverTrap(".chant/pr", forge), "cd /", "exit 1", ""].join("\n"));
|
|
117
|
+
spawnSync("sh", ["-e", file], { cwd: dir, env: { ...BASE, ...TOKENS, GITHUB_OUTPUT: output }, encoding: "utf-8" });
|
|
118
|
+
return readFileSync(output, "utf-8");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What the note job writes back from `outputs`, one `key=value` a line, as toJSON hands each value in. */
|
|
122
|
+
function takeBack(outputs: string, forge: "github" | "forgejo" = "github"): { dir: string; log: string } {
|
|
123
|
+
const values = Object.fromEntries(outputs.trimEnd().split("\n").map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)]));
|
|
124
|
+
const stdin = handoverKeys(forge).map((k) => JSON.stringify(values[k] ?? "")).join("\n") + "\n";
|
|
125
|
+
const dir = scratch("chant-handover-take-");
|
|
126
|
+
const log = execFileSync("node", ["-e", UNPACK_JS, join(dir, ".chant/pr")], { input: stdin, encoding: "utf-8" });
|
|
127
|
+
return { dir: join(dir, ".chant/pr"), log };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("the plan step hands its report to the note job as data (#4295)", () => {
|
|
131
|
+
test("every file goes on as the job's outputs, even when the step fails after changing directory, and comes back the same", () => {
|
|
132
|
+
const files = { "pr-plan.json": '{"stage":"plan"}\n', "pr-note.md": "note\n", "run-events/a.jsonl": "{}\n" };
|
|
133
|
+
const outputs = handOn(files);
|
|
134
|
+
expect(outputs).toMatch(/^report_0=/);
|
|
135
|
+
// The output carries no token, though the step started with every one.
|
|
136
|
+
expect(outputs).not.toContain(Buffer.from(SECRET).toString("base64").slice(0, 12));
|
|
137
|
+
const { dir, log } = takeBack(outputs);
|
|
138
|
+
expect(log).toContain("took 3 report files");
|
|
139
|
+
for (const [name, body] of Object.entries(files)) expect(readFileSync(join(dir, name), "utf-8")).toBe(body);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a path that leaves the directory is left out", () => {
|
|
143
|
+
const payload = gzipSync(JSON.stringify({ "../escape": Buffer.from("x").toString("base64"), "ok.txt": Buffer.from("y").toString("base64") })).toString("base64");
|
|
144
|
+
const { dir, log } = takeBack(`report_0=${payload}\n`);
|
|
145
|
+
expect(log).toContain('left out "../escape"');
|
|
146
|
+
expect(existsSync(join(dir, "..", "escape"))).toBe(false);
|
|
147
|
+
expect(readFileSync(join(dir, "ok.txt"), "utf-8")).toBe("y");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("outputs that do not read hand on nothing, and the note job carries on", () => {
|
|
151
|
+
expect(takeBack("report_0=not-base64-gzip\n").log).toContain("does not read");
|
|
152
|
+
expect(takeBack("").log).toContain("handed on no report");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a report too large is cut to the plan report and the note", () => {
|
|
156
|
+
const saved = { ...HANDOVER_LIMITS.github };
|
|
157
|
+
HANDOVER_LIMITS.github.chunk = 2_000;
|
|
158
|
+
try {
|
|
159
|
+
expect(packJs("github")).toContain("CHUNK=2000");
|
|
160
|
+
const big = Array.from({ length: 4000 }, (_, i) => `${i}-${Math.random()}`).join("\n");
|
|
161
|
+
const { dir } = takeBack(handOn({ "pr-plan.json": "{}", "pr-note.md": "n", "change-set.json": big }));
|
|
162
|
+
expect(existsSync(join(dir, "pr-plan.json"))).toBe(true);
|
|
163
|
+
expect(existsSync(join(dir, "change-set.json"))).toBe(false);
|
|
164
|
+
} finally {
|
|
165
|
+
Object.assign(HANDOVER_LIMITS.github, saved);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("Forgejo hands on up to four outputs of a MiB each", () => {
|
|
170
|
+
expect(handoverKeys("forgejo")).toEqual(["report_0", "report_1", "report_2", "report_3"]);
|
|
171
|
+
expect(handoverKeys("github")).toEqual(["report_0"]);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("the checkout keeps no credentials (#4295)", () => {
|
|
176
|
+
test("it checks the run's commit out with the token in the fetch's environment alone, and fetches chant/lifecycle while it can", () => {
|
|
177
|
+
const top = scratch("chant-handover-checkout-");
|
|
178
|
+
const origin = join(top, "acme", "estate");
|
|
179
|
+
const seed = join(top, "seed");
|
|
180
|
+
mkdirSync(origin, { recursive: true });
|
|
181
|
+
mkdirSync(seed);
|
|
182
|
+
const g = (cwd: string, ...args: string[]) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], { cwd, encoding: "utf-8" }).trim();
|
|
183
|
+
g(origin, "init", "-q", "--bare");
|
|
184
|
+
g(seed, "init", "-q");
|
|
185
|
+
writeFileSync(join(seed, "a.txt"), "a\n");
|
|
186
|
+
g(seed, "add", "-A");
|
|
187
|
+
g(seed, "commit", "-q", "-m", "a");
|
|
188
|
+
const sha = g(seed, "rev-parse", "HEAD");
|
|
189
|
+
g(seed, "push", "-q", origin, "HEAD:refs/heads/main", "HEAD:refs/heads/chant/lifecycle", "HEAD:refs/pull/7/merge");
|
|
190
|
+
|
|
191
|
+
const work = join(top, "work");
|
|
192
|
+
mkdirSync(work);
|
|
193
|
+
const file = join(top, "checkout.sh");
|
|
194
|
+
writeFileSync(file, checkoutScript(true, true) + "\n");
|
|
195
|
+
const r = spawnSync("sh", ["-e", file], {
|
|
196
|
+
cwd: work,
|
|
197
|
+
env: { ...BASE, GIT_CONFIG_GLOBAL: join(top, "gitconfig"), [CHECKOUT_TOKEN_ENV]: SECRET, GITHUB_SERVER_URL: `file://${top}`, GITHUB_REPOSITORY: "acme/estate", GITHUB_REF: "refs/pull/7/merge", GITHUB_SHA: sha },
|
|
198
|
+
encoding: "utf-8",
|
|
199
|
+
});
|
|
200
|
+
expect(r.stderr).toBe("");
|
|
201
|
+
expect(r.status).toBe(0);
|
|
202
|
+
expect(g(work, "rev-parse", "HEAD")).toBe(sha);
|
|
203
|
+
expect(g(work, "rev-parse", "chant/lifecycle")).toBe(sha);
|
|
204
|
+
// Nothing of the token is left in the repository's config.
|
|
205
|
+
const config = readFileSync(join(work, ".git", "config"), "utf-8");
|
|
206
|
+
expect(config).not.toContain("extraheader");
|
|
207
|
+
expect(config).not.toContain(Buffer.from(`x-access-token:${SECRET}`).toString("base64"));
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The forge token handover (#4295): no step that runs a pull request's code
|
|
3
|
+
* holds a forge token.
|
|
4
|
+
*
|
|
5
|
+
* A pull request's plan runs its code: the synth of its components, the
|
|
6
|
+
* plans their capabilities run, its build and lint, and its hooks. A token
|
|
7
|
+
* that can post a note or set a status can also push, so the generated plan
|
|
8
|
+
* job uses the job's token for the checkout's fetches alone and drops it
|
|
9
|
+
* before anything else runs. The ordering is terragucci's `handover.ts`
|
|
10
|
+
* (INTENTIUS/terragucci e6cb431, packages/terragucci/src/handover.ts):
|
|
11
|
+
*
|
|
12
|
+
* 1. The checkout is a script ({@link checkoutScript}), not actions/checkout,
|
|
13
|
+
* whose post step runs at the job's end with the token as its input. The
|
|
14
|
+
* token reaches its fetches through the environment, never the git config.
|
|
15
|
+
* 2. Every later step starts again without the token variables
|
|
16
|
+
* ({@link dropForgeTokens}).
|
|
17
|
+
* 3. The plan step is the job's last. A step after it would start beside
|
|
18
|
+
* whatever the change's code left running, with what it wrote to
|
|
19
|
+
* GITHUB_ENV and GITHUB_PATH. So the plan step hands its report directory
|
|
20
|
+
* on as the job's outputs ({@link handoverTrap}) in place of an artifact
|
|
21
|
+
* upload.
|
|
22
|
+
* 4. A fresh job, `plan-note`, checks out the target branch's commit, takes
|
|
23
|
+
* the report back ({@link takeOverScript}), keeps it as the artifact, and
|
|
24
|
+
* posts the note and the status with the token (`chant components
|
|
25
|
+
* pr-post`, ./pr-post.ts). It reads the report as data.
|
|
26
|
+
*
|
|
27
|
+
* `chant ci run plan` keeps the same rule: no step of the plan job gets a
|
|
28
|
+
* forge token, and the runner posts from the report once the steps settle
|
|
29
|
+
* (../workspace/ci-run.ts).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { ForgeKind } from "./pr-forge";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The variables a runner, a job or a secret may hold a forge token in.
|
|
36
|
+
* A step that runs the change's code starts without them. The list is
|
|
37
|
+
* terragucci's `STEP_TOKEN_VARS` (render.ts:1787) with chant's own names
|
|
38
|
+
* (`FORGE_TOKEN_ENV` in ./pr-forge.ts) and GitLab's added.
|
|
39
|
+
*/
|
|
40
|
+
export const FORGE_TOKEN_VARS = [
|
|
41
|
+
"CHANT_FORGE_TOKEN",
|
|
42
|
+
"CHANT_GITHUB_TOKEN",
|
|
43
|
+
"CHANT_FORGEJO_TOKEN",
|
|
44
|
+
"CHANT_GITLAB_TOKEN",
|
|
45
|
+
"CHANT_CHECKOUT_TOKEN",
|
|
46
|
+
"GITHUB_TOKEN",
|
|
47
|
+
"GH_TOKEN",
|
|
48
|
+
"FORGEJO_TOKEN",
|
|
49
|
+
"GITEA_TOKEN",
|
|
50
|
+
"GITLAB_TOKEN",
|
|
51
|
+
"CI_JOB_TOKEN",
|
|
52
|
+
"ACTIONS_RUNTIME_TOKEN",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/** `env` without {@link FORGE_TOKEN_VARS}. */
|
|
56
|
+
export function withoutForgeTokens<T extends Record<string, string | undefined>>(env: T): T {
|
|
57
|
+
const out = { ...env };
|
|
58
|
+
for (const name of FORGE_TOKEN_VARS) delete out[name];
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether `env` holds a forge token under one of {@link FORGE_TOKEN_VARS}. */
|
|
63
|
+
export function holdsForgeToken(env: Record<string, string | undefined>): boolean {
|
|
64
|
+
return FORGE_TOKEN_VARS.some((name) => (env[name] ?? "") !== "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The marker a step {@link dropForgeTokens} restarted carries. */
|
|
68
|
+
export const NO_FORGE_TOKEN_MARKER = "CHANT_NO_FORGE_TOKEN";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The first line of a step that runs the change's code: restart the step's
|
|
72
|
+
* shell once without the token variables in its process environment, or,
|
|
73
|
+
* when it cannot restart, unset them. Unsetting alone leaves the values in
|
|
74
|
+
* the shell's own initial environment, which a child reads on Linux from
|
|
75
|
+
* `/proc/<pid>/environ`; the restart replaces it. A forge runs a step's
|
|
76
|
+
* script as a file (`sh -e {0}` in a GitHub or Forgejo container job), so
|
|
77
|
+
* `$0` is that file. terragucci's `restartWithout` (render.ts:1826).
|
|
78
|
+
*/
|
|
79
|
+
export function dropForgeTokens(shell: "sh" | "bash" = "sh", extra: readonly string[] = []): string {
|
|
80
|
+
const again = shell === "bash" ? 'bash --noprofile --norc -e -o pipefail "$0"' : 'sh -e "$0"';
|
|
81
|
+
const marker = NO_FORGE_TOKEN_MARKER;
|
|
82
|
+
// #4296: a plan job that traded its OIDC token drops the variables a token is requested with too.
|
|
83
|
+
const vars = [...FORGE_TOKEN_VARS, ...extra];
|
|
84
|
+
const can = `[ -z "\${${marker}:-}" ] && [ "\${0#*/}" != "$0" ] && [ -f "$0" ] && ! { [ "\${0##*/}" = sh ] || [ "\${0##*/}" = bash ] || [ "\${0##*/}" = dash ]; } && command -v env >/dev/null 2>&1 && command -v ${shell} >/dev/null 2>&1`;
|
|
85
|
+
return `if ${can}; then exec env ${vars.map((v) => `-u ${v}`).join(" ")} ${marker}=1 ${again}; fi; unset ${vars.join(" ")}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The line a GitLab plan job starts its script with: GitLab runs the script in one shell, so the unset holds for every line after it. */
|
|
89
|
+
export const GITLAB_DROP_FORGE_TOKENS = `unset ${FORGE_TOKEN_VARS.join(" ")}`;
|
|
90
|
+
|
|
91
|
+
/** The forges whose plan job hands its report on through job outputs. GitLab passes the artifact to the next job itself. */
|
|
92
|
+
export type HandoverForge = Exclude<ForgeKind, "gitlab">;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* How many outputs the plan job hands its report on in, and the most
|
|
96
|
+
* characters each holds. Forgejo keeps each output's value to 1 MiB. GitHub
|
|
97
|
+
* keeps a job's outputs to 1 MB together, sized as UTF-16. terragucci's
|
|
98
|
+
* `HANDOVER_LIMITS` (handover.ts).
|
|
99
|
+
*/
|
|
100
|
+
export const HANDOVER_LIMITS: Record<HandoverForge, { chunk: number; parts: number }> = {
|
|
101
|
+
forgejo: { chunk: 1_000_000, parts: 4 },
|
|
102
|
+
github: { chunk: 450_000, parts: 1 },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** The outputs' names on a forge: report_0, report_1, ... */
|
|
106
|
+
export function handoverKeys(forge: HandoverForge): string[] {
|
|
107
|
+
return Array.from({ length: HANDOVER_LIMITS[forge].parts }, (_, i) => `report_${i}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The files the note job reads, which a report too large to hand on whole is cut to. */
|
|
111
|
+
export const HANDOVER_NOTE_FILES = ["pr-plan.json", "pr-note.md"] as const;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Packs the report directory (argv 1) into the step's output file (argv 2):
|
|
115
|
+
* every regular file, found without following a link, as gzipped JSON in
|
|
116
|
+
* base64, cut into the forge's chunks. A report too large is cut to
|
|
117
|
+
* {@link HANDOVER_NOTE_FILES}, and to none when even those are too large.
|
|
118
|
+
*/
|
|
119
|
+
export function packJs(forge: HandoverForge): string {
|
|
120
|
+
const { chunk, parts } = HANDOVER_LIMITS[forge];
|
|
121
|
+
return [
|
|
122
|
+
'const fs=require("fs"),p=require("path"),z=require("zlib");',
|
|
123
|
+
"const [dir,out]=process.argv.slice(1);",
|
|
124
|
+
`const CHUNK=${chunk},PARTS=${parts},NOTE=${JSON.stringify(HANDOVER_NOTE_FILES)};`,
|
|
125
|
+
"const all={};",
|
|
126
|
+
'const walk=(d,r)=>{let n=[];try{n=fs.readdirSync(d).sort()}catch{return}for(const x of n){const f=p.join(d,x),k=r?r+"/"+x:x;let s;try{s=fs.lstatSync(f)}catch{continue}if(s.isDirectory())walk(f,k);else if(s.isFile())all[k]=fs.readFileSync(f).toString("base64")}};',
|
|
127
|
+
'walk(dir,"");',
|
|
128
|
+
'const pack=(o)=>z.gzipSync(JSON.stringify(o)).toString("base64");',
|
|
129
|
+
"let b=pack(all);",
|
|
130
|
+
"if(b.length>CHUNK*PARTS){b=pack(Object.fromEntries(Object.entries(all).filter(([k])=>NOTE.includes(k))));",
|
|
131
|
+
'if(b.length>CHUNK*PARTS){b=pack({});console.error("chant: the plan report is too large to hand to the note job, even cut to the report and the note; it hands on none of it")}',
|
|
132
|
+
'else console.error("chant: the plan report is too large to hand to the note job whole; it hands on pr-plan.json and pr-note.md")}',
|
|
133
|
+
'let o="";for(let i=0;i*CHUNK<b.length;i++)o+="report_"+i+"="+b.slice(i*CHUNK,(i+1)*CHUNK)+"\\n";',
|
|
134
|
+
"fs.appendFileSync(out,o);",
|
|
135
|
+
].join("");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Writes back, under the directory argv 1 names, the files the plan job
|
|
140
|
+
* handed on: stdin holds its outputs, one JSON string a line. A path is kept
|
|
141
|
+
* only when it is plain segments (no empty one, no `.` or `..`, no backslash
|
|
142
|
+
* or control character), so nothing lands outside the directory.
|
|
143
|
+
*/
|
|
144
|
+
export const UNPACK_JS = [
|
|
145
|
+
'const fs=require("fs"),p=require("path"),z=require("zlib");',
|
|
146
|
+
"const dir=process.argv[1];",
|
|
147
|
+
'let b="";',
|
|
148
|
+
'for(const l of fs.readFileSync(0,"utf8").split("\\n")){if(!l.trim())continue;let s="";try{s=JSON.parse(l)}catch{}if(typeof s==="string")b+=s}',
|
|
149
|
+
'if(!b){console.log("chant: the plan job handed on no report");process.exit(0)}',
|
|
150
|
+
'let files;try{files=JSON.parse(z.gunzipSync(Buffer.from(b,"base64"),{maxOutputLength:268435456}).toString("utf8"))}catch(e){console.log("chant: the report the plan job handed on does not read: "+e.message);process.exit(0)}',
|
|
151
|
+
'if(!files||typeof files!=="object"||Array.isArray(files)){console.log("chant: the report the plan job handed on is not a set of files");process.exit(0)}',
|
|
152
|
+
"let n=0;",
|
|
153
|
+
'for(const [k,v] of Object.entries(files)){const parts=k.split("/");',
|
|
154
|
+
'if(typeof v!=="string"||k.length>1024||parts.some((x)=>!x||x==="."||x===".."||/[\\\\\\x00-\\x1f]/.test(x))){console.log("chant: left out "+JSON.stringify(k));continue}',
|
|
155
|
+
'try{const f=p.join(dir,...parts);fs.mkdirSync(p.dirname(f),{recursive:true});fs.writeFileSync(f,Buffer.from(v,"base64"));n++}catch(e){console.log("chant: could not write "+JSON.stringify(k)+": "+e.message)}}',
|
|
156
|
+
'console.log("chant: took "+n+" report files from the plan job")',
|
|
157
|
+
].join("");
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The lines of the plan step, after {@link dropForgeTokens}: when the step
|
|
161
|
+
* ends, however it ends, its report directory (`dir`, from the directory the
|
|
162
|
+
* step starts in) goes into the step's outputs.
|
|
163
|
+
*/
|
|
164
|
+
export function handoverTrap(dir: string, forge: HandoverForge): string[] {
|
|
165
|
+
return [
|
|
166
|
+
`chant_report="$PWD/${dir}"`,
|
|
167
|
+
`chant_handover() { node -e '${packJs(forge)}' "$chant_report" "\${GITHUB_OUTPUT:-/dev/null}" || echo "chant: the plan report could not be handed to the note job" >&2; }`,
|
|
168
|
+
"trap chant_handover EXIT",
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A job's outputs that carry the report its step `step` handed on. */
|
|
173
|
+
export function handoverOutputs(step: string, forge: HandoverForge): Record<string, string> {
|
|
174
|
+
return Object.fromEntries(handoverKeys(forge).map((k) => [k, `\${{ steps.${step}.outputs.${k} }}`]));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The script of the step that takes the report job `job` handed on into
|
|
179
|
+
* `dir`. Each output comes in as toJSON's string, one line of a quoted
|
|
180
|
+
* heredoc whose end no JSON string can be, so the change's outputs stay data.
|
|
181
|
+
*/
|
|
182
|
+
export function takeOverScript(job: string, dir: string, forge: HandoverForge): string {
|
|
183
|
+
return [
|
|
184
|
+
"set -u",
|
|
185
|
+
`node -e '${UNPACK_JS}' ${dir} <<'END_OF_REPORT'`,
|
|
186
|
+
...handoverKeys(forge).map((k) => `\${{ toJSON(needs.${job}.outputs.${k}) }}`),
|
|
187
|
+
"END_OF_REPORT",
|
|
188
|
+
].join("\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The variable the checkout script reads the job's token from. */
|
|
192
|
+
export const CHECKOUT_TOKEN_ENV = "CHANT_CHECKOUT_TOKEN";
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Checks the run's commit out with git, as actions/checkout does without
|
|
196
|
+
* credentials kept: `GITHUB_SHA` under `GITHUB_REF`. With `history`, every
|
|
197
|
+
* branch and tag too, since the plan measures the change with git. The job's
|
|
198
|
+
* token ({@link CHECKOUT_TOKEN_ENV}) reaches the fetches alone, through the
|
|
199
|
+
* environment, never the git config. A workspace already checked out is
|
|
200
|
+
* cleaned first, as actions/checkout does. terragucci's `checkoutScript`
|
|
201
|
+
* (handover.ts). With `ledger`, the `chant/lifecycle` branch is fetched
|
|
202
|
+
* into a local branch too, while the token is there: the plan reads the
|
|
203
|
+
* gate ledger from it, and a later fetch without the token cannot reach a
|
|
204
|
+
* private repository (`requireLifecycleLedger` in ./lifecycle/git.ts reads
|
|
205
|
+
* the local branch when the fetch fails).
|
|
206
|
+
*/
|
|
207
|
+
export function checkoutScript(history: boolean, ledger = false): string {
|
|
208
|
+
const fetchLine = history
|
|
209
|
+
? `git -c protocol.version=2 fetch -q --prune --no-recurse-submodules origin '+refs/heads/*:refs/remotes/origin/*' '+refs/tags/*:refs/tags/*' "+$want:$target"`
|
|
210
|
+
: 'git -c protocol.version=2 fetch -q --no-tags --prune --no-recurse-submodules --depth=1 origin "+$want:$target"';
|
|
211
|
+
return [
|
|
212
|
+
"set -eu",
|
|
213
|
+
'git config --global --add safe.directory "$PWD" 2>/dev/null || true',
|
|
214
|
+
"[ -d .git ] || git init -q .",
|
|
215
|
+
'git remote get-url origin >/dev/null 2>&1 || git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"',
|
|
216
|
+
`if [ -n "\${${CHECKOUT_TOKEN_ENV}:-}" ]; then`,
|
|
217
|
+
` chant_auth="$(printf 'x-access-token:%s' "$${CHECKOUT_TOKEN_ENV}" | base64 | tr -d '\\n')"`,
|
|
218
|
+
' export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraheader GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $chant_auth"',
|
|
219
|
+
" unset chant_auth",
|
|
220
|
+
"fi",
|
|
221
|
+
'ref="$GITHUB_REF"',
|
|
222
|
+
'want="$GITHUB_SHA"',
|
|
223
|
+
'case "$ref" in',
|
|
224
|
+
' refs/heads/*) target="refs/remotes/origin/${ref#refs/heads/}" ;;',
|
|
225
|
+
' refs/tags/*) target="$ref" ;;',
|
|
226
|
+
' refs/*) target="refs/remotes/${ref#refs/}" ;;',
|
|
227
|
+
' *) target="refs/remotes/origin/chant-head" ;;',
|
|
228
|
+
"esac",
|
|
229
|
+
"if git rev-parse -q --verify HEAD >/dev/null; then git clean -ffdxq; fi",
|
|
230
|
+
fetchLine,
|
|
231
|
+
...(ledger ? ["git -c protocol.version=2 fetch -q --no-tags origin '+refs/heads/chant/lifecycle:refs/heads/chant/lifecycle' 2>/dev/null || true"] : []),
|
|
232
|
+
'case "$ref" in',
|
|
233
|
+
' refs/heads/*) git checkout -q --force -B "${ref#refs/heads/}" "$target" ;;',
|
|
234
|
+
' *) git checkout -q --force --detach "$target" ;;',
|
|
235
|
+
"esac",
|
|
236
|
+
].join("\n");
|
|
237
|
+
}
|