@intentius/chant 0.34.0 → 0.37.0
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/commands/check-lexicon-docs.d.ts +42 -0
- package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +16 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/fetch.d.ts +1 -1
- package/dist/codegen/fetch.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +0 -10
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lifecycle/rollback.d.ts +18 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +53 -0
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
- package/src/cli/commands/check-lexicon-docs.ts +71 -0
- package/src/cli/commands/check-lexicon.ts +15 -0
- package/src/cli/handlers/components.ts +54 -3
- package/src/cli/handlers/graph.test.ts +61 -0
- package/src/cli/handlers/lifecycle.ts +8 -1
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/codegen/docs-sections.ts +1 -1
- package/src/codegen/docs.ts +122 -5
- package/src/codegen/fetch.test.ts +24 -5
- package/src/codegen/fetch.ts +19 -1
- package/src/codegen/publish-order.test.ts +133 -0
- package/src/deep-observation.test.ts +151 -0
- package/src/deep-observation.ts +66 -2
- package/src/lifecycle/rollback.test.ts +78 -1
- package/src/lifecycle/rollback.ts +41 -2
- package/src/lifecycle/status.test.ts +90 -5
- package/src/lifecycle/status.ts +85 -2
- package/src/yaml.test.ts +89 -0
- package/src/yaml.ts +66 -9
|
@@ -572,6 +572,67 @@ describe("runGraph", () => {
|
|
|
572
572
|
expect((opts as { owned: boolean }).owned).toBe(true);
|
|
573
573
|
});
|
|
574
574
|
|
|
575
|
+
// #1158: a project that declares its stacks in `ChantConfig.stacks` — rather
|
|
576
|
+
// than deriving them from `*.component.ts` — must observe each declared
|
|
577
|
+
// stack, the same contract `resolveStackTargets` gives lifecycle
|
|
578
|
+
// snapshot/diff. Every other test here passes `stacks: []`, so without this
|
|
579
|
+
// the declared-stack path is implemented and unguarded.
|
|
580
|
+
test("ChantConfig.stacks: observeResources gets every declared stack, with its region and src", async () => {
|
|
581
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
582
|
+
loadPluginsMock.mockResolvedValue([
|
|
583
|
+
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
584
|
+
]);
|
|
585
|
+
loadChantConfigMock.mockResolvedValue({
|
|
586
|
+
config: {
|
|
587
|
+
stacks: [
|
|
588
|
+
{ name: "estate-east", region: "us-east-1", src: "east/src" },
|
|
589
|
+
{ name: "estate-west", region: "us-west-2", src: "west/src" },
|
|
590
|
+
],
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
|
|
594
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
595
|
+
expect(exit).toBe(0);
|
|
596
|
+
expect(observeMock).toHaveBeenCalledTimes(1);
|
|
597
|
+
const [, , , opts] = observeMock.mock.calls[0];
|
|
598
|
+
// The region/src carry through — a multi-region estate observes each
|
|
599
|
+
// stack in its own region, not all of them in the ambient default.
|
|
600
|
+
expect((opts as { stacks: Array<{ name: string; region?: string; src?: string }> }).stacks).toEqual([
|
|
601
|
+
{ name: "estate-east", region: "us-east-1", src: "east/src" },
|
|
602
|
+
{ name: "estate-west", region: "us-west-2", src: "west/src" },
|
|
603
|
+
]);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// A stack can be both component-derived and declared. It must be observed
|
|
607
|
+
// once — `describeResources` is a live API call per stack, so a duplicate
|
|
608
|
+
// is a wasted round trip and a doubled node set to reconcile.
|
|
609
|
+
test("ChantConfig.stacks: a stack also derived from a component is not observed twice", async () => {
|
|
610
|
+
resolveLexMock.mockResolvedValue(["aws"]);
|
|
611
|
+
loadPluginsMock.mockResolvedValue([
|
|
612
|
+
{ name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
|
|
613
|
+
]);
|
|
614
|
+
discoverComponentsMock.mockResolvedValue({
|
|
615
|
+
errors: [],
|
|
616
|
+
sourceFiles: [],
|
|
617
|
+
components: new Map([
|
|
618
|
+
["loom-db", { component: { name: "loom-db", dependsOn: [], deploy: [
|
|
619
|
+
{ phase: "deploy", steps: [{ kind: "cfn-deploy", stack: "shared-estate" }] },
|
|
620
|
+
] }, exportName: "loomDb", filePath: "components/loom-db.component.ts" }],
|
|
621
|
+
]),
|
|
622
|
+
});
|
|
623
|
+
loadChantConfigMock.mockResolvedValue({
|
|
624
|
+
config: { stacks: [{ name: "shared-estate" }, { name: "estate-west" }] },
|
|
625
|
+
});
|
|
626
|
+
observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
|
|
627
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
|
|
628
|
+
expect(exit).toBe(0);
|
|
629
|
+
const [, , , opts] = observeMock.mock.calls[0];
|
|
630
|
+
expect((opts as { stacks: Array<{ name: string }> }).stacks.map((s) => s.name)).toEqual([
|
|
631
|
+
"shared-estate",
|
|
632
|
+
"estate-west",
|
|
633
|
+
]);
|
|
634
|
+
});
|
|
635
|
+
|
|
575
636
|
test("component discovery errors: falls back to the single-stack path with a warning", async () => {
|
|
576
637
|
resolveLexMock.mockResolvedValue(["aws"]);
|
|
577
638
|
loadPluginsMock.mockResolvedValue([
|
|
@@ -273,11 +273,18 @@ export async function runLifecycleRollback(ctx: CommandContext): Promise<number>
|
|
|
273
273
|
const { config } = await loadChantConfig(resolve("."));
|
|
274
274
|
const sourceDir = config.sourceDir ?? ".";
|
|
275
275
|
try {
|
|
276
|
-
const result = await rollbackToRevision({ ref, env: environment, sourceDir, cwd: resolve(".") });
|
|
276
|
+
const result = await rollbackToRevision({ ref, env: environment, sourceDir, cwd: resolve("."), dryRun: args.dryRun });
|
|
277
277
|
if (result.noop) {
|
|
278
278
|
console.error(formatSuccess(`${sourceDir} already matches ${ref} — nothing to roll back`));
|
|
279
279
|
return 0;
|
|
280
280
|
}
|
|
281
|
+
if (args.dryRun) {
|
|
282
|
+
// The delta on stdout, so it pipes and diffs like any other patch; the
|
|
283
|
+
// summary on stderr, matching the PR path's split.
|
|
284
|
+
process.stdout.write(result.diff ?? "");
|
|
285
|
+
console.error(formatSuccess(`Rollback delta for ${ref} computed — no PR opened, nothing pushed`));
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
281
288
|
console.log(result.prUrl); // the PR URL — the consumer (behold) reads this from stdout
|
|
282
289
|
console.error(formatSuccess(`Opened rollback PR on ${result.branch}`));
|
|
283
290
|
return 0;
|
package/src/cli/main.ts
CHANGED
|
@@ -211,6 +211,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
211
211
|
result.migrateTo = args[++i];
|
|
212
212
|
} else if (arg === "--emit") {
|
|
213
213
|
result.emit = args[++i];
|
|
214
|
+
} else if (arg === "--dry-run") {
|
|
215
|
+
result.dryRun = true;
|
|
214
216
|
} else if (arg === "--strict") {
|
|
215
217
|
result.strict = true;
|
|
216
218
|
} else if (arg === "--validate") {
|
package/src/cli/registry.ts
CHANGED
|
@@ -63,6 +63,8 @@ export interface ParsedArgs {
|
|
|
63
63
|
selectName?: string;
|
|
64
64
|
/** `chant import --owned` — restrict live import to chant-owned resources */
|
|
65
65
|
owned?: boolean;
|
|
66
|
+
/** `chant lifecycle rollback --dry-run` — compute the rollback delta and print it; open no PR, push nothing, leave no branch. */
|
|
67
|
+
dryRun?: boolean;
|
|
66
68
|
/** `chant import --verbatim` — keep server-defaulted fields in live import */
|
|
67
69
|
verbatim?: boolean;
|
|
68
70
|
/** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
|
|
@@ -95,7 +95,7 @@ export function generateIntrinsics(
|
|
|
95
95
|
"",
|
|
96
96
|
`The ${config.displayName} lexicon provides **${intrinsics.length}** intrinsic functions.`,
|
|
97
97
|
"",
|
|
98
|
-
`**Tag?** shows how an intrinsic is authored: a genuine tagged template (\`Sub\\\`...\\\`\`) or a plain function call (\`Ref(...)\`). **Folds?** shows whether [
|
|
98
|
+
`**Tag?** shows how an intrinsic is authored: a genuine tagged template (\`Sub\\\`...\\\`\`) or a plain function call (\`Ref(...)\`). **Folds?** shows whether [folding](/chant/concepts/typescript-as-data/#folded-vs-run) — the default \`chant build\` path — can reduce a use of this intrinsic today, without running the file — generated directly from this lexicon's registration, not restated by hand. A tagged template folds once it is registered. A plain call folds only where this lexicon has opted that intrinsic in one at a time (\`foldsAsCall\`), so a \`No\` in this column means this intrinsic has not been opted in, not that calls in general cannot fold.`,
|
|
99
99
|
"",
|
|
100
100
|
"| Function | Description | Output Key | Tag? | Folds? |",
|
|
101
101
|
"|----------|-------------|------------|------|--------|",
|
package/src/codegen/docs.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* (service grouping, resource type URLs, custom overview content).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { copyFileSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
10
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import { fileURLToPath } from "url";
|
|
13
13
|
|
|
@@ -71,6 +71,7 @@ export function docsPipeline(config: DocsConfig): DocsResult {
|
|
|
71
71
|
}
|
|
72
72
|
pages.set("index.mdx", overviewContent);
|
|
73
73
|
const suppress = new Set(config.suppressPages ?? []);
|
|
74
|
+
const extraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
|
|
74
75
|
|
|
75
76
|
// Extra pages from lexicon config
|
|
76
77
|
if (config.extraPages) {
|
|
@@ -96,12 +97,28 @@ export function docsPipeline(config: DocsConfig): DocsResult {
|
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
// A generated page must not overwrite one the lexicon explicitly declared.
|
|
101
|
+
// The extraPages above are written into `pages` first, so an unguarded
|
|
102
|
+
// `pages.set` below silently discards them: helm declared its own
|
|
103
|
+
// "Intrinsics Reference" and pre-synth rules pages and shipped neither for
|
|
104
|
+
// as long as both slugs collided (#1312). Explicit authorship wins, and the
|
|
105
|
+
// collision is reported rather than resolved in silence.
|
|
106
|
+
const claimed = (slug: string): boolean => {
|
|
107
|
+
if (suppress.has(slug)) return true;
|
|
108
|
+
if (!extraSlugs.has(slug)) return false;
|
|
109
|
+
console.warn(
|
|
110
|
+
`[docs:${config.name}] extraPages declares "${slug}", which is also a generated page — keeping the declared one.\n` +
|
|
111
|
+
` Add "${slug}" to suppressPages to make that explicit, or rename the extraPage if both are wanted.`,
|
|
112
|
+
);
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (!claimed("intrinsics") && manifest.intrinsics && manifest.intrinsics.length > 0) {
|
|
100
117
|
pages.set("intrinsics.mdx", generateIntrinsics(config, manifest));
|
|
101
118
|
}
|
|
102
119
|
|
|
103
120
|
if (
|
|
104
|
-
!
|
|
121
|
+
!claimed("pseudo-parameters") &&
|
|
105
122
|
manifest.pseudoParameters &&
|
|
106
123
|
Object.keys(manifest.pseudoParameters).length > 0
|
|
107
124
|
) {
|
|
@@ -111,14 +128,23 @@ export function docsPipeline(config: DocsConfig): DocsResult {
|
|
|
111
128
|
);
|
|
112
129
|
}
|
|
113
130
|
|
|
114
|
-
if (!
|
|
131
|
+
if (!claimed("rules") && rules.length > 0) {
|
|
115
132
|
pages.set("rules.mdx", generateRules(config, rules));
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
if (!
|
|
135
|
+
if (!claimed("serialization")) {
|
|
119
136
|
pages.set("serialization.mdx", generateSerialization(config));
|
|
120
137
|
}
|
|
121
138
|
|
|
139
|
+
// Stamp every emitted page with its provenance. These files look exactly
|
|
140
|
+
// like the hand-written pages sitting beside them, and without a marker they
|
|
141
|
+
// get edited directly — the k8s "Live Cluster" sidebar group and the AWS
|
|
142
|
+
// intrinsics guide's #1044 claim were both fixed in the emitted `.mdx` and
|
|
143
|
+
// silently reverted by the next regen (#1312).
|
|
144
|
+
for (const [filename, content] of pages) {
|
|
145
|
+
pages.set(filename, withGeneratedMarker(config, content));
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
return {
|
|
123
149
|
pages,
|
|
124
150
|
stats: {
|
|
@@ -131,6 +157,66 @@ export function docsPipeline(config: DocsConfig): DocsResult {
|
|
|
131
157
|
};
|
|
132
158
|
}
|
|
133
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Insert a provenance comment directly after a page's frontmatter.
|
|
162
|
+
*
|
|
163
|
+
* MDX parses `<!-- -->` as JSX rather than a comment, so this uses the
|
|
164
|
+
* `{/* … *\/}` form the rest of the docs already use for generated markers.
|
|
165
|
+
*/
|
|
166
|
+
function withGeneratedMarker(config: DocsConfig, content: string): string {
|
|
167
|
+
const marker = `{/* ${GENERATED_MARKER_TAG} by \`npm run docs -w @intentius/chant-lexicon-${config.name}\` — DO NOT EDIT.\n Edit lexicons/${config.name}/src/codegen/docs.ts instead; changes here are overwritten. */}`;
|
|
168
|
+
const lines = content.split("\n");
|
|
169
|
+
// Frontmatter is the leading `---` … `---` block; the marker goes after it.
|
|
170
|
+
if (lines[0] === "---") {
|
|
171
|
+
const close = lines.indexOf("---", 1);
|
|
172
|
+
if (close > 0) {
|
|
173
|
+
lines.splice(close + 1, 0, "", marker);
|
|
174
|
+
return lines.join("\n");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return `${marker}\n\n${content}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Marks a page as pipeline output. Used both to warn readers off editing the
|
|
182
|
+
* file and, in {@link writeDocsSite}, to tell a page this pipeline owns from a
|
|
183
|
+
* hand-written one when reaping pages it no longer emits.
|
|
184
|
+
*/
|
|
185
|
+
export const GENERATED_MARKER_TAG = "GENERATED-BY-CHANT-DOCS";
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Every slug a Starlight sidebar reaches, including nested group items.
|
|
189
|
+
*/
|
|
190
|
+
export function collectSidebarSlugs(items: Array<Record<string, unknown>>): Set<string> {
|
|
191
|
+
const slugs = new Set<string>();
|
|
192
|
+
const walk = (list: Array<Record<string, unknown>>): void => {
|
|
193
|
+
for (const item of list) {
|
|
194
|
+
if (typeof item.slug === "string") slugs.add(item.slug);
|
|
195
|
+
if (Array.isArray(item.items)) walk(item.items as Array<Record<string, unknown>>);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
walk(items);
|
|
199
|
+
return slugs;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Content pages that no sidebar entry points at.
|
|
204
|
+
*
|
|
205
|
+
* `index` is always the site root and never needs an entry of its own.
|
|
206
|
+
*/
|
|
207
|
+
export function unreachablePages(
|
|
208
|
+
contentDir: string,
|
|
209
|
+
sidebar: Array<Record<string, unknown>>,
|
|
210
|
+
): string[] {
|
|
211
|
+
if (!existsSync(contentDir)) return [];
|
|
212
|
+
const slugs = collectSidebarSlugs(sidebar);
|
|
213
|
+
return readdirSync(contentDir)
|
|
214
|
+
.filter((f) => f.endsWith(".mdx") || f.endsWith(".md"))
|
|
215
|
+
.map((f) => f.replace(/\.mdx?$/, ""))
|
|
216
|
+
.filter((slug) => slug !== "index" && !slugs.has(slug))
|
|
217
|
+
.sort();
|
|
218
|
+
}
|
|
219
|
+
|
|
134
220
|
/**
|
|
135
221
|
* Write generated docs pages to disk.
|
|
136
222
|
*/
|
|
@@ -161,6 +247,23 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
|
|
|
161
247
|
const filePath = join(contentDir, filename);
|
|
162
248
|
rmSync(filePath, { force: true });
|
|
163
249
|
}
|
|
250
|
+
|
|
251
|
+
// Reap pages this pipeline used to emit and no longer does. Suppressing a
|
|
252
|
+
// page only stops it being written; the copy from the last run stayed on
|
|
253
|
+
// disk, unreferenced by the sidebar and indistinguishable from a
|
|
254
|
+
// hand-written page — which is how azure, gcp and helm each kept a `rules`
|
|
255
|
+
// page after adopting their own (#1312). The provenance marker is what makes
|
|
256
|
+
// this safe: only a file this pipeline stamped is ever removed.
|
|
257
|
+
if (existsSync(contentDir)) {
|
|
258
|
+
for (const filename of readdirSync(contentDir)) {
|
|
259
|
+
if (!filename.endsWith(".mdx") && !filename.endsWith(".md")) continue;
|
|
260
|
+
if (result.pages.has(filename)) continue;
|
|
261
|
+
const filePath = join(contentDir, filename);
|
|
262
|
+
if (readFileSync(filePath, "utf-8").includes(GENERATED_MARKER_TAG)) {
|
|
263
|
+
rmSync(filePath, { force: true });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
164
267
|
rmSync(join(outDir, ".astro"), { recursive: true, force: true });
|
|
165
268
|
rmSync(join(outDir, "node_modules", ".astro"), { recursive: true, force: true });
|
|
166
269
|
|
|
@@ -170,6 +273,20 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
|
|
|
170
273
|
// Build sidebar from generated pages
|
|
171
274
|
const sidebar = buildSidebar(config, result);
|
|
172
275
|
|
|
276
|
+
// Starlight does not auto-discover pages, so a page absent from the sidebar
|
|
277
|
+
// is reachable only by typing its URL. The pipeline deliberately preserves
|
|
278
|
+
// hand-written pages it did not emit (above), which makes it easy to add one
|
|
279
|
+
// and never wire it up — azure, temporal, helm and github each accumulated
|
|
280
|
+
// several that way (#1312). Report them; `chant dev check-lexicon` gates on
|
|
281
|
+
// the same condition.
|
|
282
|
+
const unreachable = unreachablePages(contentDir, sidebar);
|
|
283
|
+
if (unreachable.length > 0) {
|
|
284
|
+
console.warn(
|
|
285
|
+
`[docs:${config.name}] ${unreachable.length} page(s) in no sidebar entry — reachable only by direct URL: ${unreachable.join(", ")}.\n` +
|
|
286
|
+
` Declare them in this lexicon's DocsConfig \`sidebarExtra\` (or \`extraPages\`) to surface them.`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
173
290
|
// package.json
|
|
174
291
|
writeFileSync(
|
|
175
292
|
join(outDir, "package.json"),
|
|
@@ -182,12 +182,26 @@ describe("fetchWithRetry", () => {
|
|
|
182
182
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
test("
|
|
185
|
+
test("bounds an attempt with a signal even when no init is given", async () => {
|
|
186
186
|
const fetchMock = vi.fn().mockResolvedValue(ok());
|
|
187
187
|
vi.stubGlobal("fetch", fetchMock);
|
|
188
188
|
|
|
189
189
|
await fetchWithRetry("https://example.test/x", 4, 1);
|
|
190
|
-
|
|
190
|
+
// A hung connect would otherwise wait on the OS default, which is long
|
|
191
|
+
// enough to run a CI job past its timeout.
|
|
192
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
193
|
+
"https://example.test/x",
|
|
194
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a caller's own signal wins over the attempt bound", async () => {
|
|
199
|
+
const fetchMock = vi.fn().mockResolvedValue(ok());
|
|
200
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
201
|
+
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
await fetchWithRetry("https://example.test/x", 4, 1, { signal: controller.signal });
|
|
204
|
+
expect(fetchMock.mock.calls[0][1].signal).toBe(controller.signal);
|
|
191
205
|
});
|
|
192
206
|
|
|
193
207
|
test("passes request init through to fetch", async () => {
|
|
@@ -196,7 +210,10 @@ describe("fetchWithRetry", () => {
|
|
|
196
210
|
|
|
197
211
|
const init = { headers: { Accept: "application/vnd.github+json" } };
|
|
198
212
|
await fetchWithRetry("https://example.test/x", 4, 1, init);
|
|
199
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
213
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
214
|
+
"https://example.test/x",
|
|
215
|
+
expect.objectContaining({ headers: init.headers, signal: expect.any(AbortSignal) }),
|
|
216
|
+
);
|
|
200
217
|
});
|
|
201
218
|
|
|
202
219
|
test("preserves init across retries on a transient status", async () => {
|
|
@@ -210,8 +227,10 @@ describe("fetchWithRetry", () => {
|
|
|
210
227
|
const resp = await fetchWithRetry("https://example.test/x", 4, 1, init);
|
|
211
228
|
expect(resp.ok).toBe(true);
|
|
212
229
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
213
|
-
|
|
214
|
-
|
|
230
|
+
for (const call of fetchMock.mock.calls) {
|
|
231
|
+
expect(call[0]).toBe("https://example.test/x");
|
|
232
|
+
expect(call[1]).toEqual(expect.objectContaining({ headers: init.headers, signal: expect.any(AbortSignal) }));
|
|
233
|
+
}
|
|
215
234
|
});
|
|
216
235
|
|
|
217
236
|
test("does not retry a permanent status when init is given", async () => {
|
package/src/codegen/fetch.ts
CHANGED
|
@@ -30,6 +30,20 @@ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
|
30
30
|
|
|
31
31
|
const DEFAULT_RETRIES = 4;
|
|
32
32
|
const DEFAULT_BACKOFF_MS = 1000;
|
|
33
|
+
/**
|
|
34
|
+
* Per-attempt ceiling, so an upstream that accepts a connection and then stops
|
|
35
|
+
* answering cannot hold a build open.
|
|
36
|
+
*
|
|
37
|
+
* Without one, a hung connect waits on the OS default — which on Linux is over
|
|
38
|
+
* a minute — and five of those plus back-off is enough to run a CI job past its
|
|
39
|
+
* timeout. That is not hypothetical: it is what pushed chant's `check` job from
|
|
40
|
+
* ~9m30s to a cancellation at 10m, twice, on two unreachable spec endpoints
|
|
41
|
+
* whose results are only ever a fallback to a committed snapshot anyway.
|
|
42
|
+
*
|
|
43
|
+
* Generous enough for a slow-but-alive endpoint; the point is a bound, not
|
|
44
|
+
* speed.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_ATTEMPT_TIMEOUT_MS = 15_000;
|
|
33
47
|
/** Hard cap on Retry-After delays so a rogue header cannot stall CI indefinitely. */
|
|
34
48
|
const MAX_RETRY_AFTER_MS = 60_000;
|
|
35
49
|
|
|
@@ -85,6 +99,7 @@ export async function fetchWithRetry(
|
|
|
85
99
|
retries = DEFAULT_RETRIES,
|
|
86
100
|
backoffMs = DEFAULT_BACKOFF_MS,
|
|
87
101
|
init?: RequestInit,
|
|
102
|
+
attemptTimeoutMs = DEFAULT_ATTEMPT_TIMEOUT_MS,
|
|
88
103
|
): Promise<Response> {
|
|
89
104
|
let lastStatus: number | undefined;
|
|
90
105
|
let lastError: Error | undefined;
|
|
@@ -101,8 +116,11 @@ export async function fetchWithRetry(
|
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
let response: Response;
|
|
119
|
+
// A caller's own signal still wins; this only bounds an attempt that would
|
|
120
|
+
// otherwise hang with no signal at all.
|
|
121
|
+
const signal = init?.signal ?? AbortSignal.timeout(attemptTimeoutMs);
|
|
104
122
|
try {
|
|
105
|
-
response =
|
|
123
|
+
response = await fetch(url, { ...(init ?? {}), signal });
|
|
106
124
|
} catch (e) {
|
|
107
125
|
// Network-level failure (DNS, connection reset, timeout). Transient.
|
|
108
126
|
lastError = e instanceof Error ? e : new Error(String(e));
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing a package runs its prepack, and a prepack builds against and
|
|
3
|
+
* imports its workspace dependencies' *generated* output — output that only
|
|
4
|
+
* exists once that dependency has been published. So publish order has to be a
|
|
5
|
+
* topological order, and for a long time directory order stood in for one.
|
|
6
|
+
*
|
|
7
|
+
* It held until it did not. chant-v0.34.0 published twelve of fourteen packages
|
|
8
|
+
* and stranded lexicon-forgejo and lexicon-helm a version behind: forgejo needs
|
|
9
|
+
* github's `src/generated/index` and helm needs k8s's `dist/generated`, but
|
|
10
|
+
* `forgejo` < `github` and `helm` < `k8s`, so both ran before the thing they
|
|
11
|
+
* import existed. Half a release shipped and the failure only surfaced from
|
|
12
|
+
* npm, after the tag.
|
|
13
|
+
*
|
|
14
|
+
* publish-packages.sh now derives the order instead of assuming one. This
|
|
15
|
+
* asserts the derivation is actually topological, so the next lexicon that
|
|
16
|
+
* depends on an alphabetically earlier one fails here rather than mid-release.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { join, dirname } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
|
|
26
|
+
|
|
27
|
+
/** Run one of publish-packages.sh's own functions and read back what it prints. */
|
|
28
|
+
function ask(fn: string): string[] {
|
|
29
|
+
const script = readFileSync(join(REPO, "scripts/publish-packages.sh"), "utf8");
|
|
30
|
+
const body = script.match(new RegExp(`^${fn}\\(\\) \\{$.*?^\\}$`, "ms"));
|
|
31
|
+
if (!body) throw new Error(`${fn}() not found in scripts/publish-packages.sh`);
|
|
32
|
+
return execFileSync("bash", ["-c", `${body[0]}\n${fn}`], { cwd: REPO, encoding: "utf8" })
|
|
33
|
+
.split("\n")
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const publishOrder = () => ask("publishable_dirs");
|
|
38
|
+
|
|
39
|
+
function manifest(dir: string) {
|
|
40
|
+
return JSON.parse(readFileSync(join(REPO, dir, "package.json"), "utf8"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("publish order", () => {
|
|
44
|
+
const order = publishOrder();
|
|
45
|
+
|
|
46
|
+
it("covers every publishable workspace package", () => {
|
|
47
|
+
// A package missing from the order never publishes at all, which is the
|
|
48
|
+
// same stranding by a different route.
|
|
49
|
+
const all = execFileSync(
|
|
50
|
+
"bash",
|
|
51
|
+
["-c", 'for d in packages/*/ lexicons/*/; do [ -f "$d/package.json" ] && echo "${d%/}"; done'],
|
|
52
|
+
{ cwd: REPO, encoding: "utf8" },
|
|
53
|
+
)
|
|
54
|
+
.split("\n")
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.filter((d) => !manifest(d).private);
|
|
57
|
+
|
|
58
|
+
expect([...order].sort()).toEqual([...all].sort());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("places every package after the workspace packages it depends on", () => {
|
|
62
|
+
const position = new Map(order.map((d, i) => [d, i]));
|
|
63
|
+
const owner = new Map(order.map((d) => [manifest(d).name as string, d]));
|
|
64
|
+
|
|
65
|
+
for (const dir of order) {
|
|
66
|
+
const pkg = manifest(dir);
|
|
67
|
+
const deps = Object.keys({
|
|
68
|
+
...pkg.dependencies,
|
|
69
|
+
...pkg.peerDependencies,
|
|
70
|
+
...pkg.optionalDependencies,
|
|
71
|
+
});
|
|
72
|
+
for (const name of deps) {
|
|
73
|
+
const depDir = owner.get(name);
|
|
74
|
+
if (!depDir || depDir === dir) continue;
|
|
75
|
+
expect(
|
|
76
|
+
position.get(depDir)!,
|
|
77
|
+
`${pkg.name} (${dir}) publishes before its dependency ${name} (${depDir}), ` +
|
|
78
|
+
`so ${name}'s generated output will not exist when ${pkg.name} prepacks`,
|
|
79
|
+
).toBeLessThan(position.get(dir)!);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("puts the two packages that stranded chant-v0.34.0 after what they import", () => {
|
|
85
|
+
// The specific regression, named, so the failure says what broke rather
|
|
86
|
+
// than only that some invariant did.
|
|
87
|
+
expect(order.indexOf("lexicons/github")).toBeLessThan(order.indexOf("lexicons/forgejo"));
|
|
88
|
+
expect(order.indexOf("lexicons/k8s")).toBeLessThan(order.indexOf("lexicons/helm"));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ordering alone did not fix the release. The rerun published nothing, found
|
|
94
|
+
* k8s already at 0.34.0, skipped it — and skipping the publish skipped the
|
|
95
|
+
* prepack that builds `dist/generated`, so helm failed on the same missing
|
|
96
|
+
* module. Anything another package compiles against has to be built whether or
|
|
97
|
+
* not it needs publishing.
|
|
98
|
+
*/
|
|
99
|
+
describe("packages built even when their publish is skipped", () => {
|
|
100
|
+
const built = new Set(ask("depended_on_dirs"));
|
|
101
|
+
const order = publishOrder();
|
|
102
|
+
|
|
103
|
+
it("covers every workspace dependency, transitively", () => {
|
|
104
|
+
for (const dir of order) {
|
|
105
|
+
const pkg = manifest(dir);
|
|
106
|
+
const owner = new Map(order.map((d) => [manifest(d).name as string, d]));
|
|
107
|
+
for (const name of Object.keys({
|
|
108
|
+
...pkg.dependencies,
|
|
109
|
+
...pkg.peerDependencies,
|
|
110
|
+
...pkg.optionalDependencies,
|
|
111
|
+
})) {
|
|
112
|
+
const depDir = owner.get(name);
|
|
113
|
+
if (!depDir || depDir === dir) continue;
|
|
114
|
+
expect(
|
|
115
|
+
built.has(depDir),
|
|
116
|
+
`${name} (${depDir}) is a dependency of ${pkg.name} but would not be built when ` +
|
|
117
|
+
`its own publish is skipped, so ${pkg.name} compiles against nothing`,
|
|
118
|
+
).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("includes the two whose skipped build failed the chant-v0.34.0 rerun", () => {
|
|
124
|
+
expect(built.has("lexicons/k8s")).toBe(true);
|
|
125
|
+
expect(built.has("lexicons/github")).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does not build a leaf nothing depends on", () => {
|
|
129
|
+
// The set is the reason a rerun is not a full 14-package rebuild.
|
|
130
|
+
expect(built.has("lexicons/helm")).toBe(false);
|
|
131
|
+
expect(built.has("lexicons/forgejo")).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|