@intentius/chant 0.124.34 → 0.124.35
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 +2 -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.map +1 -1
- package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
- 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 +4 -0
- package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
- package/dist/lifecycle/publish-declaration.js +15 -0
- package/dist/lifecycle/publish-npm.d.ts +7 -4
- package/dist/lifecycle/publish-npm.d.ts.map +1 -1
- package/dist/lifecycle/publish-npm.js +60 -13
- 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 +2 -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.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 +5 -1
- 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 +15 -0
- package/src/lifecycle/publish-npm.ts +55 -13
- package/src/lifecycle/publish-targets.test.ts +45 -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 +4 -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
|
@@ -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,21 @@ 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(),
|
|
48
63
|
})
|
|
49
64
|
.strict();
|
|
50
65
|
|
|
@@ -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) => {
|
|
@@ -159,9 +165,12 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
159
165
|
const registry = decl.registry ?? "the registry npm's config names";
|
|
160
166
|
const shown = (tag: string) => `${registry}, dist-tag ${tag}`;
|
|
161
167
|
const revealTag = (ctx: PublishContext) => ctx.tag ?? "latest";
|
|
162
|
-
|
|
168
|
+
// Under `stage: false` a package goes straight to the reveal tag, and the reveal moves no tag (#4040).
|
|
169
|
+
const staging = decl.stage !== false;
|
|
170
|
+
const stageTag = (ctx: PublishContext) => (staging ? NPM_STAGE_TAG : revealTag(ctx));
|
|
171
|
+
const npm = async (ctx: PublishContext, args: string[], cwd?: string, env?: NodeJS.ProcessEnv): Promise<{ code: number; output: string[] }> => {
|
|
163
172
|
try {
|
|
164
|
-
return await run(bin, args, ctx, cwd);
|
|
173
|
+
return await run(bin, args, ctx, cwd, env);
|
|
165
174
|
} catch (err) {
|
|
166
175
|
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
176
|
}
|
|
@@ -171,10 +180,37 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
171
180
|
const r = await npm(ctx, ["view", `${pkg.name}@${pkg.version}`, "version", ...(decl.registry ? ["--registry", decl.registry] : [])]);
|
|
172
181
|
return r.code === 0 && r.output.some((l) => l.trim() === pkg.version || l.trim() === JSON.stringify(pkg.version));
|
|
173
182
|
};
|
|
183
|
+
/**
|
|
184
|
+
* The `tokenEnv` retry (#4040): a publish that failed to authenticate runs
|
|
185
|
+
* again with the token in a throwaway user config, and once more without
|
|
186
|
+
* `--provenance` when the token path refuses the attestation.
|
|
187
|
+
*/
|
|
188
|
+
const withToken = async (ctx: PublishContext, pkg: NpmPackage, args: string[], failed: { code: number; output: string[] }) => {
|
|
189
|
+
const token = decl.tokenEnv ? ctx.env[decl.tokenEnv] : undefined;
|
|
190
|
+
if (!token || !failed.output.some((l) => NPM_AUTH_FAILED.test(l))) return failed;
|
|
191
|
+
ctx.log(`${pkg.name}@${pkg.version} could not authenticate; publishing again with ${decl.tokenEnv}`);
|
|
192
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-npm-token-"));
|
|
193
|
+
try {
|
|
194
|
+
const userconfig = join(dir, ".npmrc");
|
|
195
|
+
const host = (decl.registry ?? "https://registry.npmjs.org/").replace(/^https?:/, "").replace(/\/?$/, "/");
|
|
196
|
+
writeFileSync(userconfig, `${host}:_authToken=${token}\n`, { mode: 0o600 });
|
|
197
|
+
const env = { ...ctx.env, NPM_CONFIG_USERCONFIG: userconfig };
|
|
198
|
+
let result = await npm(ctx, args, undefined, env);
|
|
199
|
+
for (const line of result.output) ctx.log(` ${line}`);
|
|
200
|
+
if (result.code !== 0 && args.includes("--provenance") && result.output.some((l) => /^npm (error|ERR!).*(provenance|attestation)/i.test(l))) {
|
|
201
|
+
ctx.log(`${pkg.name}@${pkg.version}: the registry refused provenance on the token path; publishing without an attestation`);
|
|
202
|
+
result = await npm(ctx, args.filter((a) => a !== "--provenance"), undefined, env);
|
|
203
|
+
for (const line of result.output) ctx.log(` ${line}`);
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
} finally {
|
|
207
|
+
rmSync(dir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
};
|
|
174
210
|
const read = (ctx: PublishContext) => decl.packages.map((p) => readPackage(ctx, p, decl.sbom));
|
|
175
211
|
const extra = (pkg: NpmPackage) => ({ ...(pkg.digest ? { digest: pkg.digest } : {}), ...(pkg.sbom ? { sbom: pkg.sbom } : {}) });
|
|
176
212
|
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)}
|
|
213
|
+
read(ctx).map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: staging ? `${shown(NPM_STAGE_TAG)}, then ${revealTag(ctx)}` : shown(revealTag(ctx)), ...extra(pkg) }));
|
|
178
214
|
/**
|
|
179
215
|
* In CI, install what was published into an empty directory and run
|
|
180
216
|
* `npm audit signatures` there (#4049), which checks each package's
|
|
@@ -212,21 +248,22 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
212
248
|
const pkgs = read(ctx);
|
|
213
249
|
const items: PublishItem[] = [];
|
|
214
250
|
for (const pkg of pkgs) {
|
|
215
|
-
const item: PublishItem = { name: `${pkg.name}@${pkg.version}`, destination: shown(
|
|
251
|
+
const item: PublishItem = { name: `${pkg.name}@${pkg.version}`, destination: shown(stageTag(ctx)), ...extra(pkg) };
|
|
216
252
|
if (await onRegistry(ctx, pkg)) {
|
|
217
253
|
ctx.log(`${item.name} is already on the registry; not published again`);
|
|
218
254
|
items.push({ ...item, present: true });
|
|
219
255
|
continue;
|
|
220
256
|
}
|
|
221
|
-
const args = npmPublishArgs(decl, pkg.abs, ctx);
|
|
257
|
+
const args = npmPublishArgs(decl, pkg.abs, ctx, stageTag(ctx));
|
|
222
258
|
ctx.log(`npm publish ${pkg.path} ${args.slice(2).join(" ")}`);
|
|
223
|
-
|
|
259
|
+
let result = await npm(ctx, args);
|
|
224
260
|
for (const line of result.output) ctx.log(` ${line}`);
|
|
261
|
+
if (result.code !== 0) result = await withToken(ctx, pkg, args, result);
|
|
225
262
|
if (result.code !== 0) {
|
|
226
263
|
const done = items.map((i) => i.name);
|
|
227
264
|
throw new PublishTargetError(
|
|
228
265
|
"publish-npm-failed",
|
|
229
|
-
`npm publish of ${item.name} exited ${result.code}${done.length > 0 ? `, after ${done.join(", ")} were staged under ${
|
|
266
|
+
`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
267
|
);
|
|
231
268
|
}
|
|
232
269
|
items.push(item);
|
|
@@ -237,6 +274,11 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
|
|
|
237
274
|
const tag = revealTag(ctx);
|
|
238
275
|
const pkgs = read(ctx);
|
|
239
276
|
const items: PublishItem[] = [];
|
|
277
|
+
if (!staging) {
|
|
278
|
+
// Already under the reveal tag since the stage; only the audit is left.
|
|
279
|
+
if (ctx.provenance.oidc) await audit(ctx, pkgs);
|
|
280
|
+
return { target: this.name, destination: this.destination, items: pkgs.map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: shown(tag), ...extra(pkg) })) };
|
|
281
|
+
}
|
|
240
282
|
// The declared order: the first package is the set's core, and its tag moves first.
|
|
241
283
|
for (const pkg of pkgs) {
|
|
242
284
|
const args = npmDistTagArgs(decl, pkg.name, pkg.version, tag);
|
|
@@ -166,6 +166,51 @@ describe("npm", () => {
|
|
|
166
166
|
expect(readFakeNpmRegistry(log).packages["@acme/kit"]!.distTags).toEqual({ latest: "1.3.0", next: "1.4.0", beta: "1.4.0" });
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
+
test("stage: false publishes each package straight to latest in the order declared and moves no dist-tag (#4040)", async () => {
|
|
170
|
+
const { dir, npm, log } = setup();
|
|
171
|
+
const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "kit.tgz"], access: "public", stage: false })], { npm });
|
|
172
|
+
const ctx = context(dir, { env: { PATH: process.env.PATH }, provenance: OIDC });
|
|
173
|
+
expect((await target!.plan(ctx))[0]!.destination).toBe("the registry npm's config names, dist-tag latest");
|
|
174
|
+
const staged = await target!.stage(ctx);
|
|
175
|
+
expect(latest(log)).toEqual({ "@acme/core": "1.4.0", "@acme/cli": "1.3.0", "@acme/kit": "1.4.0" });
|
|
176
|
+
const before = calls(log).length;
|
|
177
|
+
const done = await target!.reveal(ctx, staged);
|
|
178
|
+
expect(calls(log).filter((c) => c.args[0] === "publish").map((c) => c.args)).toEqual([
|
|
179
|
+
["publish", join(dir, "packages/core"), "--tag", "latest", "--access", "public", "--provenance"],
|
|
180
|
+
["publish", join(dir, "kit.tgz"), "--tag", "latest", "--access", "public", "--provenance"],
|
|
181
|
+
]);
|
|
182
|
+
// The reveal only audits.
|
|
183
|
+
expect(calls(log).slice(before).map((c) => c.args[0])).toEqual(["install", "audit"]);
|
|
184
|
+
expect(done.items.map((i) => i.destination)).toEqual(Array(2).fill("the registry npm's config names, dist-tag latest"));
|
|
185
|
+
expect(readFakeNpmRegistry(log).packages["@acme/core"]!.distTags.next).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
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 () => {
|
|
189
|
+
const { dir, npm, log } = setup();
|
|
190
|
+
const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "kit.tgz"], stage: false, tokenEnv: "NPM_TOKEN" })], { npm });
|
|
191
|
+
const env = { PATH: process.env.PATH, NPM_TOKEN: "npm_fallback99", FAKE_NPM_NEEDAUTH: "kit.tgz", FAKE_NPM_TOKEN_NO_PROVENANCE: "1" };
|
|
192
|
+
const ctx = context(dir, { env, provenance: OIDC });
|
|
193
|
+
await target!.stage(ctx);
|
|
194
|
+
const publishes = calls(log).filter((c) => c.args[0] === "publish") as Array<{ args: string[]; userconfig?: string }>;
|
|
195
|
+
expect(publishes.map((c) => [c.args[1], c.args.includes("--provenance"), Boolean(c.userconfig)])).toEqual([
|
|
196
|
+
[join(dir, "packages/core"), true, false],
|
|
197
|
+
[join(dir, "kit.tgz"), true, false],
|
|
198
|
+
[join(dir, "kit.tgz"), true, true],
|
|
199
|
+
[join(dir, "kit.tgz"), false, true],
|
|
200
|
+
]);
|
|
201
|
+
expect(publishes[2]!.userconfig).toBe("//registry.npmjs.org/:_authToken=npm_fallback99\n");
|
|
202
|
+
expect(latest(log)["@acme/kit"]).toBe("1.4.0");
|
|
203
|
+
expect(ctx.lines.join("\n")).not.toContain("npm_fallback99");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("tokenEnv: with the variable unset, or a failure that is not authentication, there is no retry", async () => {
|
|
207
|
+
const { dir, npm, log } = setup();
|
|
208
|
+
const [target] = await createPublishTargets([decl({ type: "npm", packages: ["kit.tgz"], stage: false, tokenEnv: "NPM_TOKEN" })], { npm });
|
|
209
|
+
await expect(target!.stage(context(dir, { env: { PATH: process.env.PATH, FAKE_NPM_NEEDAUTH: "kit.tgz" } }))).rejects.toThrow(/exited 1/);
|
|
210
|
+
await expect(target!.stage(context(dir, { env: { PATH: process.env.PATH, NPM_TOKEN: "t", FAKE_NPM_FAIL: "kit.tgz" } }))).rejects.toThrow(/403/);
|
|
211
|
+
expect(calls(log).filter((c) => c.args[0] === "publish")).toHaveLength(2);
|
|
212
|
+
});
|
|
213
|
+
|
|
169
214
|
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
215
|
const { dir, npm, log } = setup();
|
|
171
216
|
const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "packages/cli", "packages/kit"] })], { npm });
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `release compose` (#4168 4.3): chosen trunk commits cherry-picked onto a
|
|
3
|
+
* base, each with a `Chant-Composed-From` trailer, on a pushed
|
|
4
|
+
* `chant/compose/<name>` branch, appended to and never rewritten.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test, expect } from "vitest";
|
|
8
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { composeRelease, ComposeError } from "./release-compose";
|
|
13
|
+
import { composeBranchContaining, composedFrom, composedIncluding, compositionOf } from "./compose-refs";
|
|
14
|
+
|
|
15
|
+
function git(dir: string, ...args: string[]): string {
|
|
16
|
+
const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
|
|
17
|
+
if (r.status !== 0) throw new Error(r.stderr);
|
|
18
|
+
return r.stdout.trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A clone of a bare remote with trunk: base, then #1 (a.txt), #2 (b.txt, work w-2), #3 (a.txt again). */
|
|
22
|
+
function trunk(dir: string): { clone: string; remote: string; base: string; c1: string; c2: string; c3: string } {
|
|
23
|
+
const remote = join(dir, "remote.git");
|
|
24
|
+
const clone = join(dir, "clone");
|
|
25
|
+
git(dir, "init", "-q", "--bare", "-b", "main", remote);
|
|
26
|
+
mkdirSync(clone);
|
|
27
|
+
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@chant.dev"], ["config", "user.name", "T"], ["remote", "add", "origin", remote]]) git(clone, ...args);
|
|
28
|
+
const commit = (file: string, text: string, message: string): string => {
|
|
29
|
+
writeFileSync(join(clone, file), text);
|
|
30
|
+
git(clone, "add", file);
|
|
31
|
+
git(clone, "commit", "-q", "-m", message);
|
|
32
|
+
return git(clone, "rev-parse", "HEAD");
|
|
33
|
+
};
|
|
34
|
+
const base = commit("a.txt", "a0\n", "base");
|
|
35
|
+
const c1 = commit("a.txt", "a1\n", "fix a (#1)");
|
|
36
|
+
const c2 = commit("b.txt", "b\n", "add b (#2)\n\nChant-Record: work:w-2");
|
|
37
|
+
const c3 = commit("a.txt", "a3\n", "rework a (#3)");
|
|
38
|
+
git(clone, "push", "-q", "origin", "main");
|
|
39
|
+
git(clone, "fetch", "-q", "origin");
|
|
40
|
+
return { clone, remote, base, c1, c2, c3 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TRUNK = { remote: "origin", branch: "main" };
|
|
44
|
+
|
|
45
|
+
describe("composeRelease", () => {
|
|
46
|
+
test("picks the chosen changes in trunk order onto the base, with trailers, and pushes the branch", async () => {
|
|
47
|
+
await withTestDir(async (dir) => {
|
|
48
|
+
const t = trunk(dir);
|
|
49
|
+
const dry = await composeRelease({ cwd: t.clone, name: "hotfix", base: t.base, prs: [], works: ["w-2"], yes: false, trunk: TRUNK });
|
|
50
|
+
expect(dry.written).toBe(false);
|
|
51
|
+
expect(dry.picked.map((s) => s.commit)).toEqual([t.c2]);
|
|
52
|
+
expect(git(t.clone, "branch", "--list", "chant/compose/hotfix")).toBe("");
|
|
53
|
+
|
|
54
|
+
const r = await composeRelease({ cwd: t.clone, name: "hotfix", base: t.base, prs: [2, 1], works: [], yes: true, trunk: TRUNK });
|
|
55
|
+
expect(r.picked.map((s) => s.commit)).toEqual([t.c1, t.c2]);
|
|
56
|
+
expect(r.picked[1].work).toEqual(["w-2"]);
|
|
57
|
+
expect(r.written).toBe(true);
|
|
58
|
+
expect(r.pushed).toBe(true);
|
|
59
|
+
expect(git(t.remote, "rev-parse", "refs/heads/chant/compose/hotfix")).toBe(r.tip);
|
|
60
|
+
expect(composedFrom(t.clone, r.tip)).toEqual([t.c2]);
|
|
61
|
+
expect(compositionOf(t.clone, r.tip)).toEqual({ branch: null, base: t.base, sources: [t.c1, t.c2] });
|
|
62
|
+
expect(composeBranchContaining(t.clone, r.tip)?.branch).toBe("chant/compose/hotfix");
|
|
63
|
+
expect(composedIncluding(t.clone, t.c1)).toEqual([{ branch: "chant/compose/hotfix", commit: git(t.clone, "rev-parse", `${r.tip}^`) }]);
|
|
64
|
+
expect(git(t.clone, "show", `${r.tip}:a.txt`)).toBe("a1");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("appends to the branch, skips what it carries, and refuses another base", async () => {
|
|
69
|
+
await withTestDir(async (dir) => {
|
|
70
|
+
const t = trunk(dir);
|
|
71
|
+
const first = await composeRelease({ cwd: t.clone, name: "x", base: t.base, prs: [1], works: [], yes: true, trunk: TRUNK });
|
|
72
|
+
const again = await composeRelease({ cwd: t.clone, name: "x", prs: [1, 2], works: [], yes: true, trunk: TRUNK });
|
|
73
|
+
expect(again.from).toBe(first.tip);
|
|
74
|
+
expect(again.already.map((s) => s.commit)).toEqual([t.c1]);
|
|
75
|
+
expect(again.picked.map((s) => s.commit)).toEqual([t.c2]);
|
|
76
|
+
expect(git(t.clone, "rev-parse", `${again.tip}^`)).toBe(first.tip);
|
|
77
|
+
await expect(composeRelease({ cwd: t.clone, name: "x", base: t.c1, prs: [3], works: [], yes: true, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-base-differs" });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("stops on a conflict, names the change and the file, and writes nothing", async () => {
|
|
82
|
+
await withTestDir(async (dir) => {
|
|
83
|
+
const t = trunk(dir);
|
|
84
|
+
const err = await composeRelease({ cwd: t.clone, name: "y", base: t.base, prs: [3], works: [], yes: true, trunk: TRUNK }).catch((e: unknown) => e);
|
|
85
|
+
expect(err).toBeInstanceOf(ComposeError);
|
|
86
|
+
expect((err as ComposeError).code).toBe("compose-conflict");
|
|
87
|
+
expect((err as ComposeError).conflict).toMatchObject({ commit: t.c3, files: ["a.txt"] });
|
|
88
|
+
expect((err as ComposeError).message).toContain("#3");
|
|
89
|
+
expect(git(t.clone, "branch", "--list", "chant/compose/y")).toBe("");
|
|
90
|
+
expect(spawnSync("git", ["rev-parse", "--verify", "--quiet", "refs/heads/chant/compose/y"], { cwd: t.remote }).status).not.toBe(0);
|
|
91
|
+
expect(git(t.clone, "worktree", "list").split("\n")).toHaveLength(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("refuses a change trunk does not have, a bad name, and no base for a new branch", async () => {
|
|
96
|
+
await withTestDir(async (dir) => {
|
|
97
|
+
const t = trunk(dir);
|
|
98
|
+
await expect(composeRelease({ cwd: t.clone, name: "z", base: t.base, prs: [9], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-source-unknown" });
|
|
99
|
+
await expect(composeRelease({ cwd: t.clone, name: "../z", base: t.base, prs: [1], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-name-invalid" });
|
|
100
|
+
await expect(composeRelease({ cwd: t.clone, name: "z", prs: [1], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-base-unknown" });
|
|
101
|
+
await expect(composeRelease({ cwd: t.clone, name: "z", base: t.base, prs: [], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-nothing-chosen" });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("composeRelease after a hand-made commit", () => {
|
|
107
|
+
test("keeps the branch's base when its tip carries no trailer", async () => {
|
|
108
|
+
await withTestDir(async (dir) => {
|
|
109
|
+
const t = trunk(dir);
|
|
110
|
+
const first = await composeRelease({ cwd: t.clone, name: "h", base: t.base, prs: [1], works: [], yes: true, trunk: TRUNK });
|
|
111
|
+
// A person resolves something on the branch and pushes it.
|
|
112
|
+
git(t.clone, "checkout", "-q", "chant/compose/h");
|
|
113
|
+
writeFileSync(join(t.clone, "c.txt"), "by hand\n");
|
|
114
|
+
git(t.clone, "add", "c.txt");
|
|
115
|
+
git(t.clone, "commit", "-q", "-m", "by hand");
|
|
116
|
+
git(t.clone, "push", "-q", "origin", "chant/compose/h");
|
|
117
|
+
git(t.clone, "checkout", "-q", "main");
|
|
118
|
+
const next = await composeRelease({ cwd: t.clone, name: "h", base: t.base, prs: [1, 2], works: [], yes: true, trunk: TRUNK });
|
|
119
|
+
expect(next.base).toBe(t.base);
|
|
120
|
+
expect(next.already.map((s) => s.commit)).toEqual([t.c1]);
|
|
121
|
+
expect(next.picked.map((s) => s.commit)).toEqual([t.c2]);
|
|
122
|
+
expect(git(t.clone, "rev-parse", `${next.tip}~2`)).toBe(first.tip);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|