From 3167dd4aacf7a6ed7ee6932742795009096cb959 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:14:00 +0000 Subject: [PATCH 01/29] test(golden): add sentinel template directories Three sentinel cases (SAM, CFN, LE-foreach) used by the harness in the next commit. Each case carries metadata.yaml + README.md + src/app.py. Pinned outputs (expected.*.yaml) are generated by update_goldens.py once the harness is in place. --- tests/golden/__init__.py | 0 .../foreach_static_zip/README.md | 7 +++++++ .../foreach_static_zip/metadata.yaml | 3 +++ .../foreach_static_zip/src/app.py | 2 ++ .../foreach_static_zip/template.yaml | 16 ++++++++++++++++ .../lambda_function_zip/README.md | 5 +++++ .../lambda_function_zip/metadata.yaml | 3 +++ .../lambda_function_zip/src/app.py | 2 ++ .../lambda_function_zip/template.yaml | 12 ++++++++++++ .../serverless_function_zip/README.md | 6 ++++++ .../serverless_function_zip/metadata.yaml | 3 +++ .../serverless_function_zip/src/app.py | 2 ++ .../serverless_function_zip/template.yaml | 11 +++++++++++ 13 files changed, 72 insertions(+) create mode 100644 tests/golden/__init__.py create mode 100644 tests/golden/templates/language_extensions/foreach_static_zip/README.md create mode 100644 tests/golden/templates/language_extensions/foreach_static_zip/metadata.yaml create mode 100644 tests/golden/templates/language_extensions/foreach_static_zip/src/app.py create mode 100644 tests/golden/templates/language_extensions/foreach_static_zip/template.yaml create mode 100644 tests/golden/templates/packageable_resources/lambda_function_zip/README.md create mode 100644 tests/golden/templates/packageable_resources/lambda_function_zip/metadata.yaml create mode 100644 tests/golden/templates/packageable_resources/lambda_function_zip/src/app.py create mode 100644 tests/golden/templates/packageable_resources/lambda_function_zip/template.yaml create mode 100644 tests/golden/templates/sam_resources/serverless_function_zip/README.md create mode 100644 tests/golden/templates/sam_resources/serverless_function_zip/metadata.yaml create mode 100644 tests/golden/templates/sam_resources/serverless_function_zip/src/app.py create mode 100644 tests/golden/templates/sam_resources/serverless_function_zip/template.yaml diff --git a/tests/golden/__init__.py b/tests/golden/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/golden/templates/language_extensions/foreach_static_zip/README.md b/tests/golden/templates/language_extensions/foreach_static_zip/README.md new file mode 100644 index 00000000000..8c6bec56ccc --- /dev/null +++ b/tests/golden/templates/language_extensions/foreach_static_zip/README.md @@ -0,0 +1,7 @@ +# foreach_static_zip + +Sentinel LE case. `Fn::ForEach` over a static collection with all expanded +functions sharing the same `CodeUri`. Locks the expanded-template shape +(`AlphaFunction`, `BetaFunction` inlined as siblings, ForEach key gone) +and the post-package shape (each function's `CodeUri` rewritten to the +same `s3://golden-bucket/`). diff --git a/tests/golden/templates/language_extensions/foreach_static_zip/metadata.yaml b/tests/golden/templates/language_extensions/foreach_static_zip/metadata.yaml new file mode 100644 index 00000000000..e2113d53a40 --- /dev/null +++ b/tests/golden/templates/language_extensions/foreach_static_zip/metadata.yaml @@ -0,0 +1,3 @@ +language_extensions: true +description: "Sentinel: Fn::ForEach over static collection, shared CodeUri." +issue_refs: [] diff --git a/tests/golden/templates/language_extensions/foreach_static_zip/src/app.py b/tests/golden/templates/language_extensions/foreach_static_zip/src/app.py new file mode 100644 index 00000000000..29193508dc0 --- /dev/null +++ b/tests/golden/templates/language_extensions/foreach_static_zip/src/app.py @@ -0,0 +1,2 @@ +def handler(event, context): + return {"ok": True} diff --git a/tests/golden/templates/language_extensions/foreach_static_zip/template.yaml b/tests/golden/templates/language_extensions/foreach_static_zip/template.yaml new file mode 100644 index 00000000000..9b4969402b8 --- /dev/null +++ b/tests/golden/templates/language_extensions/foreach_static_zip/template.yaml @@ -0,0 +1,16 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: + - AWS::LanguageExtensions + - AWS::Serverless-2016-10-31 + +Resources: + Fn::ForEach::Workers: + - WorkerName + - - Alpha + - Beta + - ${WorkerName}Function: + Type: AWS::Serverless::Function + Properties: + Handler: app.handler + Runtime: python3.11 + CodeUri: ./src/ diff --git a/tests/golden/templates/packageable_resources/lambda_function_zip/README.md b/tests/golden/templates/packageable_resources/lambda_function_zip/README.md new file mode 100644 index 00000000000..c71fa401dd1 --- /dev/null +++ b/tests/golden/templates/packageable_resources/lambda_function_zip/README.md @@ -0,0 +1,5 @@ +# lambda_function_zip + +Sentinel non-SAM case. Raw `AWS::Lambda::Function` with `Code: ./src/`. Locks +the post-package output shape after the CFN-resource-list packageable walker +rewrites `Code` to `s3://golden-bucket/`. diff --git a/tests/golden/templates/packageable_resources/lambda_function_zip/metadata.yaml b/tests/golden/templates/packageable_resources/lambda_function_zip/metadata.yaml new file mode 100644 index 00000000000..c9bdf896efe --- /dev/null +++ b/tests/golden/templates/packageable_resources/lambda_function_zip/metadata.yaml @@ -0,0 +1,3 @@ +language_extensions: false +description: "Sentinel: raw AWS::Lambda::Function with local Code path." +issue_refs: [] diff --git a/tests/golden/templates/packageable_resources/lambda_function_zip/src/app.py b/tests/golden/templates/packageable_resources/lambda_function_zip/src/app.py new file mode 100644 index 00000000000..29193508dc0 --- /dev/null +++ b/tests/golden/templates/packageable_resources/lambda_function_zip/src/app.py @@ -0,0 +1,2 @@ +def handler(event, context): + return {"ok": True} diff --git a/tests/golden/templates/packageable_resources/lambda_function_zip/template.yaml b/tests/golden/templates/packageable_resources/lambda_function_zip/template.yaml new file mode 100644 index 00000000000..e91999c61e6 --- /dev/null +++ b/tests/golden/templates/packageable_resources/lambda_function_zip/template.yaml @@ -0,0 +1,12 @@ +AWSTemplateFormatVersion: '2010-09-09' + +Resources: + HelloFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: hello + Runtime: python3.11 + Handler: app.handler + Role: arn:aws:iam::123456789012:role/lambda-role + Code: ./src/ + MemorySize: 128 diff --git a/tests/golden/templates/sam_resources/serverless_function_zip/README.md b/tests/golden/templates/sam_resources/serverless_function_zip/README.md new file mode 100644 index 00000000000..cd6f4029995 --- /dev/null +++ b/tests/golden/templates/sam_resources/serverless_function_zip/README.md @@ -0,0 +1,6 @@ +# serverless_function_zip + +Sentinel SAM case. Vanilla `AWS::Serverless::Function` with local `CodeUri` +pointing at a ZIP-packaged source dir. Locks the post-build `template.yaml` +shape (artifact path normalized to `<>`) and post-package +shape (CodeUri rewritten to `s3://golden-bucket/`). diff --git a/tests/golden/templates/sam_resources/serverless_function_zip/metadata.yaml b/tests/golden/templates/sam_resources/serverless_function_zip/metadata.yaml new file mode 100644 index 00000000000..7b7f0e74979 --- /dev/null +++ b/tests/golden/templates/sam_resources/serverless_function_zip/metadata.yaml @@ -0,0 +1,3 @@ +language_extensions: false +description: "Sentinel: AWS::Serverless::Function with local CodeUri (ZIP packaging)." +issue_refs: [] diff --git a/tests/golden/templates/sam_resources/serverless_function_zip/src/app.py b/tests/golden/templates/sam_resources/serverless_function_zip/src/app.py new file mode 100644 index 00000000000..29193508dc0 --- /dev/null +++ b/tests/golden/templates/sam_resources/serverless_function_zip/src/app.py @@ -0,0 +1,2 @@ +def handler(event, context): + return {"ok": True} diff --git a/tests/golden/templates/sam_resources/serverless_function_zip/template.yaml b/tests/golden/templates/sam_resources/serverless_function_zip/template.yaml new file mode 100644 index 00000000000..47f228ea945 --- /dev/null +++ b/tests/golden/templates/sam_resources/serverless_function_zip/template.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 + +Resources: + HelloFunction: + Type: AWS::Serverless::Function + Properties: + Handler: app.handler + Runtime: python3.11 + CodeUri: ./src/ + MemorySize: 128 From ef13c262337139b31b6da4521122364c4f82f5e4 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:15:34 +0000 Subject: [PATCH 02/29] =?UTF-8?q?test(golden):=20normalize()=20=E2=80=94?= =?UTF-8?q?=20deterministic=20YAML=20rendering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strips volatile Metadata.SamTransformMetrics, sort_keys=True, single trailing newline. Used by both the harness and the regenerator so they cannot diverge on serialization. --- tests/golden/normalize.py | 49 +++++++++++++++++++ tests/golden/test_normalize.py | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/golden/normalize.py create mode 100644 tests/golden/test_normalize.py diff --git a/tests/golden/normalize.py b/tests/golden/normalize.py new file mode 100644 index 00000000000..932dd7e9003 --- /dev/null +++ b/tests/golden/normalize.py @@ -0,0 +1,49 @@ +"""Deterministic YAML rendering for golden-template comparison. + +The harness and update_goldens.py both call normalize() so the bytes +written to disk match the bytes the test compares. Determinism rules: + +- Resources / Mappings keys sorted alphabetically. +- Metadata.SamTransformMetrics dropped (varies by run). +- Empty Metadata block dropped. +- yaml.safe_dump with sort_keys=True, default_flow_style=False. +- Single trailing newline. +""" + +from __future__ import annotations + +from typing import Any, Dict + +import yaml + +_VOLATILE_METADATA_KEYS = frozenset({"SamTransformMetrics"}) + + +def _filter_metadata(template: Dict[str, Any]) -> None: + metadata = template.get("Metadata") + if not isinstance(metadata, dict): + return + for key in _VOLATILE_METADATA_KEYS: + metadata.pop(key, None) + if not metadata: + template.pop("Metadata", None) + + +def normalize(template: Dict[str, Any]) -> str: + """Render template to deterministic YAML string.""" + # Mutate a copy so we don't surprise the caller. + template = {k: v for k, v in template.items()} + if isinstance(template.get("Metadata"), dict): + template["Metadata"] = dict(template["Metadata"]) + _filter_metadata(template) + + rendered = yaml.safe_dump( + template, + sort_keys=True, + default_flow_style=False, + width=10**9, # don't wrap long strings + allow_unicode=True, + ) + if not rendered.endswith("\n"): + rendered += "\n" + return rendered diff --git a/tests/golden/test_normalize.py b/tests/golden/test_normalize.py new file mode 100644 index 00000000000..ed4ed64338f --- /dev/null +++ b/tests/golden/test_normalize.py @@ -0,0 +1,87 @@ +"""Unit tests for normalize() — deterministic YAML serialization.""" + +import pytest + +from tests.golden.normalize import normalize + + +def test_normalize_produces_trailing_newline(): + out = normalize({"Resources": {}}) + assert out.endswith("\n") + assert not out.endswith("\n\n") + + +def test_normalize_sorts_resources_keys(): + template = { + "Resources": { + "Zeta": {"Type": "AWS::Lambda::Function"}, + "Alpha": {"Type": "AWS::Lambda::Function"}, + }, + } + out = normalize(template) + assert out.index("Alpha") < out.index("Zeta") + + +def test_normalize_sorts_mappings_keys(): + template = { + "Mappings": { + "Z": {"k": {"v": "1"}}, + "A": {"k": {"v": "1"}}, + }, + } + out = normalize(template) + assert out.index("A:") < out.index("Z:") + + +def test_normalize_drops_sam_transform_metrics(): + template = { + "Metadata": { + "SamTransformMetrics": {"foo": "bar"}, + "Other": "keep", + }, + "Resources": {}, + } + out = normalize(template) + assert "SamTransformMetrics" not in out + assert "Other" in out + + +def test_normalize_drops_metadata_block_if_empty_after_filter(): + template = { + "Metadata": {"SamTransformMetrics": {"foo": "bar"}}, + "Resources": {}, + } + out = normalize(template) + assert "Metadata" not in out + + +def test_normalize_is_idempotent(): + template = {"Resources": {"A": {"Type": "T"}}} + once = normalize(template) + import yaml + twice = normalize(yaml.safe_load(once)) + assert once == twice + + +def test_normalize_preserves_intrinsic_function_dicts(): + template = { + "Resources": { + "F": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": {"ZipFile": {"Fn::Sub": "x-${AWS::Region}"}}, + }, + } + } + } + out = normalize(template) + assert "Fn::Sub" in out + assert "x-${AWS::Region}" in out + + +def test_normalize_uses_block_style_not_flow_style(): + template = {"Resources": {"A": {"Type": "T", "Properties": {"k": "v"}}}} + out = normalize(template) + # block style uses indentation, not braces + assert "{" not in out + assert "}" not in out From 21ac61086eb7221a7f342e7b8fea303f2e924a30 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:41:54 +0000 Subject: [PATCH 03/29] =?UTF-8?q?test(golden):=20run=5Fbuild=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20in-process=20build=20invocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors sam build's pipeline up to the template-write step: LE expansion -> SAM transform -> local-artifact-path normalization. Calls expand_language_extensions and the upstream samtranslator.translator.translator.Translator directly; no subprocess, no AWS calls. --- tests/golden/harness.py | 380 +++++++++++++++++++++++++++++ tests/golden/test_harness_build.py | 44 ++++ 2 files changed, 424 insertions(+) create mode 100644 tests/golden/harness.py create mode 100644 tests/golden/test_harness_build.py diff --git a/tests/golden/harness.py b/tests/golden/harness.py new file mode 100644 index 00000000000..e380d3d278d --- /dev/null +++ b/tests/golden/harness.py @@ -0,0 +1,380 @@ +"""In-process harness for golden-template testing. + +Two pipelines, each implemented as a single function: +- run_build_pipeline: mirrors what `sam build` does to a template. +- run_package_pipeline: mirrors what `sam package` does to a built template. + +Both call SAM-CLI's own entry points (no reimplementation) and substitute +deterministic placeholders for non-deterministic outputs: +- Built artifact paths -> "<>" +- S3 URIs -> "s3://golden-bucket/" + +Compromises (documented inline): + +- The SAM transform path drives the upstream ``samtranslator.translator.translator.Translator`` + directly (the same translator ``SamTemplateValidator.get_translated_template_if_valid`` + invokes). We deliberately do not go through ``SamTranslatorWrapper.run_plugins()`` + because that only runs plugins; it does not convert ``AWS::Serverless::*`` to + the corresponding ``AWS::*`` CloudFormation resources, which is exactly what + the build-time golden output should reflect. +- Managed policies are resolved from an empty map. The sentinel templates (and + most corpus cases) do not reference managed policies by name. If a future case + introduces managed-policy references, extend this harness with an offline + managed-policy mapping. +- Local CodeUri / DefinitionUri / ImageUri values are pre-rewritten to + ``s3://bucket/value`` (matching ``SamTemplateValidator._replace_local_codeuri``) + before invoking the translator, since the translator itself rejects local + paths. The post-translate pass then rewrites those s3 URIs (and any leftover + raw paths) to ``BUILT_ARTIFACT_PLACEHOLDER``. +- Pseudo-parameters are seeded from + ``IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES`` so ``!Sub + ${AWS::Region}`` etc. resolve deterministically. +- When ``language_extensions=False`` and the template carries + ``AWS::LanguageExtensions``, the SAM transform is skipped entirely. The SAM + transform's plugins iterate ``Resources`` expecting each value to be a dict; + an unresolved ``Fn::ForEach::*`` key has a list value and crashes them. This + matches the user-visible behavior of ``sam build`` on such a template + (the LE construct is preserved verbatim for CloudFormation server-side + expansion). +""" + +from __future__ import annotations + +import copy +import hashlib +from pathlib import Path +from typing import Any, Dict, List + +import yaml + +from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions +from samcli.lib.cfn_language_extensions.utils import is_foreach_key +from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable +from samcli.lib.utils.resources import ( + RESOURCES_WITH_IMAGE_COMPONENT, + RESOURCES_WITH_LOCAL_PATHS, +) + +BUILT_ARTIFACT_PLACEHOLDER = "<>" +GOLDEN_BUCKET = "golden-bucket" + + +def _load_template(template_path: Path) -> Dict[str, Any]: + with open(template_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def _walk_artifact_properties(template: Dict[str, Any]) -> List: + """Collect (resource_id, property_path, resource_dict) for every packageable artifact.""" + results = [] + resources = template.get("Resources", {}) or {} + for resource_id, resource in resources.items(): + if not isinstance(resource, dict): + continue + rtype = resource.get("Type") + for prop_paths in ( + RESOURCES_WITH_LOCAL_PATHS.get(rtype, []), + RESOURCES_WITH_IMAGE_COMPONENT.get(rtype, []), + ): + for prop_path in prop_paths: + results.append((resource_id, prop_path, resource)) + return results + + +def _set_at_path(container: Dict[str, Any], path: str, value: Any) -> None: + """Set a JMESPath-style dotted path on container's `Properties`.""" + props = container.setdefault("Properties", {}) + parts = path.split(".") + cur = props + for part in parts[:-1]: + cur = cur.setdefault(part, {}) + cur[parts[-1]] = value + + +def _get_at_path(container: Dict[str, Any], path: str) -> Any: + props = container.get("Properties", {}) + parts = path.split(".") + cur = props + for part in parts: + if not isinstance(cur, dict): + return None + cur = cur.get(part) + return cur + + +# Stub S3 bucket / key the SAM translator sees in place of local CodeUri +# (kept in sync with _stub_local_uris_for_translator). Any artifact property +# matching this shape after SAM transform is the artifact we want to +# normalize to BUILT_ARTIFACT_PLACEHOLDER. +_STUB_S3_BUCKET = "bucket" +_STUB_S3_KEY = "value" + + +def _is_stub_s3_artifact(value: Any) -> bool: + """True if `value` is the {S3Bucket: bucket, S3Key: value} dict the SAM + translator generates from our pre-stubbed local URI.""" + if not isinstance(value, dict): + return False + if value.get("S3Bucket") == _STUB_S3_BUCKET and value.get("S3Key") == _STUB_S3_KEY: + return True + if value.get("Bucket") == _STUB_S3_BUCKET and value.get("Key") == _STUB_S3_KEY: + return True + return False + + +def _replace_local_artifact_paths(template: Dict[str, Any]) -> None: + """Rewrite local artifact-property values to BUILT_ARTIFACT_PLACEHOLDER. + + Two shapes are normalized: + - Plain string values (local paths on raw CFN resources) are replaced + with the placeholder string. + - The post-transform Lambda Code dict ``{S3Bucket: bucket, S3Key: value}`` + — generated by the SAM translator from the pre-stubbed local CodeUri — + is replaced with the placeholder string. + + Intrinsic function dicts (Fn::Sub, Fn::ForEach loop refs, etc.) are left + alone; they are not artifact paths. + """ + for _resource_id, prop_path, resource in _walk_artifact_properties(template): + current = _get_at_path(resource, prop_path) + if isinstance(current, str): + _set_at_path(resource, prop_path, BUILT_ARTIFACT_PLACEHOLDER) + elif _is_stub_s3_artifact(current): + _set_at_path(resource, prop_path, BUILT_ARTIFACT_PLACEHOLDER) + + +def _has_serverless_transform(template: Dict[str, Any]) -> bool: + transforms = template.get("Transform", []) + if isinstance(transforms, str): + transforms = [transforms] + if not isinstance(transforms, list): + return False + for t in transforms: + if isinstance(t, str) and t.startswith("AWS::Serverless"): + return True + return False + + +def _has_unresolved_language_extensions(template: Dict[str, Any]) -> bool: + """True if any top-level Resources key is an Fn::ForEach::* key. + + Such a template is not safe to feed into the SAM translator, whose + plugins expect every Resources value to be a dict. + """ + resources = template.get("Resources", {}) or {} + if not isinstance(resources, dict): + return False + return any(is_foreach_key(k) for k in resources) + + +def _stub_local_uris_for_translator(template: Dict[str, Any]) -> None: + """Pre-rewrite local CodeUri / DefinitionUri / ImageUri to s3 stubs. + + The upstream Translator rejects local paths for these properties. + SAM CLI's ``SamTemplateValidator._replace_local_codeuri`` does the + same rewrite before invoking the Translator. We do it inline here + so the harness can call Translator.translate() directly without + depending on SamTemplateValidator (which also creates a boto Session + and brings in AWS-credential side-effects). + """ + resources = template.get("Resources", {}) or {} + for resource_id, resource in resources.items(): + if is_foreach_key(resource_id) or not isinstance(resource, dict): + continue + rtype = resource.get("Type") + props = resource.get("Properties") + if not isinstance(props, dict): + continue + if rtype == "AWS::Serverless::Function": + if isinstance(props.get("CodeUri"), str): + props["CodeUri"] = "s3://bucket/value" + if isinstance(props.get("ImageUri"), str): + props["ImageUri"] = "111111111111.dkr.ecr.region.amazonaws.com/repository" + elif rtype == "AWS::Serverless::LayerVersion": + if isinstance(props.get("ContentUri"), str): + props["ContentUri"] = "s3://bucket/value" + elif rtype in ( + "AWS::Serverless::Api", + "AWS::Serverless::HttpApi", + "AWS::Serverless::StateMachine", + ): + if isinstance(props.get("DefinitionUri"), str): + props["DefinitionUri"] = "s3://bucket/value" + + +def _run_sam_transform(template: Dict[str, Any], parameter_values: Dict[str, Any]) -> Dict[str, Any]: + """Run the SAM transform on a template if it declares the SAM transform. + + Drives ``samtranslator.translator.translator.Translator.translate()`` directly + so AWS::Serverless::* resources are converted to their AWS::* counterparts. + Templates that don't declare the SAM transform — or that still carry + unresolved Fn::ForEach::* keys (i.e. LE was disabled on an LE template) — + are passed through unchanged. + """ + if not _has_serverless_transform(template): + return template + if _has_unresolved_language_extensions(template): + # Cannot safely run SAM transform on a template with unresolved + # ForEach keys; the translator's plugins iterate Resources expecting + # dicts. Mirror real `sam build` behavior by leaving the template + # untouched in this corner case. + return template + + # Lazy imports keep the import graph small and let import-time errors + # surface in the test that exercises this code path rather than at + # module load time. + from samtranslator.parser.parser import Parser + from samtranslator.translator.translator import Translator + + template_copy = copy.deepcopy(template) + _stub_local_uris_for_translator(template_copy) + + sam_translator = Translator( + managed_policy_map=None, + sam_parser=Parser(), + plugins=[], + boto_session=None, + ) + + return sam_translator.translate( + sam_template=template_copy, + parameter_values=parameter_values, + get_managed_policy_map=lambda: {}, + ) + + +def run_build_pipeline(template_path: Path, language_extensions: bool) -> Dict[str, Any]: + """In-process equivalent of `sam build` for one template. + + 1. Load the template. + 2. Run LE expansion (no-op if disabled or template has no LE transform). + 3. Run the SAM transform on the expanded template. + 4. Replace local artifact paths with BUILT_ARTIFACT_PLACEHOLDER. + """ + template = _load_template(template_path) + + parameter_values = dict(IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES) + + le_result = expand_language_extensions( + template, parameter_values=parameter_values, enabled=language_extensions + ) + expanded = copy.deepcopy(le_result.expanded_template) + + transformed = _run_sam_transform(expanded, parameter_values) + _replace_local_artifact_paths(transformed) + return transformed + + +class GoldenS3Uploader: + """Deterministic S3Uploader stand-in. + + Implements the same interface the package code relies on: + .upload(file_path, prefix) -> s3 URL + .file_exists(...) -> always False (force re-upload, deterministic) + .bucket_name -> "golden-bucket" + """ + + def __init__(self, template_dir: str): + self._template_dir = template_dir + self.bucket_name = GOLDEN_BUCKET + self.no_progressbar = True + + def upload(self, file_name: str, key: str = None) -> str: + # Hash the file content so the URI is content-addressed. + with open(file_name, "rb") as f: + digest = hashlib.sha256(f.read()).hexdigest() + return f"s3://{GOLDEN_BUCKET}/{digest}" + + def upload_with_dedup( + self, file_name: str, extension: str = None, precomputed_md5: str = None + ) -> str: + return self.upload(file_name) + + def file_exists(self, key: str) -> bool: + return False + + def to_path_style_s3_url(self, key: str, version: str = None) -> str: + return f"https://s3.amazonaws.com/{GOLDEN_BUCKET}/{key}" + + def get_version_of_artifact(self, s3_url: str): + return None + + +def run_package_pipeline( + template_path: Path, + build_output: Dict[str, Any], +) -> Dict[str, Any]: + """In-process equivalent of `sam package` for one template + build output. + + 1. Run _export_global_artifacts_pass on the original template (the pre-LE + AWS::Include pass added in PR #9030). + 2. Walk packageable artifact properties on the build output and replace + each local path with an s3://golden-bucket/ URI. + 3. Re-run _export_global_artifacts_pass on the result to handle any + AWS::Include nested in the LE-expanded template. + 4. Return the final dict. + """ + from samcli.lib.package.artifact_exporter import _export_global_artifacts_pass + + template_dir = str(template_path.parent) + uploader = GoldenS3Uploader(template_dir) + + # Pre-LE AWS::Include pass on the original template's structural form. + original = _load_template(template_path) + _export_global_artifacts_pass(original, uploader, template_dir) + + # Walk build_output's artifact properties and rewrite local paths. + pkg = copy.deepcopy(build_output) + _rewrite_artifacts_to_s3(pkg, uploader, template_dir) + + # Post-expansion AWS::Include pass to catch any remaining structural nodes. + _export_global_artifacts_pass(pkg, uploader, template_dir) + return pkg + + +def _rewrite_artifacts_to_s3(template: Dict[str, Any], uploader, template_dir: str) -> None: + """For every packageable artifact property, rewrite local path or + BUILT_ARTIFACT_PLACEHOLDER to an s3:// URI structure. + + The exact replacement shape depends on the resource type. See + ``_packageable_replacement`` for the per-resource-type mapping. This + walker only triggers when the current value is a plain string + (placeholder or path) — intrinsic function dicts (Fn::Sub etc.) survive. + """ + for _resource_id, prop_path, resource in _walk_artifact_properties(template): + current = _get_at_path(resource, prop_path) + # Skip if not a path or placeholder; intrinsic dicts (Fn::Sub) etc. survive. + if not isinstance(current, str): + continue + rtype = resource.get("Type") + replacement = _packageable_replacement(rtype, prop_path, current, template_dir) + _set_at_path(resource, prop_path, replacement) + + +def _packageable_replacement( + rtype: str, prop_path: str, current: Any, template_dir: str +) -> Any: + """Compute the deterministic replacement for an artifact property. + + Returns either a dict ({"S3Bucket": ..., "S3Key": ...}) or a string + ("s3://...") depending on the resource type. Sentinel: hashes + "||" so each case has a stable URI without + needing real file content. + + Shapes covered (extend as new corpus cases require new shapes): + - AWS::Lambda::Function .Code -> {"S3Bucket": ..., "S3Key": ...} + - AWS::Lambda::Function .Code.ImageUri -> "/:" + - AWS::Serverless::Function .CodeUri (post-transform = Lambda::Function .Code, handled above) + - All other artifact properties -> "s3:///" + """ + digest = hashlib.sha256( + f"{current}|{rtype}|{prop_path}".encode("utf-8") + ).hexdigest() + # Lambda image — must check before the .Code dict shape + if rtype == "AWS::Lambda::Function" and prop_path == "Code.ImageUri": + return f"{GOLDEN_BUCKET}.dkr.ecr.us-east-1.amazonaws.com/golden:{digest[:12]}" + # Lambda-style dict + if rtype == "AWS::Lambda::Function" and prop_path == "Code": + return {"S3Bucket": GOLDEN_BUCKET, "S3Key": digest} + # Default: s3:// URI string + return f"s3://{GOLDEN_BUCKET}/{digest}" diff --git a/tests/golden/test_harness_build.py b/tests/golden/test_harness_build.py new file mode 100644 index 00000000000..91f4f4a124e --- /dev/null +++ b/tests/golden/test_harness_build.py @@ -0,0 +1,44 @@ +"""Unit tests for run_build_pipeline.""" + +from pathlib import Path + +import pytest +import yaml + +from tests.golden.harness import run_build_pipeline + +CASES_ROOT = Path(__file__).parent / "templates" + + +def test_build_sam_case_replaces_codeuri_with_placeholder(tmp_path): + case = CASES_ROOT / "sam_resources" / "serverless_function_zip" + result = run_build_pipeline(case / "template.yaml", language_extensions=False) + func = result["Resources"]["HelloFunction"] + # SAM transform converts AWS::Serverless::Function to AWS::Lambda::Function + assert func["Type"] == "AWS::Lambda::Function" + assert func["Properties"]["Code"] == "<>" + + +def test_build_le_case_expands_foreach(tmp_path): + case = CASES_ROOT / "language_extensions" / "foreach_static_zip" + result = run_build_pipeline(case / "template.yaml", language_extensions=True) + assert "AlphaFunction" in result["Resources"] + assert "BetaFunction" in result["Resources"] + # ForEach key gone + assert not any(k.startswith("Fn::ForEach") for k in result["Resources"]) + + +def test_build_le_case_skipped_when_disabled(): + case = CASES_ROOT / "language_extensions" / "foreach_static_zip" + result = run_build_pipeline(case / "template.yaml", language_extensions=False) + # ForEach key preserved + assert any(k.startswith("Fn::ForEach") for k in result["Resources"]) + + +def test_build_cfn_case_replaces_code_path(): + case = CASES_ROOT / "packageable_resources" / "lambda_function_zip" + result = run_build_pipeline(case / "template.yaml", language_extensions=False) + func = result["Resources"]["HelloFunction"] + # Build pipeline does not rewrite raw CFN Lambda Code paths (that's package's job) + # but it does load the template through SAM's parser, so verify shape. + assert func["Type"] == "AWS::Lambda::Function" From 972d0c7fad5d2a68e9e5cd8c0421242898c0b168 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:42:19 +0000 Subject: [PATCH 04/29] =?UTF-8?q?test(golden):=20run=5Fpackage=5Fpipeline?= =?UTF-8?q?=20=E2=80=94=20in-process=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks packageable artifact properties (using RESOURCES_WITH_LOCAL_PATHS + RESOURCES_WITH_IMAGE_COMPONENT) and rewrites each to a deterministic s3://golden-bucket/. Runs _export_global_artifacts_pass before and after rewriting to mirror PR #9030's pre-LE AWS::Include pass. --- tests/golden/test_harness_package.py | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/golden/test_harness_package.py diff --git a/tests/golden/test_harness_package.py b/tests/golden/test_harness_package.py new file mode 100644 index 00000000000..03d1512f79f --- /dev/null +++ b/tests/golden/test_harness_package.py @@ -0,0 +1,47 @@ +"""Unit tests for run_package_pipeline.""" + +from pathlib import Path + +import pytest + +from tests.golden.harness import run_build_pipeline, run_package_pipeline + +CASES_ROOT = Path(__file__).parent / "templates" + + +def test_package_sam_case_rewrites_code_to_s3_uri(): + case = CASES_ROOT / "sam_resources" / "serverless_function_zip" + build_out = run_build_pipeline(case / "template.yaml", language_extensions=False) + pkg_out = run_package_pipeline(case / "template.yaml", build_out) + code = pkg_out["Resources"]["HelloFunction"]["Properties"]["Code"] + assert isinstance(code, dict) # CFN Lambda Code becomes S3Bucket/S3Key dict + s3_uri_field = code.get("S3Bucket") or code.get("ImageUri") or "" + assert s3_uri_field == "golden-bucket" + + +def test_package_le_case_rewrites_each_expanded_codeuri(): + case = CASES_ROOT / "language_extensions" / "foreach_static_zip" + build_out = run_build_pipeline(case / "template.yaml", language_extensions=True) + pkg_out = run_package_pipeline(case / "template.yaml", build_out) + for fn_id in ("AlphaFunction", "BetaFunction"): + code = pkg_out["Resources"][fn_id]["Properties"]["Code"] + assert isinstance(code, dict) + assert code.get("S3Bucket") == "golden-bucket" + + +def test_package_cfn_case_rewrites_raw_code_path(): + case = CASES_ROOT / "packageable_resources" / "lambda_function_zip" + build_out = run_build_pipeline(case / "template.yaml", language_extensions=False) + pkg_out = run_package_pipeline(case / "template.yaml", build_out) + code = pkg_out["Resources"]["HelloFunction"]["Properties"]["Code"] + assert isinstance(code, dict) + assert code.get("S3Bucket") == "golden-bucket" + + +def test_package_is_deterministic(): + """Same input produces same output across runs.""" + case = CASES_ROOT / "sam_resources" / "serverless_function_zip" + build_out = run_build_pipeline(case / "template.yaml", language_extensions=False) + a = run_package_pipeline(case / "template.yaml", build_out) + b = run_package_pipeline(case / "template.yaml", build_out) + assert a == b From 5c72dd89e76db8e72473629a13a384ee25302572 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:44:08 +0000 Subject: [PATCH 05/29] test(golden): update_goldens.py CLI + initial pinned outputs CLI supports --filter / --check / --diff / --new. Dry-run modes never mutate disk; --new only writes missing files. Three sentinel cases have expected.{build,package}.yaml pinned. --- .../foreach_static_zip/expected.build.yaml | 62 +++++++++ .../foreach_static_zip/expected.package.yaml | 66 +++++++++ .../lambda_function_zip/expected.build.yaml | 11 ++ .../lambda_function_zip/expected.package.yaml | 13 ++ .../expected.build.yaml | 33 +++++ .../expected.package.yaml | 35 +++++ tests/golden/test_update_goldens.py | 90 ++++++++++++ tests/golden/update_goldens.py | 129 ++++++++++++++++++ 8 files changed, 439 insertions(+) create mode 100644 tests/golden/templates/language_extensions/foreach_static_zip/expected.build.yaml create mode 100644 tests/golden/templates/language_extensions/foreach_static_zip/expected.package.yaml create mode 100644 tests/golden/templates/packageable_resources/lambda_function_zip/expected.build.yaml create mode 100644 tests/golden/templates/packageable_resources/lambda_function_zip/expected.package.yaml create mode 100644 tests/golden/templates/sam_resources/serverless_function_zip/expected.build.yaml create mode 100644 tests/golden/templates/sam_resources/serverless_function_zip/expected.package.yaml create mode 100644 tests/golden/test_update_goldens.py create mode 100644 tests/golden/update_goldens.py diff --git a/tests/golden/templates/language_extensions/foreach_static_zip/expected.build.yaml b/tests/golden/templates/language_extensions/foreach_static_zip/expected.build.yaml new file mode 100644 index 00000000000..733c701fe29 --- /dev/null +++ b/tests/golden/templates/language_extensions/foreach_static_zip/expected.build.yaml @@ -0,0 +1,62 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + AlphaFunction: + Properties: + Code: <> + Handler: app.handler + Role: + Fn::GetAtt: + - AlphaFunctionRole + - Arn + Runtime: python3.11 + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::Lambda::Function + AlphaFunctionRole: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Version: '2012-10-17' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::IAM::Role + BetaFunction: + Properties: + Code: <> + Handler: app.handler + Role: + Fn::GetAtt: + - BetaFunctionRole + - Arn + Runtime: python3.11 + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::Lambda::Function + BetaFunctionRole: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Version: '2012-10-17' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::IAM::Role diff --git a/tests/golden/templates/language_extensions/foreach_static_zip/expected.package.yaml b/tests/golden/templates/language_extensions/foreach_static_zip/expected.package.yaml new file mode 100644 index 00000000000..199bc966b71 --- /dev/null +++ b/tests/golden/templates/language_extensions/foreach_static_zip/expected.package.yaml @@ -0,0 +1,66 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + AlphaFunction: + Properties: + Code: + S3Bucket: golden-bucket + S3Key: cbe90f4157bf2048dddade15fccab9bb6ea1ff37413fe0f78b7543e206e8910d + Handler: app.handler + Role: + Fn::GetAtt: + - AlphaFunctionRole + - Arn + Runtime: python3.11 + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::Lambda::Function + AlphaFunctionRole: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Version: '2012-10-17' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::IAM::Role + BetaFunction: + Properties: + Code: + S3Bucket: golden-bucket + S3Key: cbe90f4157bf2048dddade15fccab9bb6ea1ff37413fe0f78b7543e206e8910d + Handler: app.handler + Role: + Fn::GetAtt: + - BetaFunctionRole + - Arn + Runtime: python3.11 + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::Lambda::Function + BetaFunctionRole: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Version: '2012-10-17' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::IAM::Role diff --git a/tests/golden/templates/packageable_resources/lambda_function_zip/expected.build.yaml b/tests/golden/templates/packageable_resources/lambda_function_zip/expected.build.yaml new file mode 100644 index 00000000000..82ea64816af --- /dev/null +++ b/tests/golden/templates/packageable_resources/lambda_function_zip/expected.build.yaml @@ -0,0 +1,11 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + HelloFunction: + Properties: + Code: <> + FunctionName: hello + Handler: app.handler + MemorySize: 128 + Role: arn:aws:iam::123456789012:role/lambda-role + Runtime: python3.11 + Type: AWS::Lambda::Function diff --git a/tests/golden/templates/packageable_resources/lambda_function_zip/expected.package.yaml b/tests/golden/templates/packageable_resources/lambda_function_zip/expected.package.yaml new file mode 100644 index 00000000000..c98f49970c0 --- /dev/null +++ b/tests/golden/templates/packageable_resources/lambda_function_zip/expected.package.yaml @@ -0,0 +1,13 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + HelloFunction: + Properties: + Code: + S3Bucket: golden-bucket + S3Key: cbe90f4157bf2048dddade15fccab9bb6ea1ff37413fe0f78b7543e206e8910d + FunctionName: hello + Handler: app.handler + MemorySize: 128 + Role: arn:aws:iam::123456789012:role/lambda-role + Runtime: python3.11 + Type: AWS::Lambda::Function diff --git a/tests/golden/templates/sam_resources/serverless_function_zip/expected.build.yaml b/tests/golden/templates/sam_resources/serverless_function_zip/expected.build.yaml new file mode 100644 index 00000000000..16f7aef5b8e --- /dev/null +++ b/tests/golden/templates/sam_resources/serverless_function_zip/expected.build.yaml @@ -0,0 +1,33 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + HelloFunction: + Properties: + Code: <> + Handler: app.handler + MemorySize: 128 + Role: + Fn::GetAtt: + - HelloFunctionRole + - Arn + Runtime: python3.11 + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::Lambda::Function + HelloFunctionRole: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Version: '2012-10-17' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::IAM::Role diff --git a/tests/golden/templates/sam_resources/serverless_function_zip/expected.package.yaml b/tests/golden/templates/sam_resources/serverless_function_zip/expected.package.yaml new file mode 100644 index 00000000000..4a29ab7501c --- /dev/null +++ b/tests/golden/templates/sam_resources/serverless_function_zip/expected.package.yaml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: '2010-09-09' +Resources: + HelloFunction: + Properties: + Code: + S3Bucket: golden-bucket + S3Key: cbe90f4157bf2048dddade15fccab9bb6ea1ff37413fe0f78b7543e206e8910d + Handler: app.handler + MemorySize: 128 + Role: + Fn::GetAtt: + - HelloFunctionRole + - Arn + Runtime: python3.11 + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::Lambda::Function + HelloFunctionRole: + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Version: '2012-10-17' + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Tags: + - Key: lambda:createdBy + Value: SAM + Type: AWS::IAM::Role diff --git a/tests/golden/test_update_goldens.py b/tests/golden/test_update_goldens.py new file mode 100644 index 00000000000..03b7e6ae107 --- /dev/null +++ b/tests/golden/test_update_goldens.py @@ -0,0 +1,90 @@ +"""Unit tests for update_goldens.py CLI behavior.""" + +import shutil +from pathlib import Path + +import pytest + +from tests.golden import update_goldens + +REPO_CASES = Path(__file__).parent / "templates" + + +@pytest.fixture +def isolated_corpus(tmp_path, monkeypatch): + """Copy the real corpus to tmp_path and point the CLI at it.""" + dest = tmp_path / "templates" + shutil.copytree(REPO_CASES, dest) + monkeypatch.setattr(update_goldens, "TEMPLATES_ROOT", dest) + return dest + + +def test_default_regenerates_all_cases(isolated_corpus): + # Wipe pinned files first, then regenerate + for p in isolated_corpus.rglob("expected.*.yaml"): + p.unlink() + rc = update_goldens.main([]) + assert rc == 0 + # Every case dir now has both expected files + for case_dir in (p.parent for p in isolated_corpus.rglob("template.yaml")): + assert (case_dir / "expected.build.yaml").exists() + assert (case_dir / "expected.package.yaml").exists() + + +def test_filter_only_regenerates_matching(isolated_corpus): + # Pre-populate by running a full regen + update_goldens.main([]) + sam_case = isolated_corpus / "sam_resources" / "serverless_function_zip" + le_case = isolated_corpus / "language_extensions" / "foreach_static_zip" + (sam_case / "expected.build.yaml").write_text("STALE\n") + rc = update_goldens.main(["--filter", "language_extensions/*"]) + assert rc == 0 + # SAM case stays stale + assert (sam_case / "expected.build.yaml").read_text() == "STALE\n" + # LE case regenerated + assert (le_case / "expected.build.yaml").read_text() != "STALE\n" + + +def test_check_returns_nonzero_when_diff(isolated_corpus): + update_goldens.main([]) # pin everything fresh + sam_case = isolated_corpus / "sam_resources" / "serverless_function_zip" + (sam_case / "expected.build.yaml").write_text("STALE\n") + rc = update_goldens.main(["--check"]) + assert rc != 0 + # File NOT modified by --check + assert (sam_case / "expected.build.yaml").read_text() == "STALE\n" + + +def test_check_returns_zero_when_clean(isolated_corpus): + update_goldens.main([]) + rc = update_goldens.main(["--check"]) + assert rc == 0 + + +def test_diff_prints_unified_diff(isolated_corpus, capsys): + update_goldens.main([]) + sam_case = isolated_corpus / "sam_resources" / "serverless_function_zip" + (sam_case / "expected.build.yaml").write_text("STALE\n") + rc = update_goldens.main(["--diff"]) + assert rc != 0 + out = capsys.readouterr().out + assert "STALE" in out + assert "---" in out and "+++" in out + + +def test_new_only_writes_missing_pins(isolated_corpus): + sam_case = isolated_corpus / "sam_resources" / "serverless_function_zip" + # Pre-pin everything + update_goldens.main([]) + # Wipe just one file + (sam_case / "expected.build.yaml").unlink() + # Add an unrelated stale modification to a *different* case + le_case = isolated_corpus / "language_extensions" / "foreach_static_zip" + le_pin = le_case / "expected.build.yaml" + le_pin.write_text("STALE\n") + rc = update_goldens.main(["--new"]) + assert rc == 0 + # SAM case now has the file back + assert (sam_case / "expected.build.yaml").exists() + # LE case stays stale (--new does not touch existing pins) + assert le_pin.read_text() == "STALE\n" diff --git a/tests/golden/update_goldens.py b/tests/golden/update_goldens.py new file mode 100644 index 00000000000..ad81724dfde --- /dev/null +++ b/tests/golden/update_goldens.py @@ -0,0 +1,129 @@ +"""CLI for regenerating, checking, or diffing pinned golden outputs. + +Usage: + python tests/golden/update_goldens.py # regenerate all + python tests/golden/update_goldens.py --filter 'sam_*/*' # subset + python tests/golden/update_goldens.py --check # dry-run, exit 1 on diff + python tests/golden/update_goldens.py --diff # like --check + show unified diff + python tests/golden/update_goldens.py --new # only write missing pins +""" + +from __future__ import annotations + +import argparse +import difflib +import fnmatch +import sys +from pathlib import Path +from typing import Iterable, List, Optional, Tuple + +import yaml + +from tests.golden.harness import run_build_pipeline, run_package_pipeline +from tests.golden.normalize import normalize + +TEMPLATES_ROOT = Path(__file__).parent / "templates" + + +def _read_metadata(case_dir: Path) -> dict: + md = case_dir / "metadata.yaml" + if not md.exists(): + return {} + with open(md, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def _resolve_le_default(case_dir: Path, meta: dict) -> bool: + if "language_extensions" in meta: + return bool(meta["language_extensions"]) + rel = case_dir.relative_to(TEMPLATES_ROOT) + return rel.parts[0] == "language_extensions" + + +def _generate(case_dir: Path) -> Tuple[str, str]: + """Return (build_yaml, package_yaml) strings for one case.""" + meta = _read_metadata(case_dir) + le_enabled = _resolve_le_default(case_dir, meta) + build_dict = run_build_pipeline(case_dir / "template.yaml", language_extensions=le_enabled) + pkg_dict = run_package_pipeline(case_dir / "template.yaml", build_dict) + return normalize(build_dict), normalize(pkg_dict) + + +def _iter_cases(filter_glob: Optional[str]) -> Iterable[Path]: + cases = sorted(p.parent for p in TEMPLATES_ROOT.rglob("template.yaml")) + if not filter_glob: + yield from cases + return + for c in cases: + rel = str(c.relative_to(TEMPLATES_ROOT)) + if fnmatch.fnmatch(rel, filter_glob): + yield c + + +def _existing(case_dir: Path, kind: str) -> Optional[str]: + p = case_dir / f"expected.{kind}.yaml" + return p.read_text(encoding="utf-8") if p.exists() else None + + +def _print_diff(case_rel: str, kind: str, current: Optional[str], generated: str) -> None: + cur_lines = (current or "").splitlines(keepends=True) + gen_lines = generated.splitlines(keepends=True) + diff = difflib.unified_diff( + cur_lines, + gen_lines, + fromfile=f"a/{case_rel}/expected.{kind}.yaml", + tofile=f"b/{case_rel}/expected.{kind}.yaml", + lineterm="", + ) + for line in diff: + print(line, end="" if line.endswith("\n") else "\n") + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--filter", help="glob applied to case path relative to templates/") + parser.add_argument("--check", action="store_true", help="dry-run; exit 1 on any diff") + parser.add_argument("--diff", action="store_true", help="like --check + print unified diff") + parser.add_argument( + "--new", + action="store_true", + help="only write missing expected.*.yaml; never overwrite existing", + ) + args = parser.parse_args(argv) + + if args.check and args.new: + parser.error("--check and --new are mutually exclusive") + if args.diff and args.new: + parser.error("--diff and --new are mutually exclusive") + + dry_run = args.check or args.diff + any_diff = False + + for case_dir in _iter_cases(args.filter): + case_rel = str(case_dir.relative_to(TEMPLATES_ROOT)) + build_yaml, pkg_yaml = _generate(case_dir) + + for kind, generated in (("build", build_yaml), ("package", pkg_yaml)): + existing = _existing(case_dir, kind) + if args.new: + if existing is None: + (case_dir / f"expected.{kind}.yaml").write_text(generated, encoding="utf-8") + continue + if dry_run: + if existing != generated: + any_diff = True + if args.diff: + _print_diff(case_rel, kind, existing, generated) + else: + print(f"WOULD CHANGE: {case_rel}/expected.{kind}.yaml") + continue + if existing != generated: + (case_dir / f"expected.{kind}.yaml").write_text(generated, encoding="utf-8") + + if dry_run and any_diff: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From ac2771bafbe43fd668bf77ce603e98a94dc30467 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:45:17 +0000 Subject: [PATCH 06/29] test(golden): parametrized corpus tests with actionable hints One parametrized test per case for build + package output. Mismatch hint points at update_goldens.py and explains the semver gate's expectations. --- tests/golden/conftest.py | 21 ++++++++++++++++ tests/golden/test_corpus.py | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/golden/conftest.py create mode 100644 tests/golden/test_corpus.py diff --git a/tests/golden/conftest.py b/tests/golden/conftest.py new file mode 100644 index 00000000000..9472db7d305 --- /dev/null +++ b/tests/golden/conftest.py @@ -0,0 +1,21 @@ +"""Pytest fixtures for the golden-template corpus. + +Each case directory under tests/golden/templates/ becomes one parametrized +test ID (relative path), enabling `pytest -k ` selection. +""" + +from pathlib import Path + +import pytest + +TEMPLATES_ROOT = Path(__file__).parent / "templates" + + +def pytest_generate_tests(metafunc): + if "golden_case" in metafunc.fixturenames: + cases = sorted(p.parent for p in TEMPLATES_ROOT.rglob("template.yaml")) + metafunc.parametrize( + "golden_case", + cases, + ids=lambda p: str(p.relative_to(TEMPLATES_ROOT)), + ) diff --git a/tests/golden/test_corpus.py b/tests/golden/test_corpus.py new file mode 100644 index 00000000000..265e391d07f --- /dev/null +++ b/tests/golden/test_corpus.py @@ -0,0 +1,48 @@ +"""Golden corpus: byte-exact diff for build + package output per case.""" + +from pathlib import Path + +import pytest + +from tests.golden.harness import run_build_pipeline, run_package_pipeline +from tests.golden.normalize import normalize +from tests.golden.update_goldens import _read_metadata, _resolve_le_default + +TEMPLATES_ROOT = Path(__file__).parent / "templates" + + +def _hint(case_dir: Path) -> str: + rel = case_dir.relative_to(TEMPLATES_ROOT) + return ( + f"\nGolden mismatch in {rel}.\n" + f"To inspect: python tests/golden/update_goldens.py --diff --filter '{rel}'\n" + f"To re-pin: python tests/golden/update_goldens.py --filter '{rel}'\n" + f"If intentional:\n" + f" - new case (added expected.*.yaml) -> no version bump at PR time\n" + f" - modified/deleted existing expected.* -> bump major in samcli/__init__.py\n" + ) + + +def test_build_output_matches_golden(golden_case): + meta = _read_metadata(golden_case) + le_enabled = _resolve_le_default(golden_case, meta) + actual = normalize( + run_build_pipeline(golden_case / "template.yaml", language_extensions=le_enabled) + ) + expected_path = golden_case / "expected.build.yaml" + assert expected_path.exists(), f"missing {expected_path}; run update_goldens.py --new" + expected = expected_path.read_text(encoding="utf-8") + assert actual == expected, _hint(golden_case) + + +def test_package_output_matches_golden(golden_case): + meta = _read_metadata(golden_case) + le_enabled = _resolve_le_default(golden_case, meta) + build_out = run_build_pipeline( + golden_case / "template.yaml", language_extensions=le_enabled + ) + actual = normalize(run_package_pipeline(golden_case / "template.yaml", build_out)) + expected_path = golden_case / "expected.package.yaml" + assert expected_path.exists(), f"missing {expected_path}; run update_goldens.py --new" + expected = expected_path.read_text(encoding="utf-8") + assert actual == expected, _hint(golden_case) From 80015735b368f4b8c3aea09ad507e68ee052b71a Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:46:10 +0000 Subject: [PATCH 07/29] test(golden): semver gate enforcing major bump on pin modify/delete Pure-function check() takes a changed-files list + base/head versions and returns (exit_code, message). git interaction lives behind a thin CLI shim that reads git diff --name-status and pulls __version__ from both refs. --- tests/golden/check_semver_bump.py | 129 +++++++++++++++++++++++++ tests/golden/test_check_semver_bump.py | 100 +++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 tests/golden/check_semver_bump.py create mode 100644 tests/golden/test_check_semver_bump.py diff --git a/tests/golden/check_semver_bump.py b/tests/golden/check_semver_bump.py new file mode 100644 index 00000000000..f29c2cac9e5 --- /dev/null +++ b/tests/golden/check_semver_bump.py @@ -0,0 +1,129 @@ +"""Semver gate: require a major version bump when an existing pinned +golden output is modified or deleted. + +Usage (CI): + python tests/golden/check_semver_bump.py --base origin/develop --head HEAD +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import List, Optional, Tuple + +EXPECTED_GLOB = re.compile(r"^tests/golden/templates/.+/expected\.(build|package)\.yaml$") + + +@dataclass(frozen=True) +class Change: + path: str + status: str # "A" added, "M" modified, "D" deleted + + +def _parse_version(s: str) -> Tuple[int, int, int]: + m = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:.*)?", s.strip()) + if not m: + raise ValueError(f"unparseable version: {s!r}") + return int(m.group(1)), int(m.group(2)), int(m.group(3)) + + +def _is_corpus_pin(path: str) -> bool: + return bool(EXPECTED_GLOB.match(path)) + + +def check( + changed: List[Change], + base_version: str, + head_version: str, +) -> Tuple[int, str]: + """Return (exit_code, message).""" + relevant = [c for c in changed if _is_corpus_pin(c.path)] + + if not relevant: + return 0, "No corpus pin changes; gate passes." + + modifications = [c for c in relevant if c.status == "M"] + deletions = [c for c in relevant if c.status == "D"] + additions = [c for c in relevant if c.status == "A"] + + if not (modifications or deletions): + # Additions only — no bump required. + return 0, f"{len(additions)} new pin(s); no version bump required." + + base_major, _, _ = _parse_version(base_version) + head_major, _, _ = _parse_version(head_version) + + if head_major > base_major: + return 0, "Major version bumped; gate passes." + + suggested = f"{base_major + 1}.0.0" + summary_lines = [ + "Semver gate FAILED.", + f" base version: {base_version}", + f" head version: {head_version}", + f" required: major bump (suggested {suggested})", + "", + "Reason: an existing pinned golden output was modified or deleted.", + " Modifications:", + *[f" M {c.path}" for c in modifications], + " Deletions:", + *[f" D {c.path}" for c in deletions], + "", + f'To fix: edit samcli/__init__.py and set __version__ = "{suggested}".', + "If the change is intentional, the major bump signals that to consumers.", + "If unintentional, run python tests/golden/update_goldens.py --diff to inspect.", + ] + return 1, "\n".join(summary_lines) + + +def _read_version_at_ref(ref: str) -> str: + """Read __version__ from samcli/__init__.py at a git ref.""" + out = subprocess.check_output( + ["git", "show", f"{ref}:samcli/__init__.py"], text=True + ) + m = re.search(r'__version__\s*=\s*"([^"]+)"', out) + if not m: + raise RuntimeError(f"cannot find __version__ at {ref}") + return m.group(1) + + +def _git_changed_files(base: str, head: str) -> List[Change]: + """Return all files changed between base and head with their git status.""" + out = subprocess.check_output( + ["git", "diff", "--name-status", f"{base}...{head}"], text=True + ) + changes: List[Change] = [] + for line in out.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + status, path = parts[0], parts[-1] + # Renames present as "R\tOLD\tNEW" — split into D + A for our purposes. + if status.startswith("R") and len(parts) == 3: + old, new = parts[1], parts[2] + changes.append(Change(old, "D")) + changes.append(Change(new, "A")) + else: + changes.append(Change(path, status[0])) + return changes + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", required=True, help="git ref of merge base / target branch") + parser.add_argument("--head", required=True, help="git ref of PR head") + args = parser.parse_args(argv) + + changed = _git_changed_files(args.base, args.head) + base_version = _read_version_at_ref(args.base) + head_version = _read_version_at_ref(args.head) + rc, msg = check(changed, base_version, head_version) + print(msg) + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/golden/test_check_semver_bump.py b/tests/golden/test_check_semver_bump.py new file mode 100644 index 00000000000..3b95ae40733 --- /dev/null +++ b/tests/golden/test_check_semver_bump.py @@ -0,0 +1,100 @@ +"""Unit tests for check_semver_bump.py — the semver-gate logic. + +Avoids actual git invocation by passing changed-files lists directly to +the lower-level entrypoint. +""" + +import pytest + +from tests.golden import check_semver_bump as csb + + +def test_no_changes_passes(): + rc, msg = csb.check( + changed=[], + base_version="1.161.1", + head_version="1.161.1", + ) + assert rc == 0 + + +def test_addition_only_does_not_require_bump(): + rc, msg = csb.check( + changed=[ + csb.Change("tests/golden/templates/x/case_a/expected.build.yaml", "A"), + ], + base_version="1.161.1", + head_version="1.161.1", + ) + assert rc == 0 + + +def test_modification_without_major_bump_fails(): + rc, msg = csb.check( + changed=[ + csb.Change("tests/golden/templates/x/case_a/expected.build.yaml", "M"), + ], + base_version="1.161.1", + head_version="1.161.2", + ) + assert rc != 0 + assert "major" in msg.lower() + assert "2.0.0" in msg # suggested next version + + +def test_modification_with_major_bump_passes(): + rc, msg = csb.check( + changed=[ + csb.Change("tests/golden/templates/x/case_a/expected.build.yaml", "M"), + ], + base_version="1.161.1", + head_version="2.0.0", + ) + assert rc == 0 + + +def test_deletion_requires_major_bump(): + rc, msg = csb.check( + changed=[ + csb.Change("tests/golden/templates/x/case_a/expected.build.yaml", "D"), + ], + base_version="1.161.1", + head_version="1.162.0", + ) + assert rc != 0 + + +def test_rename_treated_as_delete_plus_add(): + rc, msg = csb.check( + changed=[ + csb.Change("tests/golden/templates/x/old/expected.build.yaml", "D"), + csb.Change("tests/golden/templates/x/new/expected.build.yaml", "A"), + ], + base_version="1.161.1", + head_version="1.161.2", + ) + # The deletion alone forces major; rename is conservative. + assert rc != 0 + + +def test_changes_outside_corpus_ignored(): + rc, msg = csb.check( + changed=[ + csb.Change("samcli/lib/foo.py", "M"), + csb.Change("tests/golden/harness.py", "M"), + ], + base_version="1.161.1", + head_version="1.161.1", + ) + assert rc == 0 + + +def test_template_yaml_changes_ignored_only_expected_yaml_gates(): + rc, msg = csb.check( + changed=[ + csb.Change("tests/golden/templates/x/case_a/template.yaml", "M"), + ], + base_version="1.161.1", + head_version="1.161.1", + ) + assert rc == 0 From dde83738c27e44fae674e62a47abee1c33fa564a Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:46:32 +0000 Subject: [PATCH 08/29] ci(golden): standalone workflow runs the semver gate on PRs Triggered only when corpus pins or samcli/__init__.py change. Tiny, auditable, isolated from build.yml so it can be reasoned about independently. --- .github/workflows/golden-semver-gate.yml | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/golden-semver-gate.yml diff --git a/.github/workflows/golden-semver-gate.yml b/.github/workflows/golden-semver-gate.yml new file mode 100644 index 00000000000..e9611c95023 --- /dev/null +++ b/.github/workflows/golden-semver-gate.yml @@ -0,0 +1,37 @@ +name: Golden Templates Semver Gate + +# Always run on PRs to develop / feat branches. The check_semver_bump.py +# script self-gates: it returns exit 0 when no expected.*.yaml files +# changed, so an unconditional run is cheap and posts a definitive +# status on every PR. Do NOT use `on.pull_request.paths:` filtering — +# path filtering skips the whole workflow when no path matches, which +# means no status is posted, which means branch protection treats a +# required check as missing/pending and blocks the PR. Self-gating in +# the script is the canonical fix for the "skipped but required check" +# pitfall. +on: + pull_request: + branches: + - develop + - "feat/*" + - "feat-*" + +permissions: + contents: read + +jobs: + semver-gate: + if: github.repository_owner == 'aws' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: "3.11" + - name: Run semver gate + run: | + python tests/golden/check_semver_bump.py \ + --base "origin/${{ github.base_ref }}" \ + --head HEAD From e8b475d0bb61600f4cf2a852c47eba6cd54635b2 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:46:48 +0000 Subject: [PATCH 09/29] chore(make): include tests/golden in test and test-all targets Separate pytest invocation so the corpus harness doesn't enter the samcli coverage calculation. --- Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Makefile b/Makefile index 88ab0294b3d..39ee5b7be1e 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,9 @@ test: # Run unit tests (excluding cfn_language_extensions) and fail if coverage falls below 94% @echo "NOTE: Excluding cfn_language_extensions tests. Use 'make test-all' for full coverage." pytest --cov samcli --cov schema --cov-report term-missing --cov-fail-under 94 tests/unit --ignore=tests/unit/lib/cfn_language_extensions --cov-config=.coveragerc_no_lang_ext + # Golden-template suite runs as a separate pytest invocation so it + # does not enter the samcli coverage calculation above. + pytest tests/golden test-lang-ext: # Run cfn_language_extensions unit tests with coverage @@ -37,6 +40,9 @@ test-lang-ext: test-all: # Run all unit tests including cfn_language_extensions pytest --cov samcli --cov schema --cov-report term-missing --cov-fail-under 94 tests/unit + # Golden-template suite runs as a separate pytest invocation so it + # does not enter the samcli coverage calculation above. + pytest tests/golden test-cov-report: # Run all unit tests with html coverage report From eb1f954e0d431368148b5ddc4d170ac82f967ec3 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:47:14 +0000 Subject: [PATCH 10/29] docs(golden): authoring guide for new and existing cases --- tests/golden/README.md | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/golden/README.md diff --git a/tests/golden/README.md b/tests/golden/README.md new file mode 100644 index 00000000000..047a6c58047 --- /dev/null +++ b/tests/golden/README.md @@ -0,0 +1,65 @@ +# Golden Templates + +Pinned outputs of `sam build` and `sam package` for representative templates. +Catches output-shape regressions in SAM-CLI's local pipeline. See +[`designs/golden-templates-and-semver-check.md`](../../designs/golden-templates-and-semver-check.md) +for the full design. + +## Layout + +``` +tests/golden/ +├── templates/ +│ ├── sam_resources// # AWS::Serverless::* coverage +│ ├── packageable_resources// # Raw CFN resource coverage +│ ├── language_extensions// # Fn::ForEach et al +│ └── cross_cutting// # Nested stacks, AWS::Include, etc. +└── ... (harness code) +``` + +Each case directory contains: +- `template.yaml` — the input template. +- `metadata.yaml` — `language_extensions: bool`, `description`, `issue_refs`. +- `src/` — any source files referenced by `CodeUri` etc. +- `expected.build.yaml` — pinned post-build template (regenerated by harness). +- `expected.package.yaml` — pinned post-package template. +- `README.md` — what this case covers and why it's pinned. + +## Adding a new case + +1. Create the case directory and author `template.yaml`, `metadata.yaml`, `src/`, `README.md`. +2. Generate the pinned outputs: + ``` + python tests/golden/update_goldens.py --new --filter '/' + ``` +3. Eyeball `expected.{build,package}.yaml`. Confirm the shape is what you expect. +4. Commit everything together. + +The semver gate treats new cases as additions and does not require a version bump. + +## Re-pinning an existing case + +If a SAM-CLI behavior change intentionally alters the expanded output: + +``` +python tests/golden/update_goldens.py --filter '/' +``` + +Review the diff in your IDE before committing. **Modifying or deleting an +existing pin requires a major version bump in `samcli/__init__.py` in the +same PR**, enforced by `.github/workflows/golden-semver-gate.yml`. + +## Inspecting differences + +``` +python tests/golden/update_goldens.py --diff --filter '' # show would-be changes +python tests/golden/update_goldens.py --check # exit 1 if anything would change +``` + +## Running the suite + +``` +pytest tests/golden # all cases +pytest tests/golden -k foreach_static # one case +make test-all # part of make pr +``` From 61592efa1df4eced50b936815f293a91dcf01147 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 02:49:57 +0000 Subject: [PATCH 11/29] chore(golden): satisfy mypy on harness.py - Type-narrow rtype before passing to RESOURCES_WITH_LOCAL_PATHS.get. - Annotate Translator.translate() return so harness's run_build_pipeline return type is preserved (no_any_return). - Use Optional[str] = None on GoldenS3Uploader keyword args (PEP 484 no-implicit-optional). --- tests/golden/harness.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/golden/harness.py b/tests/golden/harness.py index e380d3d278d..eed7dfa70c4 100644 --- a/tests/golden/harness.py +++ b/tests/golden/harness.py @@ -43,7 +43,7 @@ import copy import hashlib from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import yaml @@ -72,6 +72,8 @@ def _walk_artifact_properties(template: Dict[str, Any]) -> List: if not isinstance(resource, dict): continue rtype = resource.get("Type") + if not isinstance(rtype, str): + continue for prop_paths in ( RESOURCES_WITH_LOCAL_PATHS.get(rtype, []), RESOURCES_WITH_IMAGE_COMPONENT.get(rtype, []), @@ -236,11 +238,12 @@ def _run_sam_transform(template: Dict[str, Any], parameter_values: Dict[str, Any boto_session=None, ) - return sam_translator.translate( + translated: Dict[str, Any] = sam_translator.translate( sam_template=template_copy, parameter_values=parameter_values, get_managed_policy_map=lambda: {}, ) + return translated def run_build_pipeline(template_path: Path, language_extensions: bool) -> Dict[str, Any]: @@ -279,21 +282,24 @@ def __init__(self, template_dir: str): self.bucket_name = GOLDEN_BUCKET self.no_progressbar = True - def upload(self, file_name: str, key: str = None) -> str: + def upload(self, file_name: str, key: Optional[str] = None) -> str: # Hash the file content so the URI is content-addressed. with open(file_name, "rb") as f: digest = hashlib.sha256(f.read()).hexdigest() return f"s3://{GOLDEN_BUCKET}/{digest}" def upload_with_dedup( - self, file_name: str, extension: str = None, precomputed_md5: str = None + self, + file_name: str, + extension: Optional[str] = None, + precomputed_md5: Optional[str] = None, ) -> str: return self.upload(file_name) def file_exists(self, key: str) -> bool: return False - def to_path_style_s3_url(self, key: str, version: str = None) -> str: + def to_path_style_s3_url(self, key: str, version: Optional[str] = None) -> str: return f"https://s3.amazonaws.com/{GOLDEN_BUCKET}/{key}" def get_version_of_artifact(self, s3_url: str): From f5de1ea41c57abd918f3e5c65203a1abd7861bec Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 03:01:46 +0000 Subject: [PATCH 12/29] chore(golden): clean ruff nits in new tests/golden/ files Drop unused pytest/yaml imports across the new tests, and replace the rename-detection magic number in check_semver_bump.py with a named constant. No behavior change. Surfaces from ruff check tests/golden when run directly (the project's `make lint` target restricts to samcli/schema, so these escaped CI). --- tests/golden/check_semver_bump.py | 5 ++++- tests/golden/conftest.py | 2 -- tests/golden/test_check_semver_bump.py | 2 -- tests/golden/test_corpus.py | 2 -- tests/golden/test_harness_build.py | 3 --- tests/golden/test_harness_package.py | 2 -- tests/golden/test_normalize.py | 2 -- 7 files changed, 4 insertions(+), 14 deletions(-) diff --git a/tests/golden/check_semver_bump.py b/tests/golden/check_semver_bump.py index f29c2cac9e5..b276c5a2476 100644 --- a/tests/golden/check_semver_bump.py +++ b/tests/golden/check_semver_bump.py @@ -16,6 +16,9 @@ EXPECTED_GLOB = re.compile(r"^tests/golden/templates/.+/expected\.(build|package)\.yaml$") +# git diff --name-status emits "R\tOLD\tNEW" for renames (3 fields). +_RENAME_DIFF_PARTS = 3 + @dataclass(frozen=True) class Change: @@ -102,7 +105,7 @@ def _git_changed_files(base: str, head: str) -> List[Change]: parts = line.split("\t") status, path = parts[0], parts[-1] # Renames present as "R\tOLD\tNEW" — split into D + A for our purposes. - if status.startswith("R") and len(parts) == 3: + if status.startswith("R") and len(parts) == _RENAME_DIFF_PARTS: old, new = parts[1], parts[2] changes.append(Change(old, "D")) changes.append(Change(new, "A")) diff --git a/tests/golden/conftest.py b/tests/golden/conftest.py index 9472db7d305..3143806c98c 100644 --- a/tests/golden/conftest.py +++ b/tests/golden/conftest.py @@ -6,8 +6,6 @@ from pathlib import Path -import pytest - TEMPLATES_ROOT = Path(__file__).parent / "templates" diff --git a/tests/golden/test_check_semver_bump.py b/tests/golden/test_check_semver_bump.py index 3b95ae40733..14c88ef6946 100644 --- a/tests/golden/test_check_semver_bump.py +++ b/tests/golden/test_check_semver_bump.py @@ -4,8 +4,6 @@ the lower-level entrypoint. """ -import pytest - from tests.golden import check_semver_bump as csb diff --git a/tests/golden/test_corpus.py b/tests/golden/test_corpus.py index 265e391d07f..d8e3399706e 100644 --- a/tests/golden/test_corpus.py +++ b/tests/golden/test_corpus.py @@ -2,8 +2,6 @@ from pathlib import Path -import pytest - from tests.golden.harness import run_build_pipeline, run_package_pipeline from tests.golden.normalize import normalize from tests.golden.update_goldens import _read_metadata, _resolve_le_default diff --git a/tests/golden/test_harness_build.py b/tests/golden/test_harness_build.py index 91f4f4a124e..3a31d6f7697 100644 --- a/tests/golden/test_harness_build.py +++ b/tests/golden/test_harness_build.py @@ -2,9 +2,6 @@ from pathlib import Path -import pytest -import yaml - from tests.golden.harness import run_build_pipeline CASES_ROOT = Path(__file__).parent / "templates" diff --git a/tests/golden/test_harness_package.py b/tests/golden/test_harness_package.py index 03d1512f79f..4a7610e5157 100644 --- a/tests/golden/test_harness_package.py +++ b/tests/golden/test_harness_package.py @@ -2,8 +2,6 @@ from pathlib import Path -import pytest - from tests.golden.harness import run_build_pipeline, run_package_pipeline CASES_ROOT = Path(__file__).parent / "templates" diff --git a/tests/golden/test_normalize.py b/tests/golden/test_normalize.py index ed4ed64338f..691e71a780f 100644 --- a/tests/golden/test_normalize.py +++ b/tests/golden/test_normalize.py @@ -1,7 +1,5 @@ """Unit tests for normalize() — deterministic YAML serialization.""" -import pytest - from tests.golden.normalize import normalize From ddad99ac50e8eae4d7a2a83b9fadb6afefe864e2 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Tue, 2 Jun 2026 03:49:04 +0000 Subject: [PATCH 13/29] fix(golden): make update_goldens.py / check_semver_bump.py work as direct scripts Both scripts have docstrings and corpus-test failure hints that tell users to invoke them as `python tests/golden/