@intentius/chant 0.124.32 → 0.124.33
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/ci-jobs.d.ts +4 -0
- package/dist/ci-jobs.d.ts.map +1 -1
- package/dist/ci-jobs.js +9 -2
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +18 -3
- package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
- package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle-health.js +150 -0
- package/dist/cli/handlers/lifecycle.d.ts +26 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.js +1 -1
- package/dist/cli/handlers/pr.d.ts +2 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +124 -2
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +16 -1
- package/dist/cli/handlers/release.d.ts +16 -1
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +83 -11
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +81 -21
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +23 -1
- package/dist/cli/registry.d.ts +18 -4
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/deploy-inputs.d.ts +91 -0
- package/dist/components/deploy-inputs.d.ts.map +1 -0
- package/dist/components/deploy-inputs.js +184 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +9 -0
- package/dist/components/gate-plan.d.ts +8 -0
- package/dist/components/gate-plan.d.ts.map +1 -1
- package/dist/components/gate-plan.js +6 -0
- package/dist/components/pr-pipeline.d.ts +33 -10
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +42 -11
- package/dist/components/pr-run.d.ts +1 -1
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/release-compile.d.ts +37 -2
- package/dist/components/release-compile.d.ts.map +1 -1
- package/dist/components/release-compile.js +64 -8
- package/dist/components/release-promote.d.ts +17 -3
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +79 -18
- package/dist/components/verbs/bom-writer.d.ts +1 -1
- package/dist/components/verbs/bom-writer.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
- package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
- package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
- package/dist/components/wave-plan.d.ts +10 -0
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +32 -4
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +32 -7
- package/dist/health.d.ts +110 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +105 -0
- package/dist/health.schema.json +191 -0
- package/dist/lexicon.d.ts +27 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/merge-record.d.ts +104 -0
- package/dist/lifecycle/merge-record.d.ts.map +1 -0
- package/dist/lifecycle/merge-record.js +102 -0
- package/dist/lifecycle/publish-attachments.d.ts +8 -5
- package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
- package/dist/lifecycle/publish-attachments.js +27 -13
- package/dist/lifecycle/publish-npm.d.ts +3 -2
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +4 -3
- package/dist/lifecycle/release-at.d.ts +40 -0
- package/dist/lifecycle/release-at.d.ts.map +1 -0
- package/dist/lifecycle/release-at.js +83 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +23 -12
- package/dist/lifecycle/release-publish.d.ts +28 -12
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +29 -17
- package/dist/lifecycle/release-store.d.ts +25 -2
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +40 -13
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +1 -0
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +4 -2
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
- package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
- package/dist/op/activities/activity-contracts.d.ts +5 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.js +3 -0
- package/dist/op/activities/shell.d.ts +38 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/shell.js +89 -3
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +7 -0
- package/dist/op/runtime.d.ts +2 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +14 -3
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +15 -5
- package/dist/pr-report-url.d.ts +2 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/runner-start.d.ts +179 -0
- package/dist/runner-start.d.ts.map +1 -0
- package/dist/runner-start.js +277 -0
- package/dist/wave-runtime.mjs +235 -228
- package/dist/workspace/check.schema.json +70 -2
- package/dist/workspace/checks/security.d.ts +35 -0
- package/dist/workspace/checks/security.d.ts.map +1 -0
- package/dist/workspace/checks/security.js +305 -0
- package/dist/workspace/checks.d.ts +2 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +5 -0
- package/dist/workspace/ci-builds.d.ts +163 -0
- package/dist/workspace/ci-builds.d.ts.map +1 -0
- package/dist/workspace/ci-builds.js +315 -0
- package/dist/workspace/ci-green-cli.d.ts +2 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +25 -5
- package/dist/workspace/ci-green-forge.d.ts +12 -0
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green-forge.js +12 -0
- package/dist/workspace/ci-green-workflow.d.ts +55 -1
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +128 -1
- package/dist/workspace/ci-green.d.ts +89 -4
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +310 -23
- package/dist/workspace/ci-last-green.schema.json +49 -0
- package/dist/workspace/ci-list.schema.json +35 -1
- package/dist/workspace/ci-run-index.d.ts +1 -1
- package/dist/workspace/ci-run-index.d.ts.map +1 -1
- package/dist/workspace/ci-run-index.schema.json +1 -1
- package/dist/workspace/ci-run.d.ts +12 -6
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +72 -16
- package/dist/workspace/ci-runner-start.d.ts +77 -0
- package/dist/workspace/ci-runner-start.d.ts.map +1 -0
- package/dist/workspace/ci-runner-start.js +185 -0
- package/dist/workspace/ci-serve.d.ts +76 -7
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +153 -19
- package/dist/workspace/declaration.d.ts +17 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +3 -0
- package/dist/workspace/declaration.schema.json +23 -0
- package/dist/workspace/ls.d.ts +2 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -1
- package/dist/workspace/ls.schema.json +2 -1
- package/dist/workspace/member-pipeline.d.ts +1 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.js +2 -0
- package/dist/workspace/merge-record.schema.json +49 -0
- package/dist/workspace/pr-report.schema.json +3 -3
- package/dist/workspace/reason-codes.d.ts +17 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +18 -0
- package/dist/workspace/release-candidates.d.ts +129 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -0
- package/dist/workspace/release-candidates.js +233 -0
- package/dist/workspace/release-candidates.schema.json +353 -0
- package/dist/workspace/security.d.ts +40 -0
- package/dist/workspace/security.d.ts.map +1 -0
- package/dist/workspace/security.js +99 -0
- package/package.json +8 -1
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
- package/src/ci-jobs.ts +11 -2
- package/src/cli/handlers/build.test.ts +36 -0
- package/src/cli/handlers/build.ts +18 -3
- package/src/cli/handlers/lifecycle-health.test.ts +123 -0
- package/src/cli/handlers/lifecycle-health.ts +157 -0
- package/src/cli/handlers/lifecycle.ts +1 -1
- package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
- package/src/cli/handlers/pr-record.test.ts +201 -0
- package/src/cli/handlers/pr.test.ts +22 -0
- package/src/cli/handlers/pr.ts +127 -4
- package/src/cli/handlers/promote.test.ts +17 -0
- package/src/cli/handlers/promote.ts +16 -1
- package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
- package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
- package/src/cli/handlers/release.e2e.test.ts +19 -28
- package/src/cli/handlers/release.ts +81 -11
- package/src/cli/main.ts +81 -21
- package/src/cli/parse-args.ts +17 -1
- package/src/cli/registry.ts +18 -4
- package/src/components/deploy-inputs.test.ts +108 -0
- package/src/components/deploy-inputs.ts +225 -0
- package/src/components/driver.ts +11 -0
- package/src/components/gate-plan.ts +9 -0
- package/src/components/pr-pipeline.ts +56 -15
- package/src/components/pr-run.ts +1 -1
- package/src/components/release-compile.test.ts +34 -0
- package/src/components/release-compile.ts +89 -8
- package/src/components/release-promote.test.ts +55 -2
- package/src/components/release-promote.ts +93 -23
- package/src/components/verbs/bom-writer.ts +1 -1
- package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
- package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
- package/src/components/wave-plan.ts +32 -4
- package/src/graph-detail.test.ts +57 -0
- package/src/graph-detail.ts +33 -7
- package/src/health.schema.json +191 -0
- package/src/health.test.ts +62 -0
- package/src/health.ts +178 -0
- package/src/lexicon.ts +41 -1
- package/src/lifecycle/merge-record.test.ts +111 -0
- package/src/lifecycle/merge-record.ts +154 -0
- package/src/lifecycle/publish-attachments.ts +25 -13
- package/src/lifecycle/publish-npm.ts +4 -3
- package/src/lifecycle/publish-targets.test.ts +34 -4
- package/src/lifecycle/release-at.test.ts +48 -0
- package/src/lifecycle/release-at.ts +100 -0
- package/src/lifecycle/release-cut.test.ts +41 -0
- package/src/lifecycle/release-cut.ts +22 -11
- package/src/lifecycle/release-publish.test.ts +43 -13
- package/src/lifecycle/release-publish.ts +38 -18
- package/src/lifecycle/release-store.ts +57 -13
- package/src/lifecycle/run-ledger.test.ts +13 -0
- package/src/lifecycle/run-ledger.ts +1 -0
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
- package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
- package/src/op/activities/activity-contracts.ts +3 -0
- package/src/op/activities/shell-container.e2e.test.ts +67 -0
- package/src/op/activities/shell.test.ts +44 -1
- package/src/op/activities/shell.ts +110 -3
- package/src/op/builders.ts +7 -0
- package/src/op/runtime.ts +2 -0
- package/src/op/step-console.test.ts +4 -2
- package/src/pr-loop.test.ts +29 -0
- package/src/pr-loop.ts +26 -7
- package/src/pr-report-url.ts +2 -2
- package/src/runner-start-bundle.test.ts +29 -0
- package/src/runner-start.test.ts +179 -0
- package/src/runner-start.ts +329 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +70 -2
- package/src/workspace/checks/security.test.ts +138 -0
- package/src/workspace/checks/security.ts +305 -0
- package/src/workspace/checks.ts +5 -0
- package/src/workspace/ci-builds.e2e.test.ts +208 -0
- package/src/workspace/ci-builds.test.ts +174 -0
- package/src/workspace/ci-builds.ts +385 -0
- package/src/workspace/ci-green-builds.test.ts +180 -0
- package/src/workspace/ci-green-cli.test.ts +54 -0
- package/src/workspace/ci-green-cli.ts +26 -5
- package/src/workspace/ci-green-forge.ts +24 -0
- package/src/workspace/ci-green-workflow.test.ts +84 -1
- package/src/workspace/ci-green-workflow.ts +139 -2
- package/src/workspace/ci-green.test.ts +5 -2
- package/src/workspace/ci-green.ts +359 -27
- package/src/workspace/ci-last-green.schema.json +49 -0
- package/src/workspace/ci-list.schema.json +35 -1
- package/src/workspace/ci-run-index.schema.json +1 -1
- package/src/workspace/ci-run-index.ts +1 -1
- package/src/workspace/ci-run.test.ts +24 -7
- package/src/workspace/ci-run.ts +78 -16
- package/src/workspace/ci-runner-start.test.ts +151 -0
- package/src/workspace/ci-runner-start.ts +193 -0
- package/src/workspace/ci-serve-builds.test.ts +86 -0
- package/src/workspace/ci-serve.test.ts +59 -0
- package/src/workspace/ci-serve.ts +177 -22
- package/src/workspace/declaration.schema.json +23 -0
- package/src/workspace/declaration.test.ts +17 -0
- package/src/workspace/declaration.ts +22 -1
- package/src/workspace/ls.schema.json +2 -1
- package/src/workspace/ls.ts +3 -2
- package/src/workspace/member-pipeline.ts +2 -1
- package/src/workspace/merge-record.schema.json +49 -0
- package/src/workspace/pr-report.schema.json +3 -3
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +18 -0
- package/src/workspace/release-candidates.schema.json +353 -0
- package/src/workspace/release-candidates.test.ts +106 -0
- package/src/workspace/release-candidates.ts +315 -0
- package/src/workspace/security.test.ts +70 -0
- package/src/workspace/security.ts +118 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge records (#4168 step 1): what landed on the target branch, keyed by
|
|
3
|
+
* the commit, kept on `chant/lifecycle` so a release can say which changes
|
|
4
|
+
* it carries and whether each was approved.
|
|
5
|
+
*
|
|
6
|
+
* _merges/<commit>.json one record per commit, rewritten when the record job runs again
|
|
7
|
+
*
|
|
8
|
+
* A merge never deploys. The record job (`chant components pr-record`)
|
|
9
|
+
* plans the members the change reaches on the merged commit, stores the
|
|
10
|
+
* change set (../lifecycle/change-set-store.ts) and writes this record. It
|
|
11
|
+
* applies nothing and refuses nothing: a plan that differs from the
|
|
12
|
+
* approved one is recorded, and the job sets a failing `chant/record`
|
|
13
|
+
* status (#4168 decision 1).
|
|
14
|
+
*
|
|
15
|
+
* The record names where the change came from (#4168 4.4): the pull request
|
|
16
|
+
* that merged it, the work items its `Chant-Record: work:<id>` trailers name,
|
|
17
|
+
* or a direct push. A workspace member's record sits under
|
|
18
|
+
* `_members/<member>/_merges/` through the same `ledgerDir` prefix the gate
|
|
19
|
+
* ledger uses.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
import { sortedJsonReplacer } from "../utils";
|
|
24
|
+
import { parseRecordRef, RECORD_TRAILER, type RecordRef } from "../workspace/trailers";
|
|
25
|
+
import { ledgerDir, readBlobFromPath, readPathSha, RefCASConflictError, writeLedgerFiles, type LedgerLocation } from "./git";
|
|
26
|
+
|
|
27
|
+
/** The directory on `chant/lifecycle` that holds merge records. */
|
|
28
|
+
export const MERGES_DIR = "_merges";
|
|
29
|
+
|
|
30
|
+
export const MERGE_RECORD_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/merge-record/v1/merge-record.schema.json";
|
|
31
|
+
export const MERGE_RECORD_CONTRACT = 1;
|
|
32
|
+
|
|
33
|
+
/** Where the change came from. */
|
|
34
|
+
export type MergeSource =
|
|
35
|
+
/** The pull or merge request that merged the commit. */
|
|
36
|
+
| { kind: "pull-request"; pr: number }
|
|
37
|
+
/** No pull request merged it; its `Chant-Record` trailers name the work items it carries out. */
|
|
38
|
+
| { kind: "work-item"; records: RecordRef[] }
|
|
39
|
+
/** No pull request merged it and its message names no work item. */
|
|
40
|
+
| { kind: "direct-push" };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How the planned change set compares with the approval. `approved`: an
|
|
44
|
+
* approval stands for this digest. `changed`: one stands for another digest,
|
|
45
|
+
* so the plan moved after review. `unapproved`: none stands. `nothing`: no
|
|
46
|
+
* component deploys what the change touches. `plan-failed`: a member failed
|
|
47
|
+
* to plan, so there is no digest to compare.
|
|
48
|
+
*/
|
|
49
|
+
export type MergePlanStatus = "approved" | "changed" | "unapproved" | "nothing" | "plan-failed";
|
|
50
|
+
|
|
51
|
+
export interface MergeRecord {
|
|
52
|
+
$schema: typeof MERGE_RECORD_SCHEMA_ID;
|
|
53
|
+
contract: typeof MERGE_RECORD_CONTRACT;
|
|
54
|
+
/** The merged commit. */
|
|
55
|
+
commit: string;
|
|
56
|
+
/** The commit the change is measured from. */
|
|
57
|
+
base: string;
|
|
58
|
+
env: string;
|
|
59
|
+
/** The workspace member whose pipeline recorded it. */
|
|
60
|
+
member?: string;
|
|
61
|
+
source: MergeSource;
|
|
62
|
+
/** The pull request's gate op (`pr-<n>`), or null without a pull request. */
|
|
63
|
+
op: string | null;
|
|
64
|
+
gate: string;
|
|
65
|
+
/** The change-set digest planned on the merged commit, or null when nothing was planned. */
|
|
66
|
+
digest: string | null;
|
|
67
|
+
plan: MergePlanStatus;
|
|
68
|
+
/** With `changed`: the digest the approval stands for. */
|
|
69
|
+
approvedDigest?: string;
|
|
70
|
+
/** Who approved the planned digest, with `approved`. */
|
|
71
|
+
approvedBy: string[];
|
|
72
|
+
/**
|
|
73
|
+
* Approvers who wrote the change: the pull request's author, or an author
|
|
74
|
+
* or committer of one of its commits (#4000). Read only with a forge. An
|
|
75
|
+
* approval by its author counts toward nothing under the review-rules
|
|
76
|
+
* group; the record keeps it so a release gate can say so.
|
|
77
|
+
*/
|
|
78
|
+
authorApproved?: string[];
|
|
79
|
+
/** The components the change selects, in plan order. */
|
|
80
|
+
components: string[];
|
|
81
|
+
/** ISO 8601. */
|
|
82
|
+
recordedAt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Where `commit` came from: the pull request `pr` when one merged it, else
|
|
87
|
+
* the work items its `Chant-Record: work:<id>` trailers name, else a direct
|
|
88
|
+
* push. A commit git can't read names no work item.
|
|
89
|
+
*/
|
|
90
|
+
export function mergeSourceOf(cwd: string, commit: string, pr: number | null): MergeSource {
|
|
91
|
+
if (pr !== null) return { kind: "pull-request", pr };
|
|
92
|
+
let text = "";
|
|
93
|
+
try {
|
|
94
|
+
text = execFileSync("git", ["log", "-1", `--format=%(trailers:key=${RECORD_TRAILER},valueonly,separator=%x1e)`, commit], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
95
|
+
} catch {
|
|
96
|
+
text = "";
|
|
97
|
+
}
|
|
98
|
+
const records: RecordRef[] = [];
|
|
99
|
+
for (const value of text.split("\x1e").map((v) => v.trim()).filter(Boolean)) {
|
|
100
|
+
const ref = parseRecordRef(value);
|
|
101
|
+
if (ref && ref.kind === "work" && !records.some((r) => r.id === ref.id)) records.push(ref);
|
|
102
|
+
}
|
|
103
|
+
return records.length > 0 ? { kind: "work-item", records } : { kind: "direct-push" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The file a commit's record is stored in, inside {@link MERGES_DIR}. */
|
|
107
|
+
export function mergeRecordFile(commit: string): string {
|
|
108
|
+
return `${commit}.json`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Write `record` under its commit and return whether anything changed. A
|
|
113
|
+
* record already there with the same content is left alone; another is
|
|
114
|
+
* replaced, since the record job may run again on the same commit. Does not
|
|
115
|
+
* push: call `pushLifecycleReport` (./git.ts) after.
|
|
116
|
+
*/
|
|
117
|
+
export async function writeMergeRecord(record: MergeRecord, opts?: LedgerLocation): Promise<{ commit: string | null; written: boolean }> {
|
|
118
|
+
const file = mergeRecordFile(record.commit);
|
|
119
|
+
const content = `${JSON.stringify(record, sortedJsonReplacer)}\n`;
|
|
120
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
121
|
+
const prior = await readPathSha(MERGES_DIR, file, opts);
|
|
122
|
+
if (prior !== null) {
|
|
123
|
+
const text = await readBlobFromPath(MERGES_DIR, file, opts);
|
|
124
|
+
if (text !== null && sameRecord(text, record)) return { commit: null, written: false };
|
|
125
|
+
}
|
|
126
|
+
const dir = await ledgerDir(MERGES_DIR, opts);
|
|
127
|
+
try {
|
|
128
|
+
const commit = await writeLedgerFiles([{ path: `${dir}/${file}`, content, expectPriorSha: prior }], `Merge record ${record.commit}`, opts);
|
|
129
|
+
return { commit, written: true };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (!(err instanceof RefCASConflictError)) throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`could not write the merge record for ${record.commit}: chant/lifecycle kept moving`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The same record, ignoring when it was written. */
|
|
138
|
+
function sameRecord(text: string, record: MergeRecord): boolean {
|
|
139
|
+
try {
|
|
140
|
+
const { recordedAt: _a, ...had } = JSON.parse(text) as MergeRecord;
|
|
141
|
+
const { recordedAt: _b, ...now } = record;
|
|
142
|
+
return JSON.stringify(had, sortedJsonReplacer) === JSON.stringify(now, sortedJsonReplacer);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The record of `commit`, or null when none is stored. Reads the local `chant/lifecycle` and never fetches. */
|
|
149
|
+
export async function readMergeRecord(commit: string, opts?: LedgerLocation): Promise<MergeRecord | null> {
|
|
150
|
+
const text = await readBlobFromPath(MERGES_DIR, mergeRecordFile(commit), opts);
|
|
151
|
+
if (text === null) return null;
|
|
152
|
+
const doc = JSON.parse(text) as MergeRecord;
|
|
153
|
+
return doc?.$schema === MERGE_RECORD_SCHEMA_ID ? doc : null;
|
|
154
|
+
}
|
|
@@ -30,7 +30,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
|
30
30
|
import { dirname, join } from "node:path";
|
|
31
31
|
import { sortedJsonReplacer } from "../utils";
|
|
32
32
|
import { writeBom } from "../components/verbs/bom-writer";
|
|
33
|
-
import {
|
|
33
|
+
import { LOCKFILE_NAMES, LOCKFILE_PARSERS } from "../components/verbs/lockfile-sbom-generator";
|
|
34
34
|
import { SBOM_MEDIA_TYPES } from "../components/verbs/sbom-generator";
|
|
35
35
|
import { bytesDigest, checkReleaseDigest, memberFile } from "./publish-targets";
|
|
36
36
|
import { PublishTargetError, type PublishContext } from "./release-publish";
|
|
@@ -104,18 +104,25 @@ function invalid(where: string): PublishTargetError {
|
|
|
104
104
|
return new PublishTargetError("publish-sbom-invalid", `${where} is not an SPDX or CycloneDX JSON document`, "Point sbom at the SPDX or CycloneDX JSON the build writes.");
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
107
|
+
/**
|
|
108
|
+
* The lockfile nearest the member: `package-lock.json`, `pom.xml` or
|
|
109
|
+
* `Cargo.lock` in `cwd` or a directory above it inside its repository (only
|
|
110
|
+
* `cwd` outside one). A member whose directory holds a `Cargo.toml` is a
|
|
111
|
+
* Rust member, so its `Cargo.lock` comes first (#4168 decision 12); for any
|
|
112
|
+
* other, the order is npm, Maven, Cargo.
|
|
113
|
+
*/
|
|
114
|
+
function nearestLockfile(cwd: string): { kind: "npm" | "maven" | "cargo"; path: string } | null {
|
|
109
115
|
let top: string | null = null;
|
|
110
116
|
try {
|
|
111
117
|
top = realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim());
|
|
112
118
|
} catch {
|
|
113
119
|
top = null;
|
|
114
120
|
}
|
|
121
|
+
const order = existsSync(join(cwd, "Cargo.toml")) ? (["cargo", "npm", "maven"] as const) : (["npm", "maven", "cargo"] as const);
|
|
115
122
|
let dir = realpathSync(cwd);
|
|
116
123
|
for (;;) {
|
|
117
|
-
for (const
|
|
118
|
-
if (existsSync(join(dir,
|
|
124
|
+
for (const kind of order) {
|
|
125
|
+
if (existsSync(join(dir, LOCKFILE_NAMES[kind]))) return { kind, path: join(dir, LOCKFILE_NAMES[kind]) };
|
|
119
126
|
}
|
|
120
127
|
const up = dirname(dir);
|
|
121
128
|
if (top === null || dir === top || up === dir || !up.startsWith(top)) return null;
|
|
@@ -127,8 +134,9 @@ function nearestLockfile(cwd: string): { kind: "npm" | "maven"; path: string } |
|
|
|
127
134
|
* The SBOM a target attaches, for an OCI image or a forge release: the file
|
|
128
135
|
* `declared` names (relative to the member, checked against the release's
|
|
129
136
|
* digest when the release records it), else one written from the member's
|
|
130
|
-
* lockfile for `subject`. Null when `declared` is `false
|
|
131
|
-
* `
|
|
137
|
+
* lockfile for `subject`. Null when `declared` is `false`, and when there is
|
|
138
|
+
* neither and `ctx.requireSbom` is not set (#4168). Throws
|
|
139
|
+
* `publish-sbom-missing` when there is neither under `supply-chain`.
|
|
132
140
|
*/
|
|
133
141
|
export function memberSbom(ctx: PublishContext, declared: string | false | undefined, subject: string, target: string): Attachment | null {
|
|
134
142
|
if (declared === false) return null;
|
|
@@ -142,18 +150,19 @@ export function memberSbom(ctx: PublishContext, declared: string | false | undef
|
|
|
142
150
|
}
|
|
143
151
|
const lock = nearestLockfile(ctx.cwd);
|
|
144
152
|
if (!lock) {
|
|
153
|
+
if (!ctx.requireSbom) return null;
|
|
145
154
|
throw new PublishTargetError(
|
|
146
155
|
"publish-sbom-missing",
|
|
147
|
-
`${target} attaches an SBOM to what it publishes, and its declaration names none and there is no package-lock.json
|
|
156
|
+
`${target} attaches an SBOM to what it publishes, and its declaration names none and there is no package-lock.json, pom.xml or Cargo.lock in ${ctx.cwd} or above it to write one from`,
|
|
148
157
|
"Set sbom in the declaration to the SPDX or CycloneDX file the build writes, or sbom: false to publish without one.",
|
|
149
158
|
);
|
|
150
159
|
}
|
|
151
160
|
const content = readFileSync(lock.path, "utf-8");
|
|
152
|
-
const packages = lock.kind
|
|
161
|
+
const packages = LOCKFILE_PARSERS[lock.kind](content);
|
|
153
162
|
const at = new Date(ctx.cut?.timestamp ?? 0);
|
|
154
163
|
const doc = writeBom(
|
|
155
164
|
"spdx",
|
|
156
|
-
{ subjectName: subject, subjectId: ctx.release.releaseDigest, packages, generator: `chant-lockfile-sbom/${lock.kind
|
|
165
|
+
{ subjectName: subject, subjectId: ctx.release.releaseDigest, packages, generator: `chant-lockfile-sbom/${LOCKFILE_NAMES[lock.kind]}` },
|
|
157
166
|
() => (Number.isNaN(at.getTime()) ? new Date(0) : at),
|
|
158
167
|
);
|
|
159
168
|
return attachment("sbom", GENERATED_SBOM_FILE, doc.mediaType, new Uint8Array(Buffer.from(doc.bytes, "utf-8")));
|
|
@@ -162,11 +171,13 @@ export function memberSbom(ctx: PublishContext, declared: string | false | undef
|
|
|
162
171
|
/**
|
|
163
172
|
* The SBOM an npm package carries at its root: the file `declared` names, else
|
|
164
173
|
* the first of {@link PACKAGE_SBOM_FILES} it holds, in a package directory or
|
|
165
|
-
* under `package/` in a packed `.tgz`. Null when `declared` is `false
|
|
166
|
-
*
|
|
174
|
+
* under `package/` in a packed `.tgz`. Null when `declared` is `false`, and
|
|
175
|
+
* when the package has none and `required` is false (#4168). Throws
|
|
176
|
+
* `publish-sbom-missing` when it has none and `required` is set (the
|
|
177
|
+
* `supply-chain` group) or `declared` names a file, and
|
|
167
178
|
* `publish-sbom-invalid` when it is not SPDX or CycloneDX JSON.
|
|
168
179
|
*/
|
|
169
|
-
export function packageSbom(abs: string, isDirectory: boolean, path: string, name: string, declared: string | false | undefined): { file: string; digest: string } | null {
|
|
180
|
+
export function packageSbom(abs: string, isDirectory: boolean, path: string, name: string, declared: string | false | undefined, required = false): { file: string; digest: string } | null {
|
|
170
181
|
if (declared === false) return null;
|
|
171
182
|
const candidates = declared !== undefined ? [declared] : [...PACKAGE_SBOM_FILES];
|
|
172
183
|
let listed: Set<string> | null = null;
|
|
@@ -188,6 +199,7 @@ export function packageSbom(abs: string, isDirectory: boolean, path: string, nam
|
|
|
188
199
|
if (!sbomMediaType(bytes)) throw invalid(`${file} in ${path}`);
|
|
189
200
|
return { file, digest: bytesDigest(bytes) };
|
|
190
201
|
}
|
|
202
|
+
if (!required && declared === undefined) return null;
|
|
191
203
|
throw new PublishTargetError(
|
|
192
204
|
"publish-sbom-missing",
|
|
193
205
|
`${name} (${path}) carries no SBOM: npm keeps no file beside a package, so its SBOM goes in it, and ${declared !== undefined ? declared : `none of ${PACKAGE_SBOM_FILES.join(", ")}`} is at its root`,
|
|
@@ -35,8 +35,9 @@
|
|
|
35
35
|
* Each package carries its SBOM (#4049): npm keeps no file beside a package,
|
|
36
36
|
* so the SBOM is a file at the package's root, the one `sbom` names or the
|
|
37
37
|
* first of `sbom.spdx.json`, `sbom.cdx.json`, `sbom.cyclonedx.json` it holds,
|
|
38
|
-
* such as `npm sbom` writes. A package with none
|
|
39
|
-
*
|
|
38
|
+
* such as `npm sbom` writes. A package with none publishes without one,
|
|
39
|
+
* except under the `supply-chain` group (#4168) or when `sbom` names a file,
|
|
40
|
+
* where it is refused (`publish-sbom-missing`). Moving a dist-tag over
|
|
40
41
|
* OIDC needs "Allow npm dist-tag" on the package's trusted-publisher record.
|
|
41
42
|
* npm makes the first version of a new package `latest` whatever tag it is
|
|
42
43
|
* published under, so a package's first release is visible when it stages.
|
|
@@ -100,7 +101,7 @@ function readPackage(ctx: PublishContext, path: string, sbom: NpmPublishDeclarat
|
|
|
100
101
|
if (pkg.private === true) {
|
|
101
102
|
throw new PublishTargetError("publish-npm-private", `${pkg.name} (${path}) is private, and npm refuses to publish it`, "Remove it from release.publish, or remove private: true.");
|
|
102
103
|
}
|
|
103
|
-
const carried = packageSbom(abs, directory, path, pkg.name, sbom);
|
|
104
|
+
const carried = packageSbom(abs, directory, path, pkg.name, sbom, ctx.requireSbom === true);
|
|
104
105
|
return { path, abs, name: pkg.name, version: pkg.version, ...(digest ? { digest } : {}), ...(carried ? { sbom: carried.file } : {}) };
|
|
105
106
|
}
|
|
106
107
|
|
|
@@ -17,6 +17,7 @@ import { join } from "node:path";
|
|
|
17
17
|
import { createRedactor, REDACTED } from "./publish-credentials";
|
|
18
18
|
import { PublishDeclarationSchema } from "./publish-declaration";
|
|
19
19
|
import { createPublishTargets } from "./publish-targets";
|
|
20
|
+
import { memberSbom } from "./publish-attachments";
|
|
20
21
|
import { parseRepository } from "./publish-oci";
|
|
21
22
|
import { PublishTargetError, type PublishContext, type PublishTarget, type PublishedTarget } from "./release-publish";
|
|
22
23
|
import { makeRelease, type ReleaseArtifact } from "./release-store";
|
|
@@ -396,7 +397,7 @@ describe("forge release assets", () => {
|
|
|
396
397
|
describe("SBOMs and provenance (#4049)", () => {
|
|
397
398
|
const CUT = { actor: "alice", label: "v1.4.0", timestamp: "2026-10-17T10:00:00.000Z", runId: "local-1", signature: "-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----\n" };
|
|
398
399
|
|
|
399
|
-
test("npm: a package carries its SBOM
|
|
400
|
+
test("npm: a package carries its SBOM; under supply-chain one with none is refused; one that is not SPDX or CycloneDX is refused; sbom: false publishes without", async () => {
|
|
400
401
|
const dir = temp();
|
|
401
402
|
writePackage(join(dir, "with"), "@acme/with", "1.0.0");
|
|
402
403
|
writePackage(join(dir, "without"), "@acme/without", "1.0.0", false);
|
|
@@ -406,9 +407,11 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
406
407
|
writeFileSync(join(dir, "cdx", "package", "bom.json"), JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5" }));
|
|
407
408
|
execFileSync("tar", ["-czf", join(dir, "cdx.tgz"), "-C", join(dir, "cdx"), "package"], { stdio: "ignore" });
|
|
408
409
|
const npm = writeFakeNpm(join(dir, "bin"), join(dir, "npm.log"));
|
|
409
|
-
const plan = async (d: Record<string, unknown
|
|
410
|
+
const plan = async (d: Record<string, unknown>, requireSbom = true) => (await createPublishTargets([decl({ type: "npm", ...d })], { npm }))[0]!.plan(context(dir, { requireSbom }));
|
|
410
411
|
|
|
411
412
|
expect((await plan({ packages: ["with"] }))[0]!.sbom).toBe("sbom.spdx.json");
|
|
413
|
+
// #4168: outside supply-chain a package with none publishes without one, quietly.
|
|
414
|
+
expect((await plan({ packages: ["without"] }, false))[0]!.sbom).toBeUndefined();
|
|
412
415
|
const missing = await refused(plan({ packages: ["without"] }));
|
|
413
416
|
expect(missing.code).toBe("publish-sbom-missing");
|
|
414
417
|
expect(missing.message).toBe("@acme/without (without) carries no SBOM: npm keeps no file beside a package, so its SBOM goes in it, and none of sbom.spdx.json, sbom.cdx.json, sbom.cyclonedx.json is at its root");
|
|
@@ -471,14 +474,15 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
471
474
|
}
|
|
472
475
|
});
|
|
473
476
|
|
|
474
|
-
test("forge: with no declared SBOM and no lockfile the target is refused; a declared SBOM goes on the release by its own name", async () => {
|
|
477
|
+
test("forge: with no declared SBOM and no lockfile the target is refused under supply-chain and attaches none otherwise; a declared SBOM goes on the release by its own name", async () => {
|
|
475
478
|
const dir = temp();
|
|
476
479
|
writeFileSync(join(dir, "api.tar.gz"), "bytes");
|
|
477
480
|
const api = "https://api.github.test";
|
|
478
481
|
const forge = startFakeForge("github", api, "acme/api");
|
|
479
482
|
const make = async (over: Record<string, unknown> = {}) => (await createPublishTargets([decl({ type: "forge", forge: "github", repo: "acme/api", api, assets: ["api.tar.gz"], ...over })], { fetch: forge.fetch }))[0]!;
|
|
480
483
|
const ctx = context(dir, { env: { GITHUB_TOKEN: "ghs_x" }, cut: CUT });
|
|
481
|
-
|
|
484
|
+
expect((await (await make()).plan(ctx)).map((i) => i.attachment ?? null)).not.toContain("sbom");
|
|
485
|
+
const err = await refused((await make()).plan({ ...ctx, requireSbom: true }));
|
|
482
486
|
expect(err.code).toBe("publish-sbom-missing");
|
|
483
487
|
expect(err.fix).toContain("sbom: false");
|
|
484
488
|
|
|
@@ -492,3 +496,29 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
492
496
|
expect(statement.predicate.cut.signature).toBe(CUT.signature);
|
|
493
497
|
});
|
|
494
498
|
});
|
|
499
|
+
|
|
500
|
+
describe("the Cargo.lock SBOM source (#4168 decision 12)", () => {
|
|
501
|
+
const CARGO_LOCK = '# This file is automatically @generated by Cargo.\nversion = 4\n\n[[package]]\nname = "app"\nversion = "0.1.0"\ndependencies = [\n "serde",\n]\n\n[[package]]\nname = "serde"\nversion = "1.0.210"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "abc"\n';
|
|
502
|
+
const NPM_LOCK = JSON.stringify({ name: "web", lockfileVersion: 3, packages: { "": { name: "web" }, "node_modules/left-pad": { version: "1.3.0" } } });
|
|
503
|
+
|
|
504
|
+
test("a member with a Cargo.toml writes its SBOM from Cargo.lock, ahead of a package-lock.json beside it", () => {
|
|
505
|
+
const dir = temp();
|
|
506
|
+
writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = \"app\"\n");
|
|
507
|
+
writeFileSync(join(dir, "Cargo.lock"), CARGO_LOCK);
|
|
508
|
+
writeFileSync(join(dir, "package-lock.json"), NPM_LOCK);
|
|
509
|
+
const sbom = memberSbom(context(dir), undefined, "app", "forge release assets")!;
|
|
510
|
+
const text = Buffer.from(sbom.bytes).toString("utf-8");
|
|
511
|
+
expect(text).toContain("pkg:cargo/serde@1.0.210");
|
|
512
|
+
expect(text).toContain("chant-lockfile-sbom/Cargo.lock");
|
|
513
|
+
expect(text).not.toContain("left-pad");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("any other member reads package-lock.json first", () => {
|
|
517
|
+
const dir = temp();
|
|
518
|
+
writeFileSync(join(dir, "Cargo.lock"), CARGO_LOCK);
|
|
519
|
+
writeFileSync(join(dir, "package-lock.json"), NPM_LOCK);
|
|
520
|
+
const text = Buffer.from(memberSbom(context(dir), undefined, "web", "forge release assets")!.bytes).toString("utf-8");
|
|
521
|
+
expect(text).toContain("left-pad");
|
|
522
|
+
expect(text).not.toContain("pkg:cargo/");
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `release cut --at` (#4168 4.2): the commit is checked out in a temporary
|
|
3
|
+
* worktree with the checkout's node_modules linked in, and removed after.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, test, expect } from "vitest";
|
|
7
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { checkoutCommit, CutAtError, resolveCutCommit } from "./release-at";
|
|
12
|
+
|
|
13
|
+
function git(dir: string, ...args: string[]): string {
|
|
14
|
+
const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
15
|
+
if (r.status !== 0) throw new Error(r.stderr);
|
|
16
|
+
return r.stdout.trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("checkoutCommit", () => {
|
|
20
|
+
test("checks the commit out detached, links node_modules at the top and the member, and removes it", async () => {
|
|
21
|
+
await withTestDir(async (dir) => {
|
|
22
|
+
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@chant.dev"], ["config", "user.name", "T"]]) git(dir, ...args);
|
|
23
|
+
mkdirSync(join(dir, "members", "api"), { recursive: true });
|
|
24
|
+
writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
|
|
25
|
+
writeFileSync(join(dir, "members", "api", "f.txt"), "old");
|
|
26
|
+
git(dir, "add", ".");
|
|
27
|
+
git(dir, "commit", "-q", "-m", "one");
|
|
28
|
+
const one = git(dir, "rev-parse", "HEAD");
|
|
29
|
+
writeFileSync(join(dir, "members", "api", "f.txt"), "new");
|
|
30
|
+
git(dir, "commit", "-q", "-am", "two");
|
|
31
|
+
mkdirSync(join(dir, "node_modules"));
|
|
32
|
+
mkdirSync(join(dir, "members", "api", "node_modules"));
|
|
33
|
+
|
|
34
|
+
expect(resolveCutCommit(dir, "HEAD~1")).toBe(one);
|
|
35
|
+
expect(() => resolveCutCommit(dir, "nope")).toThrow(CutAtError);
|
|
36
|
+
|
|
37
|
+
const co = checkoutCommit(dir, join(dir, "members", "api"), one);
|
|
38
|
+
expect(readFileSync(join(co.memberDir, "f.txt"), "utf-8")).toBe("old");
|
|
39
|
+
expect(lstatSync(join(co.dir, "node_modules")).isSymbolicLink()).toBe(true);
|
|
40
|
+
expect(lstatSync(join(co.memberDir, "node_modules")).isSymbolicLink()).toBe(true);
|
|
41
|
+
expect(git(co.dir, "rev-parse", "HEAD")).toBe(one);
|
|
42
|
+
co.remove();
|
|
43
|
+
co.remove();
|
|
44
|
+
expect(existsSync(co.dir)).toBe(false);
|
|
45
|
+
expect(git(dir, "worktree", "list").split("\n")).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `release cut --at <sha>` (#4168 4.2): cut from a chosen commit of trunk,
|
|
3
|
+
* not the checkout's `HEAD`. The commit is checked out in a temporary
|
|
4
|
+
* detached worktree, as `chant rollback` works in one (./rollback.ts), so the
|
|
5
|
+
* caller's branch and working tree are never touched and need not be clean.
|
|
6
|
+
* The cut then runs in the member's directory inside that worktree, with the
|
|
7
|
+
* same refusals a cut at `HEAD` has: a commit that is not green, a revoked
|
|
8
|
+
* one, and build steps that differ by environment.
|
|
9
|
+
*
|
|
10
|
+
* Refs are shared between worktrees, so the release and the cut the cut
|
|
11
|
+
* writes to `chant/lifecycle`, the tags it reads and the tag it makes are the
|
|
12
|
+
* repository's own. The checkout's `node_modules` are linked in, so the
|
|
13
|
+
* compile resolves the packages the checkout already installed; a commit
|
|
14
|
+
* whose lockfile differs from the checkout's would need its own install
|
|
15
|
+
* (inferred, as the cut can't tell).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
22
|
+
|
|
23
|
+
export type CutAtCode = "release-at-unknown";
|
|
24
|
+
|
|
25
|
+
export class CutAtError extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly code: CutAtCode,
|
|
28
|
+
message: string,
|
|
29
|
+
readonly fix?: string,
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "CutAtError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function git(cwd: string, args: string[]): string {
|
|
37
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The full sha `ref` names in the repository at `top`, or a {@link CutAtError}. */
|
|
41
|
+
export function resolveCutCommit(top: string, ref: string): string {
|
|
42
|
+
try {
|
|
43
|
+
return git(top, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new CutAtError(
|
|
46
|
+
"release-at-unknown",
|
|
47
|
+
`--at ${ref} names no commit this clone has`,
|
|
48
|
+
"Fetch it first (git fetch origin --tags), or name a sha `chant release candidates` lists.",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A checkout of one commit for the cut, and the member's directory inside it. */
|
|
54
|
+
export interface CommitCheckout {
|
|
55
|
+
/** The worktree's top. */
|
|
56
|
+
dir: string;
|
|
57
|
+
/** The member's directory inside it, the cut's `cwd`. */
|
|
58
|
+
memberDir: string;
|
|
59
|
+
/** Remove the worktree. Safe to call twice. */
|
|
60
|
+
remove(): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check `commit` out in a temporary detached worktree of the repository at
|
|
65
|
+
* `top`, with the checkout's `node_modules` linked at the top and at each
|
|
66
|
+
* directory down to `cwd`, the member's directory in the caller's checkout.
|
|
67
|
+
*/
|
|
68
|
+
export function checkoutCommit(top: string, cwd: string, commit: string): CommitCheckout {
|
|
69
|
+
const realTop = realpathSync(top);
|
|
70
|
+
const rel = relative(realTop, realpathSync(cwd));
|
|
71
|
+
const dir = mkdtempSync(join(tmpdir(), `chant-cut-${commit.slice(0, 12)}-`));
|
|
72
|
+
git(realTop, ["worktree", "add", "--detach", "--quiet", dir, commit]);
|
|
73
|
+
let removed = false;
|
|
74
|
+
const remove = (): void => {
|
|
75
|
+
if (removed) return;
|
|
76
|
+
removed = true;
|
|
77
|
+
try {
|
|
78
|
+
git(realTop, ["worktree", "remove", "--force", dir]);
|
|
79
|
+
} catch {
|
|
80
|
+
rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
try {
|
|
82
|
+
git(realTop, ["worktree", "prune"]);
|
|
83
|
+
} catch {
|
|
84
|
+
// Nothing left to prune.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
const levels = rel === "" ? [""] : ["", ...rel.split(sep).map((_, i, parts) => parts.slice(0, i + 1).join(sep))];
|
|
90
|
+
for (const level of levels) {
|
|
91
|
+
const from = join(realTop, level, "node_modules");
|
|
92
|
+
const to = join(dir, level, "node_modules");
|
|
93
|
+
if (existsSync(from) && !existsSync(to) && existsSync(dirname(to))) symlinkSync(from, to, "dir");
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
remove();
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
return { dir, memberDir: resolve(dir, rel), remove };
|
|
100
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
readReleaseTemplate,
|
|
21
21
|
ReleaseLabelError,
|
|
22
22
|
resolveRelease,
|
|
23
|
+
templateBytesDigest,
|
|
23
24
|
verifyReleaseDigest,
|
|
24
25
|
type ReleaseArtifact,
|
|
25
26
|
} from "./release-store";
|
|
@@ -114,6 +115,14 @@ describe("the release digest", () => {
|
|
|
114
115
|
expect(computeReleaseDigest({ ...subject, member: "api" })).not.toBe(digest);
|
|
115
116
|
});
|
|
116
117
|
|
|
118
|
+
test("a target without trees or encodings hashes as before #4155; trees and an encoding are in the digest", () => {
|
|
119
|
+
const digest = computeReleaseDigest(subject);
|
|
120
|
+
const t = subject.targets[0];
|
|
121
|
+
expect(computeReleaseDigest({ ...subject, targets: [{ ...t, trees: [] }] })).not.toBe(digest);
|
|
122
|
+
expect(computeReleaseDigest({ ...subject, targets: [{ ...t, trees: [{ component: "api", path: "chart" }] }] })).not.toBe(computeReleaseDigest({ ...subject, targets: [{ ...t, trees: [] }] }));
|
|
123
|
+
expect(computeReleaseDigest({ ...subject, targets: [{ ...t, templates: [{ ...t.templates[0], encoding: "base64" as const }] }] })).not.toBe(digest);
|
|
124
|
+
});
|
|
125
|
+
|
|
117
126
|
test("verifyReleaseDigest catches a release whose content no longer matches its digest", () => {
|
|
118
127
|
const release = makeRelease(subject);
|
|
119
128
|
expect(verifyReleaseDigest(release)).toBe(true);
|
|
@@ -122,6 +131,38 @@ describe("the release digest", () => {
|
|
|
122
131
|
});
|
|
123
132
|
|
|
124
133
|
describe("cutRelease", () => {
|
|
134
|
+
test("records a directory's files and a file that is not UTF-8, which read back by digest (#4155)", async () => {
|
|
135
|
+
await withTestDir(async (dir) => {
|
|
136
|
+
const sha = initRepo(dir);
|
|
137
|
+
green(dir, sha);
|
|
138
|
+
const tgz = Buffer.from([0x1f, 0x8b, 0xff]).toString("base64");
|
|
139
|
+
const base = fixtureCompiler();
|
|
140
|
+
const compiler: TargetCompiler = {
|
|
141
|
+
...base,
|
|
142
|
+
async compile(env) {
|
|
143
|
+
const t = await base.compile(env);
|
|
144
|
+
return {
|
|
145
|
+
...t,
|
|
146
|
+
templates: [
|
|
147
|
+
...t.templates,
|
|
148
|
+
{ component: "api", path: "chart/Chart.yaml", content: "name: api\n" },
|
|
149
|
+
{ component: "api", path: "chart/charts/dep.tgz", content: tgz, encoding: "base64" },
|
|
150
|
+
],
|
|
151
|
+
trees: [{ component: "api", path: "chart" }],
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const { release } = await cut(dir, compiler, null);
|
|
156
|
+
const prod = release.targets.find((t) => t.env === "prod")!;
|
|
157
|
+
expect(prod.trees).toEqual([{ component: "api", path: "chart" }]);
|
|
158
|
+
const bin = prod.templates.find((t) => t.path === "chart/charts/dep.tgz")!;
|
|
159
|
+
expect(bin).toMatchObject({ encoding: "base64", digest: templateBytesDigest(tgz, "base64") });
|
|
160
|
+
expect(await readReleaseTemplate(bin.digest, { cwd: dir }, "base64")).toBe(tgz);
|
|
161
|
+
expect(await readReleaseTemplate(bin.digest, { cwd: dir })).toBeNull();
|
|
162
|
+
expect((await readRelease(release.releaseDigest, { cwd: dir }))?.targets.find((t) => t.env === "prod")?.trees).toEqual([{ component: "api", path: "chart" }]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
125
166
|
test("cuts a release from a green commit and stores it apart from the deploy records", async () => {
|
|
126
167
|
await withTestDir(async (dir) => {
|
|
127
168
|
const sha = initRepo(dir);
|
|
@@ -37,7 +37,6 @@ import { execFileSync } from "node:child_process";
|
|
|
37
37
|
import { realpathSync } from "node:fs";
|
|
38
38
|
import { relative, resolve } from "node:path";
|
|
39
39
|
import { canonicalJson } from "../effect-receipt";
|
|
40
|
-
import { contentDigest } from "../content-digest";
|
|
41
40
|
import { getHeadCommit } from "./git";
|
|
42
41
|
import { resolveMemberLedger } from "./member-ledger";
|
|
43
42
|
import { resolveCiRun } from "./release-ledger";
|
|
@@ -55,6 +54,7 @@ import {
|
|
|
55
54
|
type ReleaseArtifact,
|
|
56
55
|
type ReleaseCut,
|
|
57
56
|
type ReleaseTarget,
|
|
57
|
+
templateBytesDigest,
|
|
58
58
|
type TemplateContent,
|
|
59
59
|
} from "./release-store";
|
|
60
60
|
import type { CompiledTarget, TargetCompiler } from "../components/release-compile";
|
|
@@ -322,18 +322,29 @@ async function prepare(req: CutRequest): Promise<Prepared> {
|
|
|
322
322
|
};
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
/**
|
|
325
|
+
/**
|
|
326
|
+
* The targets and template bytes of a compile, each template by its content
|
|
327
|
+
* digest. A compile of components names its directories in `trees` (#4155),
|
|
328
|
+
* an empty list when it has none, which marks the target as one that records
|
|
329
|
+
* every deploy input. A project with no components has no deploy step to
|
|
330
|
+
* name one, and its targets have no `trees`.
|
|
331
|
+
*/
|
|
326
332
|
function targetsOf(compiled: readonly CompiledTarget[]): { targets: ReleaseTarget[]; templates: TemplateContent[] } {
|
|
327
333
|
const templates: TemplateContent[] = [];
|
|
328
|
-
const targets: ReleaseTarget[] = compiled.map((t) =>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
334
|
+
const targets: ReleaseTarget[] = compiled.map((t) => {
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
const recorded: ReleaseTarget["templates"] = [];
|
|
337
|
+
for (const tpl of t.templates) {
|
|
338
|
+
// A file named twice, as a values file inside a recorded chart, is recorded once.
|
|
339
|
+
const key = `${tpl.component ?? ""}\u0000${tpl.path}`;
|
|
340
|
+
if (seen.has(key)) continue;
|
|
341
|
+
seen.add(key);
|
|
342
|
+
const digest = templateBytesDigest(tpl.content, tpl.encoding);
|
|
343
|
+
templates.push({ digest, content: tpl.content, ...(tpl.encoding ? { encoding: tpl.encoding } : {}) });
|
|
344
|
+
recorded.push({ component: tpl.component, path: tpl.path, digest, ...(tpl.encoding ? { encoding: tpl.encoding } : {}) });
|
|
345
|
+
}
|
|
346
|
+
return { env: t.env, params: t.params, templates: recorded, ...(t.trees ? { trees: t.trees } : {}) };
|
|
347
|
+
});
|
|
337
348
|
return { targets, templates };
|
|
338
349
|
}
|
|
339
350
|
|