@intentius/chant-lexicon-aws 0.44.13 → 0.45.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.
Files changed (52) hide show
  1. package/dist/agentcore/trace-fetch.d.ts +4 -1
  2. package/dist/agentcore/trace-fetch.d.ts.map +1 -1
  3. package/dist/api/read-client.d.ts +31 -1
  4. package/dist/api/read-client.d.ts.map +1 -1
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/components/capability-plugin.d.ts.map +1 -1
  7. package/dist/components/cloud-executor.d.ts +9 -0
  8. package/dist/components/cloud-executor.d.ts.map +1 -1
  9. package/dist/composites/agentcore-agent.d.ts +33 -19
  10. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  11. package/dist/composites/index.d.ts +1 -1
  12. package/dist/composites/index.d.ts.map +1 -1
  13. package/dist/deep-observe.d.ts +21 -0
  14. package/dist/deep-observe.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/integrity.json +2 -2
  18. package/dist/manifest.json +1 -1
  19. package/dist/op/activities/aws-apply.d.ts +14 -4
  20. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  21. package/dist/plugin.d.ts.map +1 -1
  22. package/dist/properties.d.ts +4 -3
  23. package/dist/properties.d.ts.map +1 -1
  24. package/dist/spec/fetch.d.ts +12 -1
  25. package/dist/spec/fetch.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/agentcore/trace-fetch.test.ts +17 -0
  28. package/src/agentcore/trace-fetch.ts +7 -2
  29. package/src/api/read-client.test.ts +87 -0
  30. package/src/api/read-client.ts +57 -1
  31. package/src/codegen/docs-links.test.ts +44 -30
  32. package/src/codegen/docs.ts +2 -1034
  33. package/src/components/capability-plugin.ts +5 -2
  34. package/src/components/cloud-executor.test.ts +29 -1
  35. package/src/components/cloud-executor.ts +23 -5
  36. package/src/composites/agentcore-agent.test.ts +32 -12
  37. package/src/composites/agentcore-agent.ts +43 -23
  38. package/src/composites/index.ts +1 -1
  39. package/src/composites/microvm-app.test.ts +2 -2
  40. package/src/deep-observe.test.ts +94 -0
  41. package/src/deep-observe.ts +58 -2
  42. package/src/import/roundtrip-fixtures.test.ts +1 -1
  43. package/src/index.ts +3 -1
  44. package/src/lifecycle-integration.test.ts +89 -0
  45. package/src/op/activities/aws-apply.test.ts +40 -4
  46. package/src/op/activities/aws-apply.ts +23 -7
  47. package/src/plugin.ts +34 -35
  48. package/src/properties.test.ts +5 -5
  49. package/src/properties.ts +6 -5
  50. package/src/serializer.test.ts +73 -33
  51. package/src/spec/fetch.test.ts +40 -0
  52. package/src/spec/fetch.ts +24 -3
@@ -8,6 +8,7 @@ import { AWS } from "./pseudo";
8
8
  import { nestedStack, NestedStackOutputRef } from "./nested-stack";
9
9
  import { stackOutput } from "@intentius/chant/stack-output";
10
10
  import { createResource } from "@intentius/chant/runtime";
11
+ import { resolveAttrRefs } from "@intentius/chant/discovery/resolve";
11
12
  import type { SerializerResult } from "@intentius/chant/serializer";
12
13
  import type { BuildResult } from "@intentius/chant/build";
13
14
  import { Parameter } from "./parameter";
