@intentius/chant-lexicon-aws 0.40.0 → 0.41.3

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/src/spec/fetch.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { homedir } from "os";
2
- import { join } from "path";
2
+ import { dirname, join } from "path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { execFile } from "child_process";
5
+ import { promisify } from "util";
3
6
  import { fetchWithCache, extractFromZip, clearCacheFile } from "@intentius/chant/codegen/fetch";
7
+ import { ACCEPT_ENV, AWS_SPEC_PIN, specContentDigest, type SpecPin } from "./pin";
4
8
 
5
9
  /**
6
10
  * Top-level CloudFormation Registry JSON Schema for a single resource type.
@@ -67,18 +71,154 @@ const SCHEMA_ZIP_URL = "https://schema.cloudformation.us-east-1.amazonaws.com/Cl
67
71
  const CACHE_DIR = join(homedir(), ".chant");
68
72
  const CACHE_FILE = join(CACHE_DIR, "CloudformationSchema.zip");
69
73
 
74
+ /**
75
+ * The pinned-spec store (chant #1511): assets on one dedicated GitHub release,
76
+ * named by content digest, in a public repo — so nothing binary ever enters
77
+ * git history, and the download needs no auth anywhere (CI included).
78
+ *
79
+ * Why a store at all: `prepack` regenerates from upstream, and the registry
80
+ * serves a single mutable "latest" artifact — on 2026-08-05 four distinct
81
+ * contents were observed in one day, two *contradicting* each other about the
82
+ * same resources, so the surface gate compared artifacts built from whichever
83
+ * variant that fetch happened to hit. Publishing aws was a retry lottery
84
+ * (v0.41.1 and v0.41.2 both stranded it). The accepted content itself is the
85
+ * only deterministic input; the accept uploads it, every build downloads it
86
+ * by digest and verifies before trusting it.
87
+ */
88
+ const SPEC_PIN_RELEASE_TAG = "aws-spec-pin";
89
+ const SPEC_PIN_REPO = "INTENTIUS/chant";
90
+ /** Verified-by-digest local copies of pin assets, so repeat builds skip the download. Content-addressed: no TTL, never invalidated. */
91
+ const PIN_CACHE_DIR = join(CACHE_DIR, "spec-pin");
92
+
93
+ /** `<first 12 digest hex>.zip` — the asset (and local cache) name for a pin. */
94
+ export function pinAssetName(pin: SpecPin = AWS_SPEC_PIN): string {
95
+ return `${pin.digest.replace(/^sha256:/, "").slice(0, 12)}.zip`;
96
+ }
97
+
98
+ /** Public, unauthenticated download URL for a pin's asset. */
99
+ export function pinAssetUrl(pin: SpecPin = AWS_SPEC_PIN): string {
100
+ return `https://github.com/${SPEC_PIN_REPO}/releases/download/${SPEC_PIN_RELEASE_TAG}/${pinAssetName(pin)}`;
101
+ }
102
+
103
+ /** Injectable downloader, so tests never reach the network. Returns undefined on any failure — the caller falls back to the live fetch. */
104
+ export type PinAssetDownloader = (url: string) => Promise<Buffer | undefined>;
105
+
106
+ const downloadPinAsset: PinAssetDownloader = async (url) => {
107
+ try {
108
+ const res = await fetch(url, { redirect: "follow" });
109
+ if (!res.ok) return undefined;
110
+ return Buffer.from(await res.arrayBuffer());
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ };
115
+
116
+ /**
117
+ * Load and verify the pinned spec: the local verified cache first, then the
118
+ * release asset. Returns undefined when neither is available (offline and
119
+ * cold, or the accept never uploaded) — the caller falls back to the live
120
+ * fetch and the pin's existing advisory drift report. Content that does not
121
+ * digest to the pin throws: a tampered or half-uploaded asset must never
122
+ * silently pass as the accepted spec, wherever it came from.
123
+ */
124
+ export async function loadPinnedSchemas(
125
+ options: { pin?: SpecPin; cacheDir?: string; download?: PinAssetDownloader } = {},
126
+ ): Promise<Map<string, Buffer> | undefined> {
127
+ const pin = options.pin ?? AWS_SPEC_PIN;
128
+ const cacheDir = options.cacheDir ?? PIN_CACHE_DIR;
129
+ const cachePath = join(cacheDir, pinAssetName(pin));
130
+
131
+ const verify = async (zipData: Buffer, source: string): Promise<Map<string, Buffer>> => {
132
+ const schemas = await extractRawSchemas(zipData);
133
+ const digest = specContentDigest(schemas);
134
+ if (digest !== pin.digest) {
135
+ throw new Error(
136
+ `pinned spec from ${source} extracts to ${digest}, but the pin declares ${pin.digest} — ` +
137
+ `refusing to build from it. Re-run the accept (${ACCEPT_ENV}=1 npm run generate) to ` +
138
+ `upload the content the pin actually names.`,
139
+ );
140
+ }
141
+ return schemas;
142
+ };
143
+
144
+ if (existsSync(cachePath)) {
145
+ return verify(readFileSync(cachePath), cachePath);
146
+ }
147
+
148
+ const url = pinAssetUrl(pin);
149
+ const zipData = await (options.download ?? downloadPinAsset)(url);
150
+ if (zipData === undefined) return undefined;
151
+ const schemas = await verify(zipData, url);
152
+ // Cache only after verification, so the cache can never hold a bad copy.
153
+ mkdirSync(cacheDir, { recursive: true });
154
+ writeFileSync(cachePath, zipData);
155
+ return schemas;
156
+ }
157
+
158
+ /**
159
+ * The accept flow's upload half: push the just-accepted zip to the pin
160
+ * release via `gh` (dev-machine path — publishes never upload). Creates the
161
+ * rolling `aws-spec-pin` release on first use. Assets are content-addressed
162
+ * and never overwritten. A missing/unauthenticated `gh` degrades to printing
163
+ * the exact command to run by hand — the accept still completes, and the pin
164
+ * block still prints.
165
+ */
166
+ async function uploadPinAsset(zipData: Buffer, digest: string): Promise<void> {
167
+ const pin: SpecPin = { ...AWS_SPEC_PIN, digest };
168
+ mkdirSync(PIN_CACHE_DIR, { recursive: true });
169
+ const localPath = join(PIN_CACHE_DIR, pinAssetName(pin));
170
+ writeFileSync(localPath, zipData);
171
+
172
+ const run = promisify(execFile);
173
+ try {
174
+ try {
175
+ await run("gh", ["release", "view", SPEC_PIN_RELEASE_TAG, "--repo", SPEC_PIN_REPO]);
176
+ } catch {
177
+ await run("gh", [
178
+ "release", "create", SPEC_PIN_RELEASE_TAG,
179
+ "--repo", SPEC_PIN_REPO,
180
+ "--title", "aws spec pin assets",
181
+ "--notes", "Content-addressed CloudFormation registry archives, one per accepted spec pin (chant #1511). Uploaded by the accept flow; downloaded and digest-verified by every build. Not a chant release.",
182
+ ]);
183
+ }
184
+ await run("gh", ["release", "upload", SPEC_PIN_RELEASE_TAG, localPath, "--repo", SPEC_PIN_REPO]);
185
+ console.error(`Accepted spec uploaded: ${pinAssetUrl(pin)}`);
186
+ } catch (err) {
187
+ console.error(
188
+ `Could not upload the accepted spec via gh (${err instanceof Error ? err.message.split("\n")[0] : String(err)}).\n` +
189
+ `Upload it yourself before merging the pin:\n` +
190
+ ` gh release upload ${SPEC_PIN_RELEASE_TAG} ${localPath} --repo ${SPEC_PIN_REPO}`,
191
+ );
192
+ }
193
+ }
194
+
70
195
  /**
71
196
  * Fetch the CloudFormation Registry schema zip and extract per-resource JSON schemas.
72
197
  * Returns a Map keyed by typeName (e.g. "AWS::S3::Bucket") to raw JSON bytes.
73
198
  *
74
- * Uses a local cache with 24h TTL.
199
+ * chant #1511 the pinned release asset is the primary source: when it
200
+ * resolves and digest-verifies, generation uses the content a human accepted
201
+ * rather than whatever upstream variant this fetch happens to hit, so every
202
+ * build (CI, prepack, publish) is deterministic. The live fetch remains for:
203
+ * `force`, the accept flow (`CHANT_ACCEPT_AWS_SPEC=1`, which must sample
204
+ * upstream — and bypasses the 24h cache for the same reason), and when the
205
+ * asset is unreachable. The live path keeps its 24h local cache.
75
206
  */
76
207
  export async function fetchSchemaZip(force = false): Promise<Map<string, Buffer>> {
208
+ const accepting = !!process.env[ACCEPT_ENV];
209
+ if (!force && !accepting) {
210
+ const pinned = await loadPinnedSchemas();
211
+ if (pinned) return pinned;
212
+ }
77
213
  const zipData = await fetchWithCache(
78
214
  { url: SCHEMA_ZIP_URL, cacheFile: CACHE_FILE },
79
- force,
215
+ force || accepting,
80
216
  );
81
- return extractRawSchemas(zipData);
217
+ const schemas = await extractRawSchemas(zipData);
218
+ if (accepting) {
219
+ await uploadPinAsset(zipData, specContentDigest(schemas));
220
+ }
221
+ return schemas;
82
222
  }
83
223
 
84
224
  /**
@@ -125,9 +125,16 @@ describe("assertPinnedSpec", () => {
125
125
  expect(() => assertPinnedSpec(pinned, { pin, pinnedNames: names, env: {} })).not.toThrow();
126
126
  });
127
127
 
128
- test("refuses a drifted archive — generation does not proceed", () => {
129
- expect(() => assertPinnedSpec(archive("AWS::A::One", "AWS::B::Two"), { pin, pinnedNames: names, env: {} }))
130
- .toThrow(/upstream CloudFormation schema has moved/);
128
+ test("reports a drifted archive — generation proceeds (#1473)", () => {
129
+ // Was a throw. Enforcement moved to the release-time surface gate, so a
130
+ // moving upstream can no longer redden an unrelated PR.
131
+ const warnings: string[] = [];
132
+ expect(() =>
133
+ assertPinnedSpec(archive("AWS::A::One", "AWS::B::Two"), {
134
+ pin, pinnedNames: names, env: {}, warn: (m) => warnings.push(m),
135
+ }),
136
+ ).not.toThrow();
137
+ expect(warnings[0]).toMatch(/upstream CloudFormation schema has moved/);
131
138
  });
132
139
 
133
140
  test("the accept env proceeds and reports instead", () => {
@@ -196,16 +203,26 @@ describe("byte churn vs a moved resource set (#1473)", () => {
196
203
  expect(warnings[0]).toContain("surface.snapshot.json");
197
204
  });
198
205
 
199
- test("a type removed — still refuses", () => {
200
- const { threw } = capture(archive("AWS::S3::Bucket"));
201
- expect(threw?.message).toContain("Generation refuses");
202
- expect(threw?.message).toContain("removed");
206
+ test("a type removed — reports loudly, does not throw", () => {
207
+ // Enforcement is the release-time surface gate; a removed type always
208
+ // shows up there. Throwing here made every aws PR hostage to upstream.
209
+ const { warnings, threw } = capture(archive("AWS::S3::Bucket"));
210
+ expect(threw).toBeUndefined();
211
+ expect(warnings[0]).toContain("removed");
212
+ expect(warnings[0]).toContain("Generation refuses");
203
213
  });
204
214
 
205
- test("a type added — still refuses", () => {
206
- const { threw } = capture(archive("AWS::S3::Bucket", "AWS::IAM::Role", "AWS::SQS::Queue"));
207
- expect(threw?.message).toContain("Generation refuses");
208
- expect(threw?.message).toContain("added");
215
+ test("a type added — reports loudly, does not throw", () => {
216
+ const { warnings, threw } = capture(archive("AWS::S3::Bucket", "AWS::IAM::Role", "AWS::SQS::Queue"));
217
+ expect(threw).toBeUndefined();
218
+ expect(warnings[0]).toContain("added");
219
+ });
220
+
221
+ test("a moved type set is reported more urgently than byte churn", () => {
222
+ const edited = new Map(pinned);
223
+ edited.set("AWS::S3::Bucket", schema("AWS::S3::Bucket", "reworded"));
224
+ expect(capture(edited).warnings[0]).toContain("resource set is unchanged");
225
+ expect(capture(archive("AWS::S3::Bucket")).warnings[0]).not.toContain("resource set is unchanged");
209
226
  });
210
227
 
211
228
  test("an unchanged archive neither warns nor throws", () => {
@@ -214,15 +231,13 @@ describe("byte churn vs a moved resource set (#1473)", () => {
214
231
  expect(warnings).toEqual([]);
215
232
  });
216
233
 
217
- test("with no previous type set to compare, a mismatch stays fatal", () => {
218
- // `specDrift` reports empty added/removed when it cannot compare, which
219
- // would otherwise read as "no type moved" and downgrade every mismatch.
234
+ test("generation is never blocked by the pin, whatever moved", () => {
235
+ // The invariant that matters now: `generate` always completes. Whether the
236
+ // result may SHIP is decided by the surface gate in validate.
220
237
  const edited = new Map(pinned);
221
238
  edited.set("AWS::S3::Bucket", schema("AWS::S3::Bucket", "reworded"));
222
-
223
- expect(() =>
224
- assertPinnedSpec(edited, { pin, pinnedNames: new Set(), env: {}, warn: () => {} }),
225
- ).toThrow(/Generation refuses/);
239
+ expect(() => assertPinnedSpec(edited, { pin, pinnedNames: new Set(), env: {}, warn: () => {} })).not.toThrow();
240
+ expect(() => assertPinnedSpec(archive("AWS::X::Y"), { pin, pinnedNames: names, env: {}, warn: () => {} })).not.toThrow();
226
241
  });
227
242
 
228
243
  test("the accept env still short-circuits both cases", () => {
package/src/spec/pin.ts CHANGED
@@ -56,9 +56,9 @@ export interface SpecPin {
56
56
  * want, and paste the printed pin here in its own commit.
57
57
  */
58
58
  export const AWS_SPEC_PIN: SpecPin = {
59
- digest: "sha256:f2a0f4f2c0685116fa928a01d889b2aa2a03e4f02cd3257b124cbb184f7e533b",
60
- resources: 1650,
61
- accepted: "2026-08-04",
59
+ digest: "sha256:f05366bcba3160e5992f4ae312286e6f06a87dcbef425b29b39259beadd42ce0",
60
+ resources: 1653,
61
+ accepted: "2026-08-05",
62
62
  };
63
63
 
64
64
  /** Env var that accepts whatever upstream currently serves, printing the new pin. */
@@ -235,13 +235,20 @@ export function assertPinnedSpec(
235
235
  // Guarded on actually HAVING a previous type set: `specDrift` reports empty
236
236
  // added/removed when it has nothing to compare against, which would
237
237
  // otherwise read as "no type moved" and downgrade every mismatch.
238
- const typeSetKnown = pinnedNames !== undefined && pinnedNames.size > 0;
239
- const typeSetMoved = drift.added.length > 0 || drift.removed.length > 0;
240
-
241
- if (typeSetKnown && !typeSetMoved) {
242
- warn(driftMessage(drift, pin, { fatal: false }));
243
- return;
244
- }
245
-
246
- throw new Error(driftMessage(drift, pin));
238
+ // chant #1473 the pin reports, it does not gate.
239
+ //
240
+ // Refusing here made every aws PR hostage to CloudFormation: the archive
241
+ // gains and edits types through the day, and `generate` runs on every CI
242
+ // job, so an unrelated change goes red the moment upstream moves. Both the
243
+ // 0.39.0 and 0.40.1 releases died this way, and a PR *accepting* the drift
244
+ // was itself refused by a newer drift that arrived while its CI queued.
245
+ //
246
+ // Enforcement lives where the consequence is: core's
247
+ // `validateLexiconArtifacts` compares the generated API against the reviewed
248
+ // `surface.snapshot.json` and, armed by CHANT_RELEASE_GATE, refuses to
249
+ // publish a surface nobody reviewed. A new or removed type always shows up
250
+ // there, so nothing is lost by reporting rather than throwing here — while a
251
+ // description edit, which changes the digest and no declaration, stops
252
+ // costing a red build.
253
+ warn(driftMessage(drift, pin, { fatal: drift.added.length > 0 || drift.removed.length > 0 }));
247
254
  }
@@ -968,6 +968,7 @@
968
968
  "AWS::Lex::ResourcePolicy",
969
969
  "AWS::LicenseManager::Grant",
970
970
  "AWS::LicenseManager::License",
971
+ "AWS::LicenseManager::LicenseAssetRuleSet",
971
972
  "AWS::Lightsail::Alarm",
972
973
  "AWS::Lightsail::Bucket",
973
974
  "AWS::Lightsail::Certificate",
@@ -1236,6 +1237,7 @@
1236
1237
  "AWS::QuickSight::Template",
1237
1238
  "AWS::QuickSight::Theme",
1238
1239
  "AWS::QuickSight::Topic",
1240
+ "AWS::QuickSight::TopicV2",
1239
1241
  "AWS::QuickSight::VPCConnection",
1240
1242
  "AWS::RAM::Permission",
1241
1243
  "AWS::RAM::ResourceShare",
@@ -1273,6 +1275,7 @@
1273
1275
  "AWS::Redshift::EventSubscription",
1274
1276
  "AWS::Redshift::Integration",
1275
1277
  "AWS::Redshift::ScheduledAction",
1278
+ "AWS::Redshift::SnapshotSchedule",
1276
1279
  "AWS::RedshiftServerless::Namespace",
1277
1280
  "AWS::RedshiftServerless::Snapshot",
1278
1281
  "AWS::RedshiftServerless::Workgroup",