repro-lambda 0.8.0__tar.gz → 0.8.2__tar.gz
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.
- repro_lambda-0.8.2/.release-please-manifest.json +3 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/CHANGELOG.md +14 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/PKG-INFO +1 -1
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/SETUP.md +17 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/USAGE.md +3 -3
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/pyproject.toml +1 -1
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/__init__.py +1 -1
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/build.py +10 -6
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/hasher.py +10 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/sources.py +11 -2
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_build_integration.py +3 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_extra_files.py +28 -0
- repro_lambda-0.8.2/tests/test_hash_contract.py +82 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_sources.py +54 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/uv.lock +1 -1
- repro_lambda-0.8.0/.release-please-manifest.json +0 -3
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/.github/workflows/build.yml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/.github/workflows/ci.yml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/.github/workflows/move-major-tag.yml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/.github/workflows/promote.yml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/.github/workflows/publish.yml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/.github/workflows/release.yml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/.gitignore +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/.pre-commit-config.yaml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/LICENSE +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/README.md +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/assets/social-preview.html +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/assets/social-preview.png +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/README.md +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/builds/catalog.json +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/lambdas.toml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/main.tf +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/outputs.tf +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/src/app/app.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/src/app/requirements.arm64.lock +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/variables.tf +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/examples/complete/versions.tf +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/release-please-config.json +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/__main__.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/catalog.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/cli.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/docker_runner.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/git_guard.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/manifest.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/promote.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/s3_uploader.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/source_locker.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/source_stager.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/verify.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/src/repro_lambda/zip_packager.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/__init__.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/conftest.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_build_nodejs.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_catalog.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_cli_build.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_cli_lock.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_cli_smoke.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_docker_runner.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_docker_runner_nodejs.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_e2e_nodejs_lambda.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_e2e_python_lambda.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_git_guard.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_hasher.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_manifest.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_per_lambda_builder.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_promote.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_python_byte_compat_regression.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_s3_uploader.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_source_locker.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_source_stager.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_sources_hash.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_sources_schema.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_verify.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_zip_excludes.py +0 -0
- {repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/test_zip_packager.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.2](https://github.com/antonbabenko/repro-lambda/compare/v0.8.1...v0.8.2) (2026-09-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **hasher:** stop re-keying artifacts on every repro-lambda release ([#19](https://github.com/antonbabenko/repro-lambda/issues/19)) ([44a263a](https://github.com/antonbabenko/repro-lambda/commit/44a263a560d6d7d3504a1365c4d2dac9c4dbad4f))
|
|
9
|
+
|
|
10
|
+
## [0.8.1](https://github.com/antonbabenko/repro-lambda/compare/v0.8.0...v0.8.1) (2026-09-23)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **sources:** fall back to the release assets endpoint when the tag view lists none ([#17](https://github.com/antonbabenko/repro-lambda/issues/17)) ([3a5608b](https://github.com/antonbabenko/repro-lambda/commit/3a5608b8cd7294f66986b1a61e4607efd052163a))
|
|
16
|
+
|
|
3
17
|
## [0.8.0](https://github.com/antonbabenko/repro-lambda/compare/v0.7.2...v0.8.0) (2026-09-18)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: repro-lambda
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
4
4
|
Summary: Build reproducible AWS Lambda packages outside Terraform, optimized for terraform-aws-lambda by serverless.tf.
|
|
5
5
|
Project-URL: Homepage, https://github.com/antonbabenko/repro-lambda
|
|
6
6
|
Project-URL: Repository, https://github.com/antonbabenko/repro-lambda
|
|
@@ -32,6 +32,23 @@ Once an artifact with a content-hash key (`lambdas/<name>/<sha256>.zip`) is
|
|
|
32
32
|
uploaded, the key is permanently bound to those bytes. `repro-lambda` treats
|
|
33
33
|
HTTP 412 PreconditionFailed on a duplicate upload as success.
|
|
34
34
|
|
|
35
|
+
The key hashes the build inputs (staged source, requirements lock, spec, base
|
|
36
|
+
image, filters, sources) plus a builder hash contract. It does not hash the
|
|
37
|
+
repro-lambda package version, so upgrading repro-lambda keeps every existing key
|
|
38
|
+
unless the release changes the packaged bytes, in which case it bumps
|
|
39
|
+
`HASH_CONTRACT` in `hasher.py` and says so in its changelog entry.
|
|
40
|
+
`tests/test_hash_contract.py` fingerprints the packaging modules, so a change to
|
|
41
|
+
them fails CI until the contract is reviewed.
|
|
42
|
+
|
|
43
|
+
`--verify` builds twice and compares the two results; it does not compare against
|
|
44
|
+
the object already in S3. On a cache hit the build catalog records
|
|
45
|
+
`hash-contract:<value>` as the builder, because the running version did not
|
|
46
|
+
build that object.
|
|
47
|
+
|
|
48
|
+
Releases up to 0.8.1 folded the package version into the key, so 0.8.1 would mint
|
|
49
|
+
keys of its own. 0.8.2 returns to the keys 0.8.0 minted. If a pin was taken from a
|
|
50
|
+
0.8.1 build, it moves back to the 0.8.0 key on the next build, with identical bytes.
|
|
51
|
+
|
|
35
52
|
## Terraform - per-account bootstrap
|
|
36
53
|
|
|
37
54
|
The Terraform below assumes you already have a configured AWS provider in the
|
|
@@ -86,9 +86,9 @@ include_patterns = ["worker/**/*.py", "worker/**/*.json"]
|
|
|
86
86
|
exclude_patterns = ["**/tests/**"]
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
The resolved per-lambda builder (base-image digest + include/exclude lists
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
The resolved per-lambda builder (base-image digest + include/exclude lists)
|
|
90
|
+
folds into the content hash, so changing an override re-keys that lambda's
|
|
91
|
+
artifact while leaving the others untouched.
|
|
92
92
|
|
|
93
93
|
## Declarative sources - `[[lambda.source]]`
|
|
94
94
|
|
|
@@ -11,7 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from repro_lambda import __version__
|
|
12
12
|
from repro_lambda.catalog import Catalog, CatalogEntry
|
|
13
13
|
from repro_lambda.docker_runner import build_nodejs_lambda, build_python_lambda
|
|
14
|
-
from repro_lambda.hasher import compute_content_hash
|
|
14
|
+
from repro_lambda.hasher import HASH_CONTRACT, compute_content_hash
|
|
15
15
|
from repro_lambda.manifest import BuilderConfig, LambdaSpec, resolve_builder
|
|
16
16
|
from repro_lambda.s3_uploader import S3Uploader, UploadResult
|
|
17
17
|
from repro_lambda.source_stager import stage_source
|
|
@@ -83,7 +83,7 @@ def compute_sha_for(
|
|
|
83
83
|
requirements_lock=lock_path,
|
|
84
84
|
spec=spec,
|
|
85
85
|
base_image=primary_base_image,
|
|
86
|
-
builder_version=
|
|
86
|
+
builder_version=HASH_CONTRACT,
|
|
87
87
|
extra_files=extras,
|
|
88
88
|
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
|
|
89
89
|
include_patterns=builder.include_patterns,
|
|
@@ -130,7 +130,7 @@ def build_one(
|
|
|
130
130
|
requirements_lock=lock_path,
|
|
131
131
|
spec=spec,
|
|
132
132
|
base_image=primary_base_image,
|
|
133
|
-
builder_version=
|
|
133
|
+
builder_version=HASH_CONTRACT,
|
|
134
134
|
extra_files=extras,
|
|
135
135
|
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
|
|
136
136
|
include_patterns=builder.include_patterns,
|
|
@@ -144,7 +144,7 @@ def build_one(
|
|
|
144
144
|
|
|
145
145
|
uploader = S3Uploader(region=spec.region)
|
|
146
146
|
if uploader.exists(bucket=target_bucket, key=bucket_key):
|
|
147
|
-
_record(catalog, spec, sha, source_commit, builder)
|
|
147
|
+
_record(catalog, spec, sha, source_commit, builder, built=False)
|
|
148
148
|
return BuildOutcome(BuildResult.CACHE_HIT, sha, bucket_key)
|
|
149
149
|
|
|
150
150
|
# Cache miss only: fetch + verify + extract declarative sources into the staged
|
|
@@ -182,7 +182,7 @@ def build_one(
|
|
|
182
182
|
|
|
183
183
|
result = uploader.upload(bucket=target_bucket, key=bucket_key, body_path=out_zip)
|
|
184
184
|
assert result in {UploadResult.UPLOADED, UploadResult.ALREADY_PRESENT}
|
|
185
|
-
_record(catalog, spec, sha, source_commit, builder)
|
|
185
|
+
_record(catalog, spec, sha, source_commit, builder, built=True)
|
|
186
186
|
return BuildOutcome(BuildResult.BUILT_AND_UPLOADED, sha, bucket_key)
|
|
187
187
|
|
|
188
188
|
|
|
@@ -192,6 +192,8 @@ def _record(
|
|
|
192
192
|
sha: str,
|
|
193
193
|
source_commit: str,
|
|
194
194
|
builder: BuilderConfig,
|
|
195
|
+
*,
|
|
196
|
+
built: bool,
|
|
195
197
|
) -> None:
|
|
196
198
|
if spec.package_manager == "npm":
|
|
197
199
|
primary_image = builder.base_image_nodejs
|
|
@@ -205,7 +207,9 @@ def _record(
|
|
|
205
207
|
runtime=spec.runtime,
|
|
206
208
|
arch=spec.arch,
|
|
207
209
|
region=spec.region,
|
|
208
|
-
|
|
210
|
+
# A cache hit did not build the object, and the key no longer carries the
|
|
211
|
+
# package version, so only the hash contract is known to match it.
|
|
212
|
+
builder_version=__version__ if built else f"hash-contract:{HASH_CONTRACT}",
|
|
209
213
|
base_image_digest=primary_image.split("@", 1)[-1],
|
|
210
214
|
built_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
211
215
|
),
|
|
@@ -7,6 +7,15 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from repro_lambda.manifest import LambdaSpec, Source
|
|
9
9
|
|
|
10
|
+
# The builder's contribution to every artifact key. NOT the package version: a
|
|
11
|
+
# release that leaves the packaged bytes alone must keep every key, or promote-by-key
|
|
12
|
+
# sees two keys for one zip. Bump it only when the same inputs would produce different
|
|
13
|
+
# zip bytes (packaging, normalisation, install flags, base-image handling), and update
|
|
14
|
+
# the fingerprints in tests/test_hash_contract.py with it. Frozen at "0.8.0", the last
|
|
15
|
+
# package version folded here, so keys minted by 0.8.0 stay valid; the packaging
|
|
16
|
+
# modules did not change between 0.7.2 and 0.8.1.
|
|
17
|
+
HASH_CONTRACT = "0.8.0"
|
|
18
|
+
|
|
10
19
|
|
|
11
20
|
def _sha256_file(path: Path) -> str:
|
|
12
21
|
h = hashlib.sha256()
|
|
@@ -32,6 +41,7 @@ def compute_content_hash(
|
|
|
32
41
|
"""
|
|
33
42
|
sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
|
|
34
43
|
+ sha256(requirements_lock) + spec scalars + base_image + builder_version
|
|
44
|
+
(the HASH_CONTRACT, not the package version)
|
|
35
45
|
+ optional resolved include/exclude filter lists + optional extra_files keyed by
|
|
36
46
|
destination relname (e.g. "package.json").
|
|
37
47
|
|
|
@@ -34,6 +34,7 @@ import tempfile
|
|
|
34
34
|
import zipfile
|
|
35
35
|
from http.client import HTTPSConnection
|
|
36
36
|
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
37
38
|
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
38
39
|
|
|
39
40
|
from repro_lambda import __version__
|
|
@@ -183,7 +184,7 @@ def _stream_to(resp, sink: io.BufferedWriter, max_bytes: int) -> str:
|
|
|
183
184
|
return h.hexdigest()
|
|
184
185
|
|
|
185
186
|
|
|
186
|
-
def _http_get_json(url: str, headers: dict[str, str]) ->
|
|
187
|
+
def _http_get_json(url: str, headers: dict[str, str]) -> Any:
|
|
187
188
|
buf = io.BytesIO()
|
|
188
189
|
writer = io.BufferedWriter(_RawSink(buf))
|
|
189
190
|
_http_request(url, headers, writer, _JSON_MAX_BYTES)
|
|
@@ -219,7 +220,15 @@ def _github_asset_url(src: Source, token: str | None) -> tuple[str, dict[str, st
|
|
|
219
220
|
auth = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
|
|
220
221
|
release = _http_get_json(api, auth)
|
|
221
222
|
want = src.resolved_asset
|
|
222
|
-
|
|
223
|
+
assets = release.get("assets", [])
|
|
224
|
+
if not any(a.get("name") == want for a in assets) and "id" in release:
|
|
225
|
+
# The tag view can serve a stale, empty `assets` list for a while after an
|
|
226
|
+
# upload; the per-release assets endpoint is current.
|
|
227
|
+
# ponytail: first 100 assets only, paginate if a release ever carries more.
|
|
228
|
+
assets = _http_get_json(
|
|
229
|
+
f"{_GITHUB_API}/repos/{src.repo}/releases/{release['id']}/assets?per_page=100", auth
|
|
230
|
+
)
|
|
231
|
+
for asset in assets:
|
|
223
232
|
if asset.get("name") == want:
|
|
224
233
|
asset_id = asset["id"]
|
|
225
234
|
dl = f"{_GITHUB_API}/repos/{src.repo}/releases/assets/{asset_id}"
|
|
@@ -7,6 +7,7 @@ from moto import mock_aws
|
|
|
7
7
|
|
|
8
8
|
from repro_lambda.build import BuildResult, build_one, compute_sha_for
|
|
9
9
|
from repro_lambda.catalog import Catalog
|
|
10
|
+
from repro_lambda.hasher import HASH_CONTRACT
|
|
10
11
|
from repro_lambda.manifest import BuilderConfig, LambdaSpec
|
|
11
12
|
|
|
12
13
|
|
|
@@ -84,6 +85,8 @@ def test_build_one_cache_hit_skips_docker_and_returns_existing_sha(
|
|
|
84
85
|
assert outcome.sha256 == sha
|
|
85
86
|
mock_docker.assert_not_called()
|
|
86
87
|
assert catalog.lambdas["app"].current == sha
|
|
88
|
+
# This process did not build the object; claim only the hash contract.
|
|
89
|
+
assert catalog.lambdas["app"].history[0].builder_version == f"hash-contract:{HASH_CONTRACT}"
|
|
87
90
|
|
|
88
91
|
|
|
89
92
|
def test_build_one_cache_miss_runs_docker_uploads_and_records(git_repo_with_sample: Path, mocker):
|
|
@@ -220,3 +220,31 @@ def test_compute_sha_for_reflects_payload_binary(tmp_path: Path):
|
|
|
220
220
|
sha_b = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
|
|
221
221
|
|
|
222
222
|
assert sha_a != sha_b, "content hash must track the bundled binary's bytes"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_compute_sha_for_ignores_the_package_version(tmp_path: Path, monkeypatch):
|
|
226
|
+
# A release that does not change the packaged bytes must not re-key artifacts:
|
|
227
|
+
# identical zips under two keys break promote-by-key between accounts.
|
|
228
|
+
repo = _git_repo_with_source(tmp_path)
|
|
229
|
+
(repo / "handler" / "requirements.arm64.lock").write_text("")
|
|
230
|
+
builder = BuilderConfig(base_image_python=PINNED_IMAGE)
|
|
231
|
+
spec = _spec()
|
|
232
|
+
|
|
233
|
+
sha_a = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
|
|
234
|
+
monkeypatch.setattr("repro_lambda.build.__version__", "99.0.0")
|
|
235
|
+
sha_b = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
|
|
236
|
+
|
|
237
|
+
assert sha_a == sha_b
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_compute_sha_for_rekeys_on_hash_contract_bump(tmp_path: Path, monkeypatch):
|
|
241
|
+
repo = _git_repo_with_source(tmp_path)
|
|
242
|
+
(repo / "handler" / "requirements.arm64.lock").write_text("")
|
|
243
|
+
builder = BuilderConfig(base_image_python=PINNED_IMAGE)
|
|
244
|
+
spec = _spec()
|
|
245
|
+
|
|
246
|
+
sha_a = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
|
|
247
|
+
monkeypatch.setattr("repro_lambda.build.HASH_CONTRACT", "bumped", raising=False)
|
|
248
|
+
sha_b = compute_sha_for(repo_root=repo, spec=spec, builder=builder)
|
|
249
|
+
|
|
250
|
+
assert sha_a != sha_b
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Guards for HASH_CONTRACT: artifact keys must change exactly when packaged bytes can."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import repro_lambda
|
|
9
|
+
from repro_lambda.hasher import HASH_CONTRACT, compute_content_hash
|
|
10
|
+
from repro_lambda.manifest import LambdaSpec
|
|
11
|
+
|
|
12
|
+
_PKG = Path(repro_lambda.__file__).parent
|
|
13
|
+
|
|
14
|
+
# Modules that shape the zip bytes or the key beyond the hashed inputs. Editing one
|
|
15
|
+
# means deciding whether the same inputs can now produce different bytes: if yes, bump
|
|
16
|
+
# HASH_CONTRACT; either way, record the new fingerprints here under the (possibly new)
|
|
17
|
+
# contract. A fingerprint update is cheap; a key change re-keys every artifact.
|
|
18
|
+
# Known gap: npm packaging uses base_image_python, which is not in the key (issue #20).
|
|
19
|
+
PACKAGING_FINGERPRINTS = {
|
|
20
|
+
"0.8.0": {
|
|
21
|
+
"build.py": "2dc3140e8af9e7e68d70199f85f261db04eef3e847d60ac21c9276133e9ec041",
|
|
22
|
+
"hasher.py": "c5eb56e00737efdca6c4bb47b694390b3dbd8941569f6c5cb299ba4cd5b18704",
|
|
23
|
+
"zip_packager.py": "56810a5e337a2fd5f72c269ba81bab06ca2f019e14e0edb728d6c37881d7cf0a",
|
|
24
|
+
"docker_runner.py": "0092a103b8dd11e6c0a00979e4c38272ccc6d707f2ee74035d8d84e61fed4988",
|
|
25
|
+
"source_stager.py": "38186277fb94ab6feac8a07cad2ae35f5adfaa52bde3d51ac71daccb003d3c68",
|
|
26
|
+
"sources.py": "10415ccc89b85f521fb64ef199f084f0bea1842032d414cb3d3537334f6caa0b",
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_packaging_modules_match_the_hash_contract():
|
|
32
|
+
expected = PACKAGING_FINGERPRINTS.get(HASH_CONTRACT)
|
|
33
|
+
assert expected, f"no packaging fingerprints recorded for HASH_CONTRACT={HASH_CONTRACT!r}"
|
|
34
|
+
actual = {name: hashlib.sha256((_PKG / name).read_bytes()).hexdigest() for name in expected}
|
|
35
|
+
changed = sorted(n for n in expected if actual[n] != expected[n])
|
|
36
|
+
assert not changed, (
|
|
37
|
+
f"packaging module(s) changed: {changed}. If the same inputs can now produce "
|
|
38
|
+
"different zip bytes, bump HASH_CONTRACT in hasher.py. Then record these "
|
|
39
|
+
f"fingerprints under the contract: {actual}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_content_hash_golden_key(tmp_path: Path):
|
|
44
|
+
# Pins the hash layout: any change to what is folded, or how, re-keys every artifact.
|
|
45
|
+
src = tmp_path / "source"
|
|
46
|
+
src.mkdir()
|
|
47
|
+
(src / "app.py").write_text("ok\n")
|
|
48
|
+
lock = tmp_path / "lock.txt"
|
|
49
|
+
lock.write_text("\n")
|
|
50
|
+
spec = LambdaSpec(
|
|
51
|
+
logical_name="fn",
|
|
52
|
+
source_dir="src/fn",
|
|
53
|
+
requirements_lock="src/fn/requirements.${arch}.lock",
|
|
54
|
+
runtime="python3.13",
|
|
55
|
+
arch="arm64",
|
|
56
|
+
handler="app.handler",
|
|
57
|
+
region="eu-west-1",
|
|
58
|
+
)
|
|
59
|
+
got = compute_content_hash(src, lock, spec, "img@sha256:0", HASH_CONTRACT)
|
|
60
|
+
assert got == "de8da0c8801288758e8dc86edc15b085ffadc6eb915dcc45d875bfb235854dea"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# The zip command's exclusion list lives in cli.py; changing it changes the zip bytes.
|
|
64
|
+
ZIP_EXCLUDES = {
|
|
65
|
+
"0.8.0": [
|
|
66
|
+
"*__pycache__*",
|
|
67
|
+
"*.pyc",
|
|
68
|
+
"*.dist-info/RECORD",
|
|
69
|
+
"*.dist-info/INSTALLER",
|
|
70
|
+
"*.dist-info/direct_url.json",
|
|
71
|
+
"*.dist-info/REQUESTED",
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_zip_excludes_match_the_hash_contract():
|
|
77
|
+
from repro_lambda.cli import _LAMBDA_ZIP_EXCLUDES
|
|
78
|
+
|
|
79
|
+
assert ZIP_EXCLUDES.get(HASH_CONTRACT) == _LAMBDA_ZIP_EXCLUDES, (
|
|
80
|
+
"the lambda zip exclusion list changed: bump HASH_CONTRACT in hasher.py "
|
|
81
|
+
"and record the new list under the contract"
|
|
82
|
+
)
|
|
@@ -358,6 +358,60 @@ def test_github_source_requires_token(tmp_path: Path):
|
|
|
358
358
|
sources._github_asset_url(src, None)
|
|
359
359
|
|
|
360
360
|
|
|
361
|
+
def _gh_src() -> Source:
|
|
362
|
+
return Source(
|
|
363
|
+
name="p",
|
|
364
|
+
type="github_release",
|
|
365
|
+
sha256=SHA_PLACEHOLDER,
|
|
366
|
+
extract="tar.gz",
|
|
367
|
+
dest="p",
|
|
368
|
+
repo="o/r",
|
|
369
|
+
tag="t",
|
|
370
|
+
asset="a.tgz",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_github_asset_falls_back_to_assets_endpoint(monkeypatch):
|
|
375
|
+
# GitHub can serve releases/tags/<tag> with a stale, empty `assets` list right after
|
|
376
|
+
# an upload, while /releases/{id}/assets already lists the file.
|
|
377
|
+
calls = []
|
|
378
|
+
|
|
379
|
+
def fake_get(url, headers):
|
|
380
|
+
calls.append(url)
|
|
381
|
+
if url.endswith("/releases/tags/t"):
|
|
382
|
+
return {"id": 7, "assets": []}
|
|
383
|
+
assert url.endswith("/releases/7/assets?per_page=100")
|
|
384
|
+
return [{"name": "other", "id": 1}, {"name": "a.tgz", "id": 42}]
|
|
385
|
+
|
|
386
|
+
monkeypatch.setattr(sources, "_http_get_json", fake_get)
|
|
387
|
+
url, headers = sources._github_asset_url(_gh_src(), "tok")
|
|
388
|
+
assert url.endswith("/repos/o/r/releases/assets/42")
|
|
389
|
+
assert headers["Accept"] == "application/octet-stream"
|
|
390
|
+
assert len(calls) == 2
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_github_asset_embedded_list_needs_no_second_call(monkeypatch):
|
|
394
|
+
calls = []
|
|
395
|
+
|
|
396
|
+
def fake_get(url, headers):
|
|
397
|
+
calls.append(url)
|
|
398
|
+
return {"id": 7, "assets": [{"name": "a.tgz", "id": 42}]}
|
|
399
|
+
|
|
400
|
+
monkeypatch.setattr(sources, "_http_get_json", fake_get)
|
|
401
|
+
url, _ = sources._github_asset_url(_gh_src(), "tok")
|
|
402
|
+
assert url.endswith("/releases/assets/42")
|
|
403
|
+
assert len(calls) == 1
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_github_asset_missing_everywhere_raises(monkeypatch):
|
|
407
|
+
def fake_get(url, headers):
|
|
408
|
+
return {"id": 7, "assets": []} if url.endswith("/tags/t") else []
|
|
409
|
+
|
|
410
|
+
monkeypatch.setattr(sources, "_http_get_json", fake_get)
|
|
411
|
+
with pytest.raises(SourceFetchError, match="not found"):
|
|
412
|
+
sources._github_asset_url(_gh_src(), "tok")
|
|
413
|
+
|
|
414
|
+
|
|
361
415
|
# --- staging + end-to-end (no network) -------------------------------------
|
|
362
416
|
|
|
363
417
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_nodejs_lambda/handler/index.js
RENAMED
|
File without changes
|
|
File without changes
|
{repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_nodejs_lambda/handler/package.json
RENAMED
|
File without changes
|
|
File without changes
|
{repro_lambda-0.8.0 → repro_lambda-0.8.2}/tests/fixtures/sample_python_lambda/handler/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|