@@ -41,7 +42,7 @@ describe("awsSerializer", () => {
41
42
  describe("awsSerializer.serialize", () => {
42
43
  test("produces valid CF template structure", () => {
43
44
  const entities = new Map<string, Declarable>();
44
- const output = awsSerializer.serialize(entities);
45
+ const output = awsSerializer.serialize(entities) as string;
45
46
  const template = JSON.parse(output);
46
47
 
47
48
  expect(template.AWSTemplateFormatVersion).toBe("2010-09-09");
@@ -50,7 +51,7 @@ describe("awsSerializer.serialize", () => {
50
51
 
51
52
  test("serializes empty entities", () => {
52
53
  const entities = new Map<string, Declarable>();
53
- const output = awsSerializer.serialize(entities);
54
+ const output = awsSerializer.serialize(entities) as string;
54
55
  const template = JSON.parse(output);
55
56
 
56
57
  expect(template.Resources).toEqual({});
@@ -61,7 +62,7 @@ describe("awsSerializer.serialize", () => {
61
62
  const entities = new Map<string, Declarable>();
62
63
  entities.set("MyBucket", new MockBucket({ BucketName: "my-bucket" }));
63
64
 
64
- const output = awsSerializer.serialize(entities);
65
+ const output = awsSerializer.serialize(entities) as string;
65
66
  const template = JSON.parse(output);
66
67
 
67
68
  expect(template.Resources.MyBucket).toBeDefined();
@@ -74,7 +75,7 @@ describe("awsSerializer.serialize", () => {
74
75
  entities.set("MyBucket", new MockBucket({ BucketName: "my-bucket" }));
75
76
  entities.set("rotationTransform", templateTransform("AWS::SecretsManager-2020-07-23"));
76
77
 
77
- const template = JSON.parse(awsSerializer.serialize(entities));
78
+ const template = JSON.parse(awsSerializer.serialize(entities) as string);
78
79
 
79
80
  expect(template.Transform).toBe("AWS::SecretsManager-2020-07-23");
80
81
  expect(template.Resources.rotationTransform).toBeUndefined();
@@ -87,7 +88,7 @@ describe("awsSerializer.serialize", () => {
87
88
  entities.set("t2", templateTransform("AWS::LanguageExtensions"));
88
89
  entities.set("t3", templateTransform("AWS::SecretsManager-2020-07-23")); // dup
89
90
 
90
- const template = JSON.parse(awsSerializer.serialize(entities));
91
+ const template = JSON.parse(awsSerializer.serialize(entities) as string);
91
92
 
92
93
  expect(template.Transform).toEqual(["AWS::SecretsManager-2020-07-23", "AWS::LanguageExtensions"]);
93
94
  });
@@ -96,7 +97,7 @@ describe("awsSerializer.serialize", () => {
96
97
  const entities = new Map<string, Declarable>();
97
98
  entities.set("MyBucket", new MockBucket({ BucketName: "my-bucket" }));
98
99
 
99
- const template = JSON.parse(awsSerializer.serialize(entities));
100
+ const template = JSON.parse(awsSerializer.serialize(entities) as string);
100
101
 
101
102
  expect(template.Transform).toBeUndefined();
102
103
  });
@@ -108,7 +109,7 @@ describe("awsSerializer.serialize", () => {
108
109
  defaultValue: "dev",
109
110
  }));
110
111
 
111
- const output = awsSerializer.serialize(entities);
112
+ const output = awsSerializer.serialize(entities) as string;
112
113
  const template = JSON.parse(output);
113
114
 
114
115
  expect(template.Parameters).toBeDefined();
@@ -124,7 +125,7 @@ describe("awsSerializer.serialize", () => {
124
125
  VersioningConfiguration: { Status: "Enabled" },
125
126
  }));
126
127
 
127
- const output = awsSerializer.serialize(entities);
128
+ const output = awsSerializer.serialize(entities) as string;
128
129
  const template = JSON.parse(output);
129
130
 
130
131
  expect(template.Resources.MyBucket.Properties.VersioningConfiguration).toEqual({
@@ -136,7 +137,7 @@ describe("awsSerializer.serialize", () => {
136
137
  const entities = new Map<string, Declarable>();
137
138
  entities.set("MyBucket", new MockBucket({ BucketName: "test" }));
138
139
 
139
- const output = awsSerializer.serialize(entities);
140
+ const output = awsSerializer.serialize(entities) as string;
140
141
  const template = JSON.parse(output);
141
142
 
142
143
  expect(template.Resources.MyBucket.Properties.BucketName).toBeDefined();
@@ -147,7 +148,7 @@ describe("awsSerializer.serialize", () => {
147
148
  entities.set("DataBucket", new MockBucket({ BucketName: "data-bucket" }));
148
149
  entities.set("LogsBucket", new MockBucket({ BucketName: "logs-bucket" }));
149
150
 
150
- const output = awsSerializer.serialize(entities);
151
+ const output = awsSerializer.serialize(entities) as string;
151
152
  const template = JSON.parse(output);
152
153
 
153
154
  expect(Object.keys(template.Resources)).toHaveLength(2);
@@ -160,7 +161,7 @@ describe("awsSerializer.serialize", () => {
160
161
  entities.set("Env", new Parameter("String"));
161
162
  entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
162
163
 
163
- const output = awsSerializer.serialize(entities);
164
+ const output = awsSerializer.serialize(entities) as string;
164
165
  const template = JSON.parse(output);
165
166
 
166
167
  expect(template.Parameters).toBeDefined();
@@ -173,7 +174,7 @@ describe("awsSerializer.serialize", () => {
173
174
  const entities = new Map<string, Declarable>();
174
175
  entities.set("MyBucket", new MockBucket({})); // No properties set
175
176
 
176
- const output = awsSerializer.serialize(entities);
177
+ const output = awsSerializer.serialize(entities) as string;
177
178
  const template = JSON.parse(output);
178
179
 
179
180
  // Should have no Properties key or empty properties
@@ -210,7 +211,7 @@ describe("property-kind Declarables", () => {
210
211
  entities.set("DataEncryption", encryption);
211
212
  entities.set("MyBucket", bucket);
212
213
 
213
- const output = awsSerializer.serialize(entities);
214
+ const output = awsSerializer.serialize(entities) as string;
214
215
  const template = JSON.parse(output);
215
216
 
216
217
  // Encryption should be inlined, not a Ref
@@ -232,7 +233,7 @@ describe("property-kind Declarables", () => {
232
233
  entities.set("DataEncryption", encryption);
233
234
  entities.set("MyBucket", new MockBucket({ BucketName: "my-bucket" }));
234
235
 
235
- const output = awsSerializer.serialize(entities);
236
+ const output = awsSerializer.serialize(entities) as string;
236
237
  const template = JSON.parse(output);
237
238
 
238
239
  expect(template.Resources.DataEncryption).toBeUndefined();
@@ -257,7 +258,7 @@ describe("property-kind Declarables", () => {
257
258
  entities.set("SourceBucket", sourceBucket);
258
259
  entities.set("Config", new MockConfig(sourceBucket));
259
260
 
260
- const output = awsSerializer.serialize(entities);
261
+ const output = awsSerializer.serialize(entities) as string;
261
262
  const template = JSON.parse(output);
262
263
 
263
264
  expect(template.Resources.Config.Properties.Bucket).toEqual({ Ref: "SourceBucket" });
@@ -268,7 +269,7 @@ describe("intrinsic serialization", () => {
268
269
  test("handles AttrRef in properties", () => {
269
270
  const source = new MockBucket({ BucketName: "source" });
270
271
  // Set the logical name on the AttrRef before using it
271
- (source.arn as Record<string, unknown>)._setLogicalName("SourceBucket");
272
+ (source.arn as unknown as { _setLogicalName(n: string): void })._setLogicalName("SourceBucket");
272
273
 
273
274
  class MockReplication implements Declarable {
274
275
  readonly [DECLARABLE_MARKER] = true as const;
@@ -285,27 +286,61 @@ describe("intrinsic serialization", () => {
285
286
  entities.set("SourceBucket", source);
286
287
  entities.set("Replication", new MockReplication(source.arn));
287
288
 
288
- const output = awsSerializer.serialize(entities);
289
+ const output = awsSerializer.serialize(entities) as string;
289
290
  const template = JSON.parse(output);
290
291
 
291
292
  expect(template.Resources.Replication.Properties.SourceArn).toEqual({
292
293
  "Fn::GetAtt": ["SourceBucket", "Arn"],
293
294
  });
294
295
  });
296
+
297
+ // chant #1535 — the issue's example: a sibling attr-ref inside a nested
298
+ // policy document. The serializer walks it to Fn::GetAtt at any depth; the
299
+ // `Principal: {}` the issue saw came from the fold path dropping the ref
300
+ // before the serializer ever ran (see core's fold.ts). Pinned here so the
301
+ // aws side of that contract stays covered.
302
+ test("handles AttrRef nested inside a policy document (#1535)", () => {
303
+ const OIDCProvider = createResource("AWS::IAM::OIDCProvider", "aws", { Arn: "Arn" });
304
+ const Role = createResource("AWS::IAM::Role", "aws", { Arn: "Arn", RoleId: "RoleId" });
305
+ const provider = new OIDCProvider({ Url: "https://token.actions.githubusercontent.com" });
306
+ const role = new Role({
307
+ AssumeRolePolicyDocument: {
308
+ Version: "2012-10-17",
309
+ Statement: [
310
+ {
311
+ Effect: "Allow",
312
+ Principal: { Federated: provider.Arn },
313
+ Action: "sts:AssumeRoleWithWebIdentity",
314
+ },
315
+ ],
316
+ },
317
+ });
318
+ const entities = new Map<string, Declarable>([
319
+ ["Provider", provider],
320
+ ["Role", role],
321
+ ]);
322
+ resolveAttrRefs(entities);
323
+
324
+ const template = JSON.parse(awsSerializer.serialize(entities) as string);
325
+
326
+ expect(template.Resources.Role.Properties.AssumeRolePolicyDocument.Statement[0].Principal).toEqual({
327
+ Federated: { "Fn::GetAtt": ["Provider", "Arn"] },
328
+ });
329
+ });
295
330
  });
296
331
 
297
332
  describe("LexiconOutput serialization", () => {
298
333
  test("resolves AttrRef nested in a Join output to Fn::GetAtt, not the __attrRef envelope (chant#935)", () => {
299
334
  const s1 = new MockBucket({ BucketName: "subnet-1" });
300
335
  const s2 = new MockBucket({ BucketName: "subnet-2" });
301
- (s1.arn as Record<string, unknown>)._setLogicalName("Subnet1");
302
- (s2.arn as Record<string, unknown>)._setLogicalName("Subnet2");
336
+ (s1.arn as unknown as { _setLogicalName(n: string): void })._setLogicalName("Subnet1");
337
+ (s2.arn as unknown as { _setLogicalName(n: string): void })._setLogicalName("Subnet2");
303
338
  const joined = new LexiconOutput(Join(":", [s1.arn, s2.arn]), "SubnetIds");
304
339
  joined._setSourceEntity("Subnet1");
305
340
  const entities = new Map<string, Declarable>();
306
341
  entities.set("Subnet1", s1);
307
342
  entities.set("Subnet2", s2);
308
- const template = JSON.parse(awsSerializer.serialize(entities, [joined]));
343
+ const template = JSON.parse(awsSerializer.serialize(entities, [joined]) as string);
309
344
  const value = JSON.stringify(template.Outputs.SubnetIds.Value);
310
345
  expect(value).not.toContain("__attrRef");
311
346
  expect(value).toContain("Fn::GetAtt");
@@ -320,7 +355,7 @@ describe("LexiconOutput serialization", () => {
320
355
  const entities = new Map<string, Declarable>();
321
356
  entities.set("dataBucket", bucket);
322
357
 
323
- const result = awsSerializer.serialize(entities, [lexiconOutput]);
358
+ const result = awsSerializer.serialize(entities, [lexiconOutput]) as string;
324
359
  const template = JSON.parse(result);
325
360
 
326
361
  expect(template.Outputs).toBeDefined();
@@ -342,7 +377,7 @@ describe("LexiconOutput serialization", () => {
342
377
  entities.set("dataBucket", dataBucket);
343
378
  entities.set("logsBucket", logsBucket);
344
379
 
345
- const result = awsSerializer.serialize(entities, [dataOutput, logsOutput]);
380
+ const result = awsSerializer.serialize(entities, [dataOutput, logsOutput]) as string;
346
381
  const template = JSON.parse(result);
347
382
 
348
383
  expect(template.Outputs).toBeDefined();
@@ -376,7 +411,7 @@ describe("LexiconOutput serialization", () => {
376
411
  const entities = new Map<string, Declarable>();
377
412
  entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
378
413
 
379
- const result = awsSerializer.serialize(entities);
414
+ const result = awsSerializer.serialize(entities) as string;
380
415
  const template = JSON.parse(result);
381
416
 
382
417
  expect(template.Outputs).toBeUndefined();
@@ -458,7 +493,12 @@ function mockChildBuildResult(childTemplate: object): BuildResult {
458
493
  entities: new Map(),
459
494
  warnings: [],
460
495
  errors: [],
461
- manifest: { lexicons: ["aws"], outputs: {}, deployOrder: ["aws"] },
496
+ manifest: {
497
+ lexicons: ["aws"],
498
+ outputs: {},
499
+ deployOrder: ["aws"],
500
+ stackGraph: { nodes: ["aws"], edges: [], order: ["aws"], waves: [["aws"]], cycles: [] },
501
+ },
462
502
  sourceFileCount: 1,
463
503
  };
464
504
  }
@@ -623,7 +663,7 @@ class MockResourceWithAttrs implements Declarable {
623
663
  readonly lexicon = "aws";
624
664
  readonly entityType: string;
625
665
  readonly props: Record<string, unknown>;
626
- readonly attributes: Record<string, unknown>;
666
+ readonly attributes!: Record<string, unknown>;
627
667
 
628
668
  constructor(
629
669
  type: string,
@@ -639,7 +679,7 @@ class MockResourceWithAttrs implements Declarable {
639
679
  describe("resource-level CF attributes", () => {
640
680
  function serialize(...entries: [string, Declarable][]) {
641
681
  const entities = new Map<string, Declarable>(entries);
642
- const output = awsSerializer.serialize(entities);
682
+ const output = awsSerializer.serialize(entities) as string;
643
683
  return JSON.parse(output as string);
644
684
  }
645
685
 
@@ -839,7 +879,7 @@ describe("default tags serialization", () => {
839
879
  entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
840
880
  entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
841
881
 
842
- const output = awsSerializer.serialize(entities);
882
+ const output = awsSerializer.serialize(entities) as string;
843
883
  const template = JSON.parse(output as string);
844
884
 
845
885
  expect(template.Resources.tags).toBeUndefined();
@@ -851,7 +891,7 @@ describe("default tags serialization", () => {
851
891
  entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
852
892
  entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
853
893
 
854
- const output = awsSerializer.serialize(entities);
894
+ const output = awsSerializer.serialize(entities) as string;
855
895
  const template = JSON.parse(output as string);
856
896
 
857
897
  expect(template.Resources.MyBucket.Properties.Tags).toEqual([
@@ -868,7 +908,7 @@ describe("default tags serialization", () => {
868
908
  }));
869
909
  entities.set("tags", defaultTags([{ Key: "Env", Value: "prod" }]) as unknown as Declarable);
870
910
 
871
- const output = awsSerializer.serialize(entities);
911
+ const output = awsSerializer.serialize(entities) as string;
872
912
  const template = JSON.parse(output as string);
873
913
 
874
914
  expect(template.Resources.Perm.Properties.Tags).toBeUndefined();
@@ -885,7 +925,7 @@ describe("default tags serialization", () => {
885
925
  { Key: "Team", Value: "platform" },
886
926
  ]) as unknown as Declarable);
887
927
 
888
- const output = awsSerializer.serialize(entities);
928
+ const output = awsSerializer.serialize(entities) as string;
889
929
  const template = JSON.parse(output as string);
890
930
 
891
931
  const tags = template.Resources.MyBucket.Properties.Tags;
@@ -904,7 +944,7 @@ describe("default tags serialization", () => {
904
944
  { Key: "Stack", Value: Sub`${AWS.StackName}` },
905
945
  ]) as unknown as Declarable);
906
946
 
907
- const output = awsSerializer.serialize(entities);
947
+ const output = awsSerializer.serialize(entities) as string;
908
948
  const template = JSON.parse(output as string);
909
949
 
910
950
  const tags = template.Resources.MyBucket.Properties.Tags;
@@ -922,7 +962,7 @@ describe("default tags serialization", () => {
922
962
  { Key: "Environment", Value: env },
923
963
  ]) as unknown as Declarable);
924
964
 
925
- const output = awsSerializer.serialize(entities);
965
+ const output = awsSerializer.serialize(entities) as string;
926
966
  const template = JSON.parse(output as string);
927
967
 
928
968
  const tags = template.Resources.MyBucket.Properties.Tags;
@@ -935,7 +975,7 @@ describe("default tags serialization", () => {
935
975
  const entities = new Map<string, Declarable>();
936
976
  entities.set("MyBucket", new MockBucket({ BucketName: "bucket" }));
937
977
 
938
- const output = awsSerializer.serialize(entities);
978
+ const output = awsSerializer.serialize(entities) as string;
939
979
  const template = JSON.parse(output as string);
940
980
 
941
981
  expect(template.Resources.MyBucket.Properties.Tags).toBeUndefined();
@@ -125,3 +125,43 @@ describe("loadPinnedSchemas (#1511)", () => {
125
125
  );
126
126
  });
127
127
  });
128
+
129
+ // #1481 — the live fallback is the nondeterminism that made `chant` and
130
+ // `publish` disagree about the same commit. Under the release gate it is a
131
+ // refusal, and the refusal fires before any request to the live archive.
132
+ describe("fetchSchemaZip under CHANT_RELEASE_GATE (#1481)", () => {
133
+ test("refuses to fall back to the live archive when the pinned asset is unavailable", async () => {
134
+ const dir = mkdtempSync(join(tmpdir(), "chant-spec-pin-"));
135
+ try {
136
+ await expect(
137
+ fetchSchemaZip(false, {
138
+ env: { CHANT_RELEASE_GATE: "1" },
139
+ download: async () => undefined,
140
+ pinCacheDir: dir,
141
+ }),
142
+ ).rejects.toThrow(/CHANT_RELEASE_GATE=1 and the pinned spec asset could not be loaded/);
143
+ } finally {
144
+ rmSync(dir, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test("the gate goes through the pinned loader, never the live path", async () => {
149
+ const dir = mkdtempSync(join(tmpdir(), "chant-spec-pin-"));
150
+ try {
151
+ const { zipSync } = await import("fflate");
152
+ const { AWS_SPEC_PIN } = await import("./pin");
153
+ // A zip that digests to the committed pin cannot be forged here, so use
154
+ // the verified-cache path: a cached asset that does NOT digest to the
155
+ // pin is refused by the loader, which is only reachable on the pinned
156
+ // path. The live path would have returned whatever upstream serves.
157
+ const bogus = Buffer.from(zipSync({ "x.json": Buffer.from(JSON.stringify({ typeName: "AWS::X::Y" })) }));
158
+ mkdirSync(dir, { recursive: true });
159
+ writeFileSync(join(dir, pinAssetName(AWS_SPEC_PIN)), bogus);
160
+ await expect(
161
+ fetchSchemaZip(false, { env: { CHANT_RELEASE_GATE: "1" }, pinCacheDir: dir }),
162
+ ).rejects.toThrow(/extracts to/);
163
+ } finally {
164
+ rmSync(dir, { recursive: true, force: true });
165
+ }
166
+ });
167
+ });
package/src/spec/fetch.ts CHANGED
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import { execFile } from "child_process";
5
5
  import { promisify } from "util";
6
6
  import { fetchWithCache, extractFromZip, clearCacheFile } from "@intentius/chant/codegen/fetch";
7
+ import { RELEASE_GATE_ENV } from "@intentius/chant/codegen/validate";
7
8
  import { ACCEPT_ENV, AWS_SPEC_PIN, specContentDigest, type SpecPin } from "./pin";
8
9
 
9
10
  /**
@@ -203,12 +204,32 @@ async function uploadPinAsset(zipData: Buffer, digest: string): Promise<void> {
203
204
  * `force`, the accept flow (`CHANT_ACCEPT_AWS_SPEC=1`, which must sample
204
205
  * upstream — and bypasses the 24h cache for the same reason), and when the
205
206
  * asset is unreachable. The live path keeps its 24h local cache.
207
+ *
208
+ * chant #1481 — the fallback is what made two workflows disagree about one
209
+ * commit. The live archive is served from CloudFront and two fetches seconds
210
+ * apart can get different variants; under the release gate, building from
211
+ * it would ship a surface nobody reviewed on a retry lottery, so the gate
212
+ * refuses instead. Anywhere else the fallback stays, but says so, because a
213
+ * silent fallback is indistinguishable from the pinned path in a CI log.
206
214
  */
207
- export async function fetchSchemaZip(force = false): Promise<Map<string, Buffer>> {
208
- const accepting = !!process.env[ACCEPT_ENV];
215
+ export async function fetchSchemaZip(
216
+ force = false,
217
+ options: { env?: NodeJS.ProcessEnv; download?: PinAssetDownloader; pinCacheDir?: string } = {},
218
+ ): Promise<Map<string, Buffer>> {
219
+ const env = options.env ?? process.env;
220
+ const accepting = !!env[ACCEPT_ENV];
209
221
  if (!force && !accepting) {
210
- const pinned = await loadPinnedSchemas();
222
+ const pinned = await loadPinnedSchemas({ download: options.download, cacheDir: options.pinCacheDir });
211
223
  if (pinned) return pinned;
224
+ if (env[RELEASE_GATE_ENV] === "1") {
225
+ throw new Error(
226
+ `${RELEASE_GATE_ENV}=1 and the pinned spec asset could not be loaded (${pinAssetUrl()}) — ` +
227
+ `refusing to build a release from the live CloudFormation archive, which is not the content ` +
228
+ `the pin was reviewed against. Check the asset exists on the ${SPEC_PIN_RELEASE_TAG} release ` +
229
+ `(the accept flow uploads it: ${ACCEPT_ENV}=1 npm run generate).`,
230
+ );
231
+ }
232
+ console.error(`pinned spec asset unavailable (${pinAssetUrl()}); falling back to the live CloudFormation archive`);
212
233
  }
213
234
  const zipData = await fetchWithCache(
214
235
  { url: SCHEMA_ZIP_URL, cacheFile: CACHE_FILE },