@intentius/chant 0.124.34 → 0.124.36
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/cli/handlers/graph.js +2 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +147 -51
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +23 -8
- package/dist/cli/handlers/release.d.ts +12 -0
- package/dist/cli/handlers/release.d.ts.map +1 -1
- package/dist/cli/handlers/release.js +125 -10
- package/dist/cli/handlers/run.d.ts +3 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +12 -2
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +19 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +17 -0
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +8 -1
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/auto-release.js +3 -2
- package/dist/components/green-cover.d.ts +77 -0
- package/dist/components/green-cover.d.ts.map +1 -0
- package/dist/components/green-cover.js +153 -0
- package/dist/components/pr-pipeline.d.ts +34 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +39 -5
- package/dist/components/promote.d.ts +4 -1
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/promote.js +6 -3
- package/dist/components/release-promote.d.ts +9 -0
- package/dist/components/release-promote.d.ts.map +1 -1
- package/dist/components/release-promote.js +15 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +5 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-declared.js +7 -2
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +30 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +13 -2
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts +5 -0
- package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +20 -2
- package/dist/lifecycle/compose-refs.d.ts +52 -0
- package/dist/lifecycle/compose-refs.d.ts.map +1 -0
- package/dist/lifecycle/compose-refs.js +115 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.js +3 -2
- package/dist/lifecycle/green-apply.d.ts +39 -0
- package/dist/lifecycle/green-apply.d.ts.map +1 -0
- package/dist/lifecycle/green-apply.js +51 -0
- package/dist/lifecycle/publish-declaration.d.ts +6 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +21 -0
- package/dist/lifecycle/publish-npm.d.ts +11 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +104 -14
- package/dist/lifecycle/release-compose.d.ts +91 -0
- package/dist/lifecycle/release-compose.d.ts.map +1 -0
- package/dist/lifecycle/release-compose.js +235 -0
- package/dist/lifecycle/release-cut.d.ts +8 -0
- package/dist/lifecycle/release-cut.d.ts.map +1 -1
- package/dist/lifecycle/release-cut.js +2 -0
- package/dist/lifecycle/release-publish.d.ts +6 -0
- package/dist/lifecycle/release-publish.d.ts.map +1 -1
- package/dist/lifecycle/release-publish.js +2 -1
- package/dist/lifecycle/release-store.d.ts +15 -0
- package/dist/lifecycle/release-store.d.ts.map +1 -1
- package/dist/lifecycle/release-store.js +5 -1
- package/dist/wave-runtime.mjs +225 -223
- package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/checks/ci-workflow.js +8 -1
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.js +5 -3
- package/dist/workspace/ci-green-workflow.d.ts +18 -2
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.js +27 -5
- package/dist/workspace/ci-green.d.ts +31 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +78 -4
- package/dist/workspace/ci-run.d.ts +6 -0
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +2 -1
- package/dist/workspace/ci-serve.d.ts +94 -13
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +248 -30
- package/dist/workspace/ci-workflow.d.ts +4 -4
- package/dist/workspace/ci-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-workflow.js +2 -2
- package/dist/workspace/delivery-metrics.d.ts +3 -1
- package/dist/workspace/delivery-metrics.d.ts.map +1 -1
- package/dist/workspace/delivery-metrics.js +12 -3
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +4 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +1 -0
- package/dist/workspace/release-candidates.d.ts +28 -0
- package/dist/workspace/release-candidates.d.ts.map +1 -1
- package/dist/workspace/release-candidates.js +38 -2
- package/dist/workspace/release-candidates.schema.json +51 -0
- package/dist/workspace/releases.schema.json +30 -0
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/pr-green.test.ts +230 -0
- package/src/cli/handlers/pr.ts +148 -66
- package/src/cli/handlers/promote.test.ts +34 -0
- package/src/cli/handlers/promote.ts +22 -8
- package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
- package/src/cli/handlers/release-publish-targets.e2e.test.ts +4 -1
- package/src/cli/handlers/release.ts +112 -10
- package/src/cli/handlers/run.ts +12 -1
- package/src/cli/main.ts +19 -1
- package/src/cli/parse-args.ts +13 -0
- package/src/cli/registry.ts +14 -0
- package/src/codegen/release-wiring.test.ts +56 -9
- package/src/components/auto-release.test.ts +22 -0
- package/src/components/auto-release.ts +9 -2
- package/src/components/green-cover.test.ts +170 -0
- package/src/components/green-cover.ts +186 -0
- package/src/components/pr-pipeline.test.ts +14 -4
- package/src/components/pr-pipeline.ts +56 -5
- package/src/components/promote.test.ts +7 -0
- package/src/components/promote.ts +9 -4
- package/src/components/release-promote.test.ts +24 -1
- package/src/components/release-promote.ts +15 -0
- package/src/deep-observation.ts +8 -0
- package/src/graph-declared.test.ts +18 -0
- package/src/graph-declared.ts +8 -1
- package/src/graph-detail.test.ts +68 -1
- package/src/graph-detail.ts +29 -1
- package/src/graph-ir.ts +12 -4
- package/src/lifecycle/__fixtures__/publish-fakes.ts +20 -2
- package/src/lifecycle/compose-refs.ts +125 -0
- package/src/lifecycle/deep-observe.test.ts +20 -0
- package/src/lifecycle/deep-observe.ts +4 -2
- package/src/lifecycle/green-apply.ts +71 -0
- package/src/lifecycle/publish-declaration.ts +21 -0
- package/src/lifecycle/publish-npm.ts +100 -14
- package/src/lifecycle/publish-targets.test.ts +108 -0
- package/src/lifecycle/release-compose.test.ts +125 -0
- package/src/lifecycle/release-compose.ts +312 -0
- package/src/lifecycle/release-cut.ts +6 -0
- package/src/lifecycle/release-publish.ts +8 -1
- package/src/lifecycle/release-store.ts +16 -1
- package/src/workspace/checks/ci-workflow.test.ts +12 -2
- package/src/workspace/checks/ci-workflow.ts +7 -1
- package/src/workspace/ci-green-cli.ts +5 -3
- package/src/workspace/ci-green-workflow.test.ts +52 -0
- package/src/workspace/ci-green-workflow.ts +34 -5
- package/src/workspace/ci-green.test.ts +33 -0
- package/src/workspace/ci-green.ts +85 -4
- package/src/workspace/ci-run.test.ts +18 -13
- package/src/workspace/ci-run.ts +8 -1
- package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
- package/src/workspace/ci-serve-land.test.ts +176 -0
- package/src/workspace/ci-serve.test.ts +77 -43
- package/src/workspace/ci-serve.ts +268 -34
- package/src/workspace/ci-workflow.ts +4 -4
- package/src/workspace/delivery-metrics.ts +12 -3
- package/src/workspace/graph-contract.test.ts +3 -2
- package/src/workspace/member-commands.test.ts +11 -4
- package/src/workspace/member-commands.ts +3 -0
- package/src/workspace/nested-graph.test.ts +4 -0
- package/src/workspace/nested-graph.ts +1 -0
- package/src/workspace/release-candidates.schema.json +51 -0
- package/src/workspace/release-candidates.test.ts +30 -3
- package/src/workspace/release-candidates.ts +54 -2
- package/src/workspace/releases.schema.json +30 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composed commits (#4168 4.3), as git holds them. A composed release lives
|
|
3
|
+
* on a pushed `chant/compose/<name>` branch whose commits each cherry-pick
|
|
4
|
+
* one trunk commit and name it in a `Chant-Composed-From: <sha>` trailer.
|
|
5
|
+
* The base is the commit the first composed commit was picked onto.
|
|
6
|
+
*
|
|
7
|
+
* Kept free of other chant imports, so the ci tick, the cut, the metrics
|
|
8
|
+
* and the compose command read composed commits the same way.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
/** The branch prefix a composed release lives under. */
|
|
14
|
+
export const COMPOSE_BRANCH_PREFIX = "chant/compose/";
|
|
15
|
+
|
|
16
|
+
/** The trailer naming the trunk commit a composed commit picks. */
|
|
17
|
+
export const COMPOSED_FROM_TRAILER = "Chant-Composed-From";
|
|
18
|
+
|
|
19
|
+
/** What a composed commit carries: its branch when one is known, its base, and the trunk commits picked onto the base, oldest first. */
|
|
20
|
+
export interface Composition {
|
|
21
|
+
branch: string | null;
|
|
22
|
+
base: string;
|
|
23
|
+
sources: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function tryGit(top: string, args: string[]): string | null {
|
|
27
|
+
try {
|
|
28
|
+
return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether `branch` names a compose branch. */
|
|
35
|
+
export function isComposeBranch(branch: unknown): branch is string {
|
|
36
|
+
return typeof branch === "string" && branch.startsWith(COMPOSE_BRANCH_PREFIX) && branch.length > COMPOSE_BRANCH_PREFIX.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The trunk commits one commit's `Chant-Composed-From` trailers name. */
|
|
40
|
+
export function composedFrom(top: string, sha: string): string[] {
|
|
41
|
+
const out = tryGit(top, ["show", "-s", `--format=%(trailers:key=${COMPOSED_FROM_TRAILER},valueonly,separator=%x00)`, sha]);
|
|
42
|
+
return (out ?? "")
|
|
43
|
+
.split("\0")
|
|
44
|
+
.map((s) => s.trim())
|
|
45
|
+
.filter((s) => /^[0-9a-f]{40}$/.test(s));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The composition `sha` ends: walk its first parents while each commit
|
|
50
|
+
* carries a `Chant-Composed-From` trailer. Null when `sha` carries none.
|
|
51
|
+
*/
|
|
52
|
+
export function compositionOf(top: string, sha: string, branch: string | null = null): Composition | null {
|
|
53
|
+
const sources: string[] = [];
|
|
54
|
+
let at = sha;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const from = composedFrom(top, at);
|
|
57
|
+
if (from.length === 0) break;
|
|
58
|
+
sources.unshift(...from);
|
|
59
|
+
const parent = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
|
|
60
|
+
if (!parent) break;
|
|
61
|
+
at = parent;
|
|
62
|
+
}
|
|
63
|
+
if (sources.length === 0) return null;
|
|
64
|
+
return { branch, base: at, sources };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The compose branches this clone knows on `remote`, as `{ branch, ref }`, by name. */
|
|
68
|
+
export function remoteComposeBranches(top: string, remote = "origin"): Array<{ branch: string; ref: string }> {
|
|
69
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
70
|
+
const out = tryGit(top, ["for-each-ref", "--format=%(refname)", `${prefix}${COMPOSE_BRANCH_PREFIX}`]);
|
|
71
|
+
return (out ?? "")
|
|
72
|
+
.split("\n")
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.map((ref) => ({ branch: ref.slice(prefix.length), ref }))
|
|
75
|
+
.sort((a, b) => (a.branch < b.branch ? -1 : 1));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a commit of a compose branch carries beyond trunk: walk its first
|
|
80
|
+
* parents back to the first commit `trunkRef` reaches, which is the base,
|
|
81
|
+
* collecting the `Chant-Composed-From` trailers on the way. A commit a
|
|
82
|
+
* person made on the branch, such as a resolved conflict, carries none and
|
|
83
|
+
* is walked past. Null when `sha` is on trunk or no trunk commit is found.
|
|
84
|
+
*/
|
|
85
|
+
export function compositionOnTrunk(top: string, sha: string, trunkRef: string, branch: string | null = null): Composition | null {
|
|
86
|
+
const onTrunk = (c: string) => tryGit(top, ["merge-base", "--is-ancestor", c, trunkRef]) !== null;
|
|
87
|
+
if (onTrunk(sha)) return null;
|
|
88
|
+
const sources: string[] = [];
|
|
89
|
+
let at: string | null = sha;
|
|
90
|
+
while (at && !onTrunk(at)) {
|
|
91
|
+
sources.unshift(...composedFrom(top, at));
|
|
92
|
+
at = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
|
|
93
|
+
}
|
|
94
|
+
return at ? { branch, base: at, sources } : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The pushed compose branch `sha` is a first-parent commit of, as this clone last fetched it, or null. */
|
|
98
|
+
export function composeBranchContaining(top: string, sha: string, remote = "origin"): { branch: string; ref: string } | null {
|
|
99
|
+
for (const b of remoteComposeBranches(top, remote)) {
|
|
100
|
+
const line = tryGit(top, ["rev-list", "--first-parent", b.ref]);
|
|
101
|
+
if (line?.split("\n").includes(sha)) return b;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The composed commits on the pushed compose branches that pick `trunkSha`. */
|
|
107
|
+
export function composedIncluding(top: string, trunkSha: string, remote = "origin"): Array<{ branch: string; commit: string }> {
|
|
108
|
+
const out: Array<{ branch: string; commit: string }> = [];
|
|
109
|
+
for (const b of remoteComposeBranches(top, remote)) {
|
|
110
|
+
const tip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${b.ref}^{commit}`]);
|
|
111
|
+
if (!tip) continue;
|
|
112
|
+
const composition = compositionOf(top, tip, b.branch);
|
|
113
|
+
if (!composition?.sources.includes(trunkSha)) continue;
|
|
114
|
+
// The composed commit that picked it: walk the composed commits for its trailer.
|
|
115
|
+
let at: string | null = tip;
|
|
116
|
+
while (at && at !== composition.base) {
|
|
117
|
+
if (composedFrom(top, at).includes(trunkSha)) {
|
|
118
|
+
out.push({ branch: b.branch, commit: at });
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
at = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -108,6 +108,26 @@ describe("diffDeepObservation", () => {
|
|
|
108
108
|
expect(result.unchanged).toEqual(["r"]);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
test("prepare rewrites each raw tree, both sides, before paths are taken (#4231)", () => {
|
|
112
|
+
const seen: string[] = [];
|
|
113
|
+
const rekey: DeepNormalizationHooks = {
|
|
114
|
+
prepare(tree, { side }) {
|
|
115
|
+
seen.push(side);
|
|
116
|
+
const ids = tree.ids as Record<string, unknown> | undefined;
|
|
117
|
+
if (!ids) return tree;
|
|
118
|
+
return { ...tree, ids: Object.fromEntries(Object.entries(ids).map(([k, v]) => [k.toLowerCase(), v])) };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const result = diffDeepObservation(
|
|
122
|
+
entities({ r: { entityType: "T", props: { ids: { "A/B": {} } } } }),
|
|
123
|
+
{ resources: { r: { type: "T", properties: { ids: { "a/b": {} } } } }, unobserved: {} },
|
|
124
|
+
rekey,
|
|
125
|
+
);
|
|
126
|
+
expect(seen.sort()).toEqual(["declared", "live"]);
|
|
127
|
+
expect(result.drifted).toEqual([]);
|
|
128
|
+
expect(result.unchanged).toEqual(["r"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
111
131
|
test("a declared property at its default is still compared", () => {
|
|
112
132
|
const result = diffDeepObservation(
|
|
113
133
|
entities({ r: { entityType: "AWS::IAM::Role", props: { Path: "/" } } }),
|
|
@@ -139,8 +139,10 @@ export function diffDeepObservation(
|
|
|
139
139
|
|
|
140
140
|
for (const [name, entity] of entities) {
|
|
141
141
|
const liveEntity = live.resources[name];
|
|
142
|
-
const
|
|
143
|
-
|
|
142
|
+
const prepare = (tree: Record<string, unknown>, side: "declared" | "live") =>
|
|
143
|
+
hooks?.prepare ? hooks.prepare(tree, { entityType: entity.entityType, side }) : tree;
|
|
144
|
+
const declaredRaw = prepare(entity.props ?? {}, "declared");
|
|
145
|
+
const liveRaw = prepare(liveEntity?.properties ?? {}, "live");
|
|
144
146
|
const declaredPaths = deepPathSet(declaredRaw);
|
|
145
147
|
const livePaths = deepPathSet(liveRaw);
|
|
146
148
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applied-green records (#4168 step 9): the green commit apply on merge last
|
|
3
|
+
* applied to an environment, kept on `chant/lifecycle` so the next apply
|
|
4
|
+
* measures its change from there.
|
|
5
|
+
*
|
|
6
|
+
* _green-applies/<env>.json one record per environment, rewritten by each apply that finishes
|
|
7
|
+
*
|
|
8
|
+
* Apply on merge applies green commits, not each merge. `pr-apply --green`
|
|
9
|
+
* plans at the green commit, measured from the one recorded here, and writes
|
|
10
|
+
* this record once everything applied. A workspace member's record sits under
|
|
11
|
+
* `_members/<member>/_green-applies/` through the same `ledgerDir` prefix the
|
|
12
|
+
* merge records use.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { sortedJsonReplacer } from "../utils";
|
|
16
|
+
import { ledgerDir, readBlobFromPath, readPathSha, RefCASConflictError, writeLedgerFiles, type LedgerLocation } from "./git";
|
|
17
|
+
|
|
18
|
+
/** The directory on `chant/lifecycle` that holds applied-green records. */
|
|
19
|
+
export const GREEN_APPLIES_DIR = "_green-applies";
|
|
20
|
+
|
|
21
|
+
export const GREEN_APPLY_CONTRACT = 1;
|
|
22
|
+
|
|
23
|
+
export interface GreenApplyRecord {
|
|
24
|
+
contract: number;
|
|
25
|
+
env: string;
|
|
26
|
+
member?: string;
|
|
27
|
+
/** The green commit applied. */
|
|
28
|
+
commit: string;
|
|
29
|
+
/** The commit the change was measured from: the green commit applied before, or the first apply's base. */
|
|
30
|
+
base: string;
|
|
31
|
+
/** The change set's digest. */
|
|
32
|
+
digest: string;
|
|
33
|
+
/** `merges` when the merged changes' approvals covered the plan, `gate` when the combined plan was approved at its own gate. */
|
|
34
|
+
approval: "merges" | "gate";
|
|
35
|
+
approvedBy: string[];
|
|
36
|
+
/** The first-parent commits from `base` (exclusive) to `commit`, oldest first. */
|
|
37
|
+
merges: string[];
|
|
38
|
+
appliedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function recordFile(env: string): string {
|
|
42
|
+
return `${env}.json`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The last green commit applied to `env`, or null when none is recorded. Reads the local `chant/lifecycle` and never fetches. */
|
|
46
|
+
export async function readGreenApply(env: string, opts?: LedgerLocation): Promise<GreenApplyRecord | null> {
|
|
47
|
+
const text = await readBlobFromPath(GREEN_APPLIES_DIR, recordFile(env), opts);
|
|
48
|
+
if (text === null) return null;
|
|
49
|
+
try {
|
|
50
|
+
const doc = JSON.parse(text) as GreenApplyRecord;
|
|
51
|
+
return doc?.contract === GREEN_APPLY_CONTRACT && typeof doc.commit === "string" ? doc : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Write `record` for its environment. Does not push: call `pushLifecycleReport` (./git.ts) after. */
|
|
58
|
+
export async function writeGreenApply(record: GreenApplyRecord, opts?: LedgerLocation): Promise<string> {
|
|
59
|
+
const file = recordFile(record.env);
|
|
60
|
+
const content = `${JSON.stringify(record, sortedJsonReplacer)}\n`;
|
|
61
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
62
|
+
const prior = await readPathSha(GREEN_APPLIES_DIR, file, opts);
|
|
63
|
+
const dir = await ledgerDir(GREEN_APPLIES_DIR, opts);
|
|
64
|
+
try {
|
|
65
|
+
return await writeLedgerFiles([{ path: `${dir}/${file}`, content, expectPriorSha: prior }], `Applied green ${record.commit} to ${record.env}`, opts);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (!(err instanceof RefCASConflictError)) throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`could not write the applied-green record for ${record.env}: chant/lifecycle kept moving`);
|
|
71
|
+
}
|
|
@@ -45,6 +45,27 @@ export const NpmPublishSchema = z
|
|
|
45
45
|
* `sbom.cyclonedx.json` it holds. `false` publishes without one.
|
|
46
46
|
*/
|
|
47
47
|
sbom: sbomField.optional(),
|
|
48
|
+
/**
|
|
49
|
+
* `false` publishes each package straight to the reveal tag (`latest`
|
|
50
|
+
* unless the publish names another), with no `next` stage and no
|
|
51
|
+
* `npm dist-tag` move (#4040). For credentials that may publish but not
|
|
52
|
+
* move a dist-tag, such as npm trusted publishing without "Allow npm
|
|
53
|
+
* dist-tag" on the package's record (#3904). Default `true`.
|
|
54
|
+
*/
|
|
55
|
+
stage: z.boolean().optional(),
|
|
56
|
+
/**
|
|
57
|
+
* A variable holding an npm token to retry with when a package's publish
|
|
58
|
+
* fails to authenticate (#4040), as chant's own publish-packages.sh does:
|
|
59
|
+
* a package whose trusted-publisher record is broken publishes with the
|
|
60
|
+
* token rather than failing the release. Unset or empty, there is no retry.
|
|
61
|
+
*/
|
|
62
|
+
tokenEnv: envName.optional(),
|
|
63
|
+
/**
|
|
64
|
+
* Seconds the reveal waits for the registry to serve every version it
|
|
65
|
+
* published before installing them to audit (#4244). A registry accepts a
|
|
66
|
+
* publish before it serves it. Default 600.
|
|
67
|
+
*/
|
|
68
|
+
registryWait: z.number().int().min(0).optional(),
|
|
48
69
|
})
|
|
49
70
|
.strict();
|
|
50
71
|
|
|
@@ -42,10 +42,13 @@
|
|
|
42
42
|
* npm makes the first version of a new package `latest` whatever tag it is
|
|
43
43
|
* published under, so a package's first release is visible when it stages.
|
|
44
44
|
*
|
|
45
|
-
* Credentials are npm's own
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
45
|
+
* Credentials are npm's own: npm authenticates with the registry's
|
|
46
|
+
* `_authToken` in the user's or the CI's npm config (`actions/setup-node`
|
|
47
|
+
* writes one that reads `NODE_AUTH_TOKEN`), or by trusted publishing. The one
|
|
48
|
+
* exception is `tokenEnv` (#4040): when a publish fails to authenticate, the
|
|
49
|
+
* package is published again with that variable's token in a throwaway npm
|
|
50
|
+
* user config, never on the command line, and without `--provenance` if the
|
|
51
|
+
* registry refuses the attestation on the token path. npm runs with the run's
|
|
49
52
|
* environment, and everything it prints is redacted before chant prints it.
|
|
50
53
|
*/
|
|
51
54
|
|
|
@@ -58,6 +61,9 @@ import type { NpmPublishDeclaration } from "./publish-declaration";
|
|
|
58
61
|
import { checkReleaseDigest, fileDigest, isDir, memberFile, publishTargetKey } from "./publish-targets";
|
|
59
62
|
import { NPM_STAGE_TAG, PublishTargetError, type PublishContext, type PublishDeps, type PublishItem, type PublishTarget } from "./release-publish";
|
|
60
63
|
|
|
64
|
+
/** npm's output when a publish could not authenticate, as publish-packages.sh matches it. */
|
|
65
|
+
const NPM_AUTH_FAILED = /ENEEDAUTH|E401|need auth/;
|
|
66
|
+
|
|
61
67
|
interface NpmPackage {
|
|
62
68
|
path: string;
|
|
63
69
|
abs: string;
|
|
@@ -133,9 +139,9 @@ export function npmAuditSignaturesArgs(decl: Pick<NpmPublishDeclaration, "regist
|
|
|
133
139
|
return ["audit", "signatures", ...(decl.registry ? ["--registry", decl.registry] : [])];
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx.cwd): Promise<{ code: number; output: string[] }> {
|
|
142
|
+
function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx.cwd, env: NodeJS.ProcessEnv = ctx.env): Promise<{ code: number; output: string[] }> {
|
|
137
143
|
return new Promise((resolvePromise, reject) => {
|
|
138
|
-
const child = spawn(bin, args, { cwd, env
|
|
144
|
+
const child = spawn(bin, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
139
145
|
const output: string[] = [];
|
|
140
146
|
let partial = { out: "", err: "" };
|
|
141
147
|
const take = (key: "out" | "err") => (chunk: Buffer) => {
|
|
@@ -154,14 +160,26 @@ function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx
|
|
|
154
160
|
});
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/** Seconds the reveal waits for the registry to serve what it published, by default (#4244). */
|
|
164
|
+
export const NPM_REGISTRY_WAIT_SECONDS = 600;
|
|
165
|
+
/** Between polls of the registry while it waits. */
|
|
166
|
+
export const NPM_REGISTRY_POLL_MS = 10_000;
|
|
167
|
+
/** npm's errors for a version the registry does not serve yet. */
|
|
168
|
+
const NPM_NOT_SERVED = /\b(ETARGET|ERESOLVE|notarget)\b|No matching version found/;
|
|
169
|
+
|
|
157
170
|
export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): PublishTarget {
|
|
158
171
|
const bin = deps.npm ?? "npm";
|
|
172
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
173
|
+
const now = deps.now ?? Date.now;
|
|
159
174
|
const registry = decl.registry ?? "the registry npm's config names";
|
|
160
175
|
const shown = (tag: string) => `${registry}, dist-tag ${tag}`;
|
|
161
176
|
const revealTag = (ctx: PublishContext) => ctx.tag ?? "latest";
|
|
162
|
-
|
|
177
|
+
// Under `stage: false` a package goes straight to the reveal tag, and the reveal moves no tag (#4040).
|
|
178
|
+
const staging = decl.stage !== false;
|
|
179
|
+
const stageTag = (ctx: PublishContext) => (staging ? NPM_STAGE_TAG : revealTag(ctx));
|
|
180
|
+
const npm = async (ctx: PublishContext, args: string[], cwd?: string, env?: NodeJS.ProcessEnv): Promise<{ code: number; output: string[] }> => {
|
|
163
181
|
try {
|
|
164
|
-
return await run(bin, args, ctx, cwd);
|
|
182
|
+
return await run(bin, args, ctx, cwd, env);
|
|
165
183
|
} catch (err) {
|
|
166
184
|
throw new PublishTargetError("publish-npm-failed", ctx.redactor.redact(`npm could not be run: ${err instanceof Error ? err.message : String(err)}`), "Install npm, or put it on PATH.");
|
|
167
185
|
}
|
|
@@ -171,24 +189,86 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
171
189
|
const r = await npm(ctx, ["view", `${pkg.name}@${pkg.version}`, "version", ...(decl.registry ? ["--registry", decl.registry] : [])]);
|
|
172
190
|
return r.code === 0 && r.output.some((l) => l.trim() === pkg.version || l.trim() === JSON.stringify(pkg.version));
|
|
173
191
|
};
|
|
192
|
+
/**
|
|
193
|
+
* The `tokenEnv` retry (#4040): a publish that failed to authenticate runs
|
|
194
|
+
* again with the token in a throwaway user config, and once more without
|
|
195
|
+
* `--provenance` when the token path refuses the attestation.
|
|
196
|
+
*/
|
|
197
|
+
const withToken = async (ctx: PublishContext, pkg: NpmPackage, args: string[], failed: { code: number; output: string[] }) => {
|
|
198
|
+
const token = decl.tokenEnv ? ctx.env[decl.tokenEnv] : undefined;
|
|
199
|
+
if (!token || !failed.output.some((l) => NPM_AUTH_FAILED.test(l))) return failed;
|
|
200
|
+
ctx.log(`${pkg.name}@${pkg.version} could not authenticate; publishing again with ${decl.tokenEnv}`);
|
|
201
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-npm-token-"));
|
|
202
|
+
try {
|
|
203
|
+
const userconfig = join(dir, ".npmrc");
|
|
204
|
+
const host = (decl.registry ?? "https://registry.npmjs.org/").replace(/^https?:/, "").replace(/\/?$/, "/");
|
|
205
|
+
writeFileSync(userconfig, `${host}:_authToken=${token}\n`, { mode: 0o600 });
|
|
206
|
+
const env = { ...ctx.env, NPM_CONFIG_USERCONFIG: userconfig };
|
|
207
|
+
let result = await npm(ctx, args, undefined, env);
|
|
208
|
+
for (const line of result.output) ctx.log(` ${line}`);
|
|
209
|
+
if (result.code !== 0 && args.includes("--provenance") && result.output.some((l) => /^npm (error|ERR!).*(provenance|attestation)/i.test(l))) {
|
|
210
|
+
ctx.log(`${pkg.name}@${pkg.version}: the registry refused provenance on the token path; publishing without an attestation`);
|
|
211
|
+
result = await npm(ctx, args.filter((a) => a !== "--provenance"), undefined, env);
|
|
212
|
+
for (const line of result.output) ctx.log(` ${line}`);
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
} finally {
|
|
216
|
+
rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
};
|
|
174
219
|
const read = (ctx: PublishContext) => decl.packages.map((p) => readPackage(ctx, p, decl.sbom));
|
|
175
220
|
const extra = (pkg: NpmPackage) => ({ ...(pkg.digest ? { digest: pkg.digest } : {}), ...(pkg.sbom ? { sbom: pkg.sbom } : {}) });
|
|
176
221
|
const plan = async (ctx: PublishContext): Promise<PublishItem[]> =>
|
|
177
|
-
read(ctx).map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: `${shown(NPM_STAGE_TAG)}, then ${revealTag(ctx)}
|
|
222
|
+
read(ctx).map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: staging ? `${shown(NPM_STAGE_TAG)}, then ${revealTag(ctx)}` : shown(revealTag(ctx)), ...extra(pkg) }));
|
|
178
223
|
/**
|
|
179
224
|
* In CI, install what was published into an empty directory and run
|
|
180
225
|
* `npm audit signatures` there (#4049), which checks each package's
|
|
181
226
|
* registry signature and provenance attestation.
|
|
182
227
|
*/
|
|
228
|
+
/**
|
|
229
|
+
* Wait until the registry serves every version (#4244): npm accepts a
|
|
230
|
+
* publish minutes before `npm view` and `npm install` find it, so an
|
|
231
|
+
* install straight after fails with ETARGET or ERESOLVE.
|
|
232
|
+
*/
|
|
233
|
+
const served = async (ctx: PublishContext, pkgs: NpmPackage[]): Promise<void> => {
|
|
234
|
+
const wait = (decl.registryWait ?? NPM_REGISTRY_WAIT_SECONDS) * 1000;
|
|
235
|
+
const start = now();
|
|
236
|
+
let missing = pkgs;
|
|
237
|
+
for (;;) {
|
|
238
|
+
const still: NpmPackage[] = [];
|
|
239
|
+
for (const pkg of missing) if (!(await onRegistry(ctx, pkg))) still.push(pkg);
|
|
240
|
+
missing = still;
|
|
241
|
+
if (missing.length === 0) return;
|
|
242
|
+
const names = missing.map((p) => `${p.name}@${p.version}`).join(", ");
|
|
243
|
+
if (now() - start >= wait) {
|
|
244
|
+
throw new PublishTargetError(
|
|
245
|
+
"publish-npm-not-served",
|
|
246
|
+
`the registry did not serve ${names} within ${Math.round(wait / 1000)}s of the publish, so nothing was audited`,
|
|
247
|
+
"Each package was published. Once npm serves them, run the publish again: it publishes nothing twice and audits again. `registryWait` sets the wait.",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
ctx.log(`waiting for the registry to serve ${names}`);
|
|
251
|
+
await sleep(NPM_REGISTRY_POLL_MS);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
183
254
|
const audit = async (ctx: PublishContext, pkgs: NpmPackage[]): Promise<void> => {
|
|
255
|
+
await served(ctx, pkgs);
|
|
184
256
|
const dir = mkdtempSync(join(tmpdir(), "chant-npm-audit-"));
|
|
185
257
|
try {
|
|
186
258
|
const dependencies = Object.fromEntries(pkgs.map((p) => [p.name, p.version]));
|
|
187
259
|
writeFileSync(join(dir, "package.json"), `${JSON.stringify({ name: "chant-publish-audit", version: "0.0.0", private: true, dependencies }, null, 2)}\n`);
|
|
188
260
|
for (const args of [npmAuditInstallArgs(decl), npmAuditSignaturesArgs(decl)]) {
|
|
189
261
|
ctx.log(`npm ${args.join(" ")}`);
|
|
190
|
-
|
|
262
|
+
let result = await npm(ctx, args, dir);
|
|
191
263
|
for (const line of result.output) ctx.log(` ${line}`);
|
|
264
|
+
// `npm view` can answer before every mirror `npm install` reads does: wait once more, then install again.
|
|
265
|
+
if (result.code !== 0 && args[0] === "install" && result.output.some((l) => NPM_NOT_SERVED.test(l))) {
|
|
266
|
+
ctx.log("the registry does not serve every version to an install yet; waiting, then installing again");
|
|
267
|
+
await sleep(NPM_REGISTRY_POLL_MS);
|
|
268
|
+
await served(ctx, pkgs);
|
|
269
|
+
result = await npm(ctx, args, dir);
|
|
270
|
+
for (const line of result.output) ctx.log(` ${line}`);
|
|
271
|
+
}
|
|
192
272
|
if (result.code !== 0) {
|
|
193
273
|
throw new PublishTargetError(
|
|
194
274
|
"publish-npm-audit-failed",
|
|
@@ -212,21 +292,22 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
212
292
|
const pkgs = read(ctx);
|
|
213
293
|
const items: PublishItem[] = [];
|
|
214
294
|
for (const pkg of pkgs) {
|
|
215
|
-
const item: PublishItem = { name: `${pkg.name}@${pkg.version}`, destination: shown(
|
|
295
|
+
const item: PublishItem = { name: `${pkg.name}@${pkg.version}`, destination: shown(stageTag(ctx)), ...extra(pkg) };
|
|
216
296
|
if (await onRegistry(ctx, pkg)) {
|
|
217
297
|
ctx.log(`${item.name} is already on the registry; not published again`);
|
|
218
298
|
items.push({ ...item, present: true });
|
|
219
299
|
continue;
|
|
220
300
|
}
|
|
221
|
-
const args = npmPublishArgs(decl, pkg.abs, ctx);
|
|
301
|
+
const args = npmPublishArgs(decl, pkg.abs, ctx, stageTag(ctx));
|
|
222
302
|
ctx.log(`npm publish ${pkg.path} ${args.slice(2).join(" ")}`);
|
|
223
|
-
|
|
303
|
+
let result = await npm(ctx, args);
|
|
224
304
|
for (const line of result.output) ctx.log(` ${line}`);
|
|
305
|
+
if (result.code !== 0) result = await withToken(ctx, pkg, args, result);
|
|
225
306
|
if (result.code !== 0) {
|
|
226
307
|
const done = items.map((i) => i.name);
|
|
227
308
|
throw new PublishTargetError(
|
|
228
309
|
"publish-npm-failed",
|
|
229
|
-
`npm publish of ${item.name} exited ${result.code}${done.length > 0 ? `, after ${done.join(", ")} were staged under ${
|
|
310
|
+
`npm publish of ${item.name} exited ${result.code}${done.length > 0 ? `, after ${done.join(", ")} were ${staging ? "staged" : "published"} under ${stageTag(ctx)}` : `, before any package was ${staging ? "staged" : "published"}`}: ${result.output.at(-1) ?? "npm printed nothing"}`,
|
|
230
311
|
);
|
|
231
312
|
}
|
|
232
313
|
items.push(item);
|
|
@@ -237,6 +318,11 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
237
318
|
const tag = revealTag(ctx);
|
|
238
319
|
const pkgs = read(ctx);
|
|
239
320
|
const items: PublishItem[] = [];
|
|
321
|
+
if (!staging) {
|
|
322
|
+
// Already under the reveal tag since the stage; only the audit is left.
|
|
323
|
+
if (ctx.provenance.oidc) await audit(ctx, pkgs);
|
|
324
|
+
return { target: this.name, destination: this.destination, items: pkgs.map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: shown(tag), ...extra(pkg) })) };
|
|
325
|
+
}
|
|
240
326
|
// The declared order: the first package is the set's core, and its tag moves first.
|
|
241
327
|
for (const pkg of pkgs) {
|
|
242
328
|
const args = npmDistTagArgs(decl, pkg.name, pkg.version, tag);
|
|
@@ -136,6 +136,10 @@ describe("npm", () => {
|
|
|
136
136
|
["dist-tag", "add", "@acme/core@1.4.0", "latest"],
|
|
137
137
|
["dist-tag", "add", "@acme/cli@1.4.0", "latest"],
|
|
138
138
|
["dist-tag", "add", "@acme/kit@1.4.0", "latest"],
|
|
139
|
+
// #4244: it first asks the registry for each version it published.
|
|
140
|
+
["view", "@acme/core@1.4.0", "version"],
|
|
141
|
+
["view", "@acme/cli@1.4.0", "version"],
|
|
142
|
+
["view", "@acme/kit@1.4.0", "version"],
|
|
139
143
|
// #4049: with an OIDC token, what was published is installed in an empty directory and its signatures audited.
|
|
140
144
|
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
|
|
141
145
|
["audit", "signatures"],
|
|
@@ -166,6 +170,51 @@ describe("npm", () => {
|
|
|
166
170
|
expect(readFakeNpmRegistry(log).packages["@acme/kit"]!.distTags).toEqual({ latest: "1.3.0", next: "1.4.0", beta: "1.4.0" });
|
|
167
171
|
});
|
|
168
172
|
|
|
173
|
+
test("stage: false publishes each package straight to latest in the order declared and moves no dist-tag (#4040)", async () => {
|
|
174
|
+
const { dir, npm, log } = setup();
|
|
175
|
+
const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "kit.tgz"], access: "public", stage: false })], { npm });
|
|
176
|
+
const ctx = context(dir, { env: { PATH: process.env.PATH }, provenance: OIDC });
|
|
177
|
+
expect((await target!.plan(ctx))[0]!.destination).toBe("the registry npm's config names, dist-tag latest");
|
|
178
|
+
const staged = await target!.stage(ctx);
|
|
179
|
+
expect(latest(log)).toEqual({ "@acme/core": "1.4.0", "@acme/cli": "1.3.0", "@acme/kit": "1.4.0" });
|
|
180
|
+
const before = calls(log).length;
|
|
181
|
+
const done = await target!.reveal(ctx, staged);
|
|
182
|
+
expect(calls(log).filter((c) => c.args[0] === "publish").map((c) => c.args)).toEqual([
|
|
183
|
+
["publish", join(dir, "packages/core"), "--tag", "latest", "--access", "public", "--provenance"],
|
|
184
|
+
["publish", join(dir, "kit.tgz"), "--tag", "latest", "--access", "public", "--provenance"],
|
|
185
|
+
]);
|
|
186
|
+
// The reveal only audits, once the registry serves both (#4244).
|
|
187
|
+
expect(calls(log).slice(before).map((c) => c.args[0])).toEqual(["view", "view", "install", "audit"]);
|
|
188
|
+
expect(done.items.map((i) => i.destination)).toEqual(Array(2).fill("the registry npm's config names, dist-tag latest"));
|
|
189
|
+
expect(readFakeNpmRegistry(log).packages["@acme/core"]!.distTags.next).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("tokenEnv: a package that can't authenticate publishes again with the token from a throwaway user config, then without provenance if the token path refuses it (#4040)", async () => {
|
|
193
|
+
const { dir, npm, log } = setup();
|
|
194
|
+
const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "kit.tgz"], stage: false, tokenEnv: "NPM_TOKEN" })], { npm });
|
|
195
|
+
const env = { PATH: process.env.PATH, NPM_TOKEN: "npm_fallback99", FAKE_NPM_NEEDAUTH: "kit.tgz", FAKE_NPM_TOKEN_NO_PROVENANCE: "1" };
|
|
196
|
+
const ctx = context(dir, { env, provenance: OIDC });
|
|
197
|
+
await target!.stage(ctx);
|
|
198
|
+
const publishes = calls(log).filter((c) => c.args[0] === "publish") as Array<{ args: string[]; userconfig?: string }>;
|
|
199
|
+
expect(publishes.map((c) => [c.args[1], c.args.includes("--provenance"), Boolean(c.userconfig)])).toEqual([
|
|
200
|
+
[join(dir, "packages/core"), true, false],
|
|
201
|
+
[join(dir, "kit.tgz"), true, false],
|
|
202
|
+
[join(dir, "kit.tgz"), true, true],
|
|
203
|
+
[join(dir, "kit.tgz"), false, true],
|
|
204
|
+
]);
|
|
205
|
+
expect(publishes[2]!.userconfig).toBe("//registry.npmjs.org/:_authToken=npm_fallback99\n");
|
|
206
|
+
expect(latest(log)["@acme/kit"]).toBe("1.4.0");
|
|
207
|
+
expect(ctx.lines.join("\n")).not.toContain("npm_fallback99");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("tokenEnv: with the variable unset, or a failure that is not authentication, there is no retry", async () => {
|
|
211
|
+
const { dir, npm, log } = setup();
|
|
212
|
+
const [target] = await createPublishTargets([decl({ type: "npm", packages: ["kit.tgz"], stage: false, tokenEnv: "NPM_TOKEN" })], { npm });
|
|
213
|
+
await expect(target!.stage(context(dir, { env: { PATH: process.env.PATH, FAKE_NPM_NEEDAUTH: "kit.tgz" } }))).rejects.toThrow(/exited 1/);
|
|
214
|
+
await expect(target!.stage(context(dir, { env: { PATH: process.env.PATH, NPM_TOKEN: "t", FAKE_NPM_FAIL: "kit.tgz" } }))).rejects.toThrow(/403/);
|
|
215
|
+
expect(calls(log).filter((c) => c.args[0] === "publish")).toHaveLength(2);
|
|
216
|
+
});
|
|
217
|
+
|
|
169
218
|
test("a stage that failed part way names what it staged and, run again, publishes only the rest; a tag move that fails names what moved", async () => {
|
|
170
219
|
const { dir, npm, log } = setup();
|
|
171
220
|
const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "packages/cli", "packages/kit"] })], { npm });
|
|
@@ -436,6 +485,65 @@ describe("SBOMs and provenance (#4049)", () => {
|
|
|
436
485
|
expect(readFileSync(log, "utf-8")).toContain(`"args":["audit","signatures"]`);
|
|
437
486
|
});
|
|
438
487
|
|
|
488
|
+
describe("npm: the reveal waits for the registry to serve what it published (#4244)", () => {
|
|
489
|
+
function lagged(env: Record<string, string>, registryWait?: number) {
|
|
490
|
+
const dir = temp();
|
|
491
|
+
writePackage(join(dir, "core"), "@acme/core", "1.0.0");
|
|
492
|
+
writePackage(join(dir, "cli"), "@acme/cli", "1.0.0");
|
|
493
|
+
const log = join(dir, "npm.log");
|
|
494
|
+
const npm = writeFakeNpm(join(dir, "bin"), log);
|
|
495
|
+
let clock = 0;
|
|
496
|
+
const sleeps: number[] = [];
|
|
497
|
+
const deps = { npm, now: () => clock, sleep: async (ms: number) => { sleeps.push(ms); clock += ms; } };
|
|
498
|
+
const ctx = context(dir, { env: { PATH: process.env.PATH, ...env }, provenance: OIDC });
|
|
499
|
+
const d = decl({ type: "npm", packages: ["core", "cli"], stage: false, ...(registryWait !== undefined ? { registryWait } : {}) });
|
|
500
|
+
return { log, deps, ctx, d, sleeps };
|
|
501
|
+
}
|
|
502
|
+
const installs = (log: string) => readFileSync(log, "utf-8").split("\n").filter((l) => l.includes(`"args":["install"`)).length;
|
|
503
|
+
|
|
504
|
+
test("every version served at once: no wait, one install", async () => {
|
|
505
|
+
const { log, deps, ctx, d, sleeps } = lagged({});
|
|
506
|
+
const [target] = await createPublishTargets([d], deps);
|
|
507
|
+
await target!.reveal(ctx, await target!.stage(ctx));
|
|
508
|
+
expect(sleeps).toEqual([]);
|
|
509
|
+
expect(installs(log)).toBe(1);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("one version served late: it waits, names it, then installs once", async () => {
|
|
513
|
+
const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_LAG: "@acme/cli=2" });
|
|
514
|
+
const [target] = await createPublishTargets([d], deps);
|
|
515
|
+
await target!.reveal(ctx, await target!.stage(ctx));
|
|
516
|
+
expect(sleeps).toEqual([10_000, 10_000]);
|
|
517
|
+
expect(ctx.lines).toContain("waiting for the registry to serve @acme/cli@1.0.0");
|
|
518
|
+
expect(installs(log)).toBe(1);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("a version never served: the wait ends with an error naming it, before any install", async () => {
|
|
522
|
+
const { log, deps, ctx, d } = lagged({ FAKE_NPM_LAG: "@acme/core=1000" }, 30);
|
|
523
|
+
const [target] = await createPublishTargets([d], deps);
|
|
524
|
+
const err = await refused(target!.reveal(ctx, await target!.stage(ctx)));
|
|
525
|
+
expect(err.code).toBe("publish-npm-not-served");
|
|
526
|
+
expect(err.message).toBe("the registry did not serve @acme/core@1.0.0 within 30s of the publish, so nothing was audited");
|
|
527
|
+
expect(installs(log)).toBe(0);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("an install that fails with ETARGET waits and installs once more", async () => {
|
|
531
|
+
const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_INSTALL_ETARGET: "1" });
|
|
532
|
+
const [target] = await createPublishTargets([d], deps);
|
|
533
|
+
await target!.reveal(ctx, await target!.stage(ctx));
|
|
534
|
+
expect(installs(log)).toBe(2);
|
|
535
|
+
expect(sleeps).toEqual([10_000]);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("a second ETARGET is the audit's failure, not a third install", async () => {
|
|
539
|
+
const { log, deps, ctx, d } = lagged({ FAKE_NPM_INSTALL_ETARGET: "2" });
|
|
540
|
+
const [target] = await createPublishTargets([d], deps);
|
|
541
|
+
const err = await refused(target!.reveal(ctx, await target!.stage(ctx)));
|
|
542
|
+
expect(err.code).toBe("publish-npm-audit-failed");
|
|
543
|
+
expect(installs(log)).toBe(2);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
439
547
|
test("OCI: the provenance statement carries the cut's signature, says a workstation's provenance is the signature, and is the same bytes every run; a declared SBOM is pushed as it is", async () => {
|
|
440
548
|
const reg = await startFakeRegistry({ username: "robot", password: "pw-attach", token: "tok-attach" });
|
|
441
549
|
try {
|