repro-lambda 0.4.2__tar.gz → 0.5.1__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.
Files changed (67) hide show
  1. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/.github/workflows/build.yml +20 -0
  2. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/CHANGELOG.md +18 -0
  3. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/PKG-INFO +2 -1
  4. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/SETUP.md +76 -0
  5. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/pyproject.toml +2 -2
  6. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/__init__.py +1 -1
  7. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/build.py +16 -0
  8. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/cli.py +31 -1
  9. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/hasher.py +26 -1
  10. repro_lambda-0.5.1/src/repro_lambda/manifest.py +478 -0
  11. repro_lambda-0.5.1/src/repro_lambda/source_locker.py +149 -0
  12. repro_lambda-0.5.1/src/repro_lambda/sources.py +473 -0
  13. repro_lambda-0.5.1/tests/test_source_locker.py +137 -0
  14. repro_lambda-0.5.1/tests/test_sources.py +449 -0
  15. repro_lambda-0.5.1/tests/test_sources_hash.py +109 -0
  16. repro_lambda-0.5.1/tests/test_sources_schema.py +246 -0
  17. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/uv.lock +12 -1
  18. repro_lambda-0.4.2/src/repro_lambda/manifest.py +0 -228
  19. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/.github/workflows/ci.yml +0 -0
  20. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/.github/workflows/move-major-tag.yml +0 -0
  21. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/.github/workflows/promote.yml +0 -0
  22. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/.github/workflows/publish.yml +0 -0
  23. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/.gitignore +0 -0
  24. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/.pre-commit-config.yaml +0 -0
  25. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/LICENSE +0 -0
  26. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/README.md +0 -0
  27. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/__main__.py +0 -0
  28. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/catalog.py +0 -0
  29. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/docker_runner.py +0 -0
  30. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/git_guard.py +0 -0
  31. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/promote.py +0 -0
  32. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/s3_uploader.py +0 -0
  33. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/source_stager.py +0 -0
  34. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/verify.py +0 -0
  35. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/src/repro_lambda/zip_packager.py +0 -0
  36. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/__init__.py +0 -0
  37. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/conftest.py +0 -0
  38. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
  39. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
  40. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
  41. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
  42. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
  43. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
  44. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
  45. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
  46. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_build_integration.py +0 -0
  47. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_build_nodejs.py +0 -0
  48. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_catalog.py +0 -0
  49. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_cli_build.py +0 -0
  50. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_cli_lock.py +0 -0
  51. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_cli_smoke.py +0 -0
  52. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_docker_runner.py +0 -0
  53. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_docker_runner_nodejs.py +0 -0
  54. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_e2e_nodejs_lambda.py +0 -0
  55. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_e2e_python_lambda.py +0 -0
  56. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_extra_files.py +0 -0
  57. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_git_guard.py +0 -0
  58. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_hasher.py +0 -0
  59. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_manifest.py +0 -0
  60. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_per_lambda_builder.py +0 -0
  61. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_promote.py +0 -0
  62. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_python_byte_compat_regression.py +0 -0
  63. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_s3_uploader.py +0 -0
  64. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_source_stager.py +0 -0
  65. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_verify.py +0 -0
  66. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_zip_excludes.py +0 -0
  67. {repro_lambda-0.4.2 → repro_lambda-0.5.1}/tests/test_zip_packager.py +0 -0
@@ -25,6 +25,14 @@ on:
25
25
  required: false
26
26
  default: ""
27
27
  description: S3 bucket name for prod Lambda artifacts (master push only).
28
+ secrets:
29
+ sources_token:
30
+ required: false
31
+ description: >-
32
+ Token with read access to the private release repos referenced by any
33
+ github_release [[lambda.source]]. Exported as REPRO_LAMBDA_SOURCES_TOKEN for the
34
+ build. Omit when every source is a public https URL. Generic by design - the
35
+ reusable workflow never names a consumer repo.
28
36
 
29
37
  jobs:
30
38
  detect-arches:
@@ -64,6 +72,16 @@ jobs:
64
72
  - uses: actions/checkout@v7
65
73
  - uses: astral-sh/setup-uv@v8.2.0
66
74
 
75
+ # Content-addressed source cache, scoped per-arch (a source's sha256 is its key, so a
76
+ # re-pin changes the manifest hash -> fresh fetch; restore-keys reuses the prior cache).
77
+ - name: Cache fetched sources
78
+ uses: actions/cache@v4
79
+ with:
80
+ path: builds/.sources-cache
81
+ key: repro-sources-${{ matrix.arch }}-${{ hashFiles(inputs.manifest_path) }}
82
+ restore-keys: |
83
+ repro-sources-${{ matrix.arch }}-
84
+
67
85
  - name: Configure AWS credentials (dev)
68
86
  uses: aws-actions/configure-aws-credentials@v6
69
87
  with:
@@ -73,6 +91,7 @@ jobs:
73
91
  - name: Build (dev bucket)
74
92
  env:
75
93
  REPRO_LAMBDA_BUCKET: ${{ inputs.dev_bucket }}
94
+ REPRO_LAMBDA_SOURCES_TOKEN: ${{ secrets.sources_token }}
76
95
  run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
77
96
 
78
97
  - name: Verify reproducible (PR only)
@@ -90,6 +109,7 @@ jobs:
90
109
  if: github.ref == 'refs/heads/master' && inputs.aws_prod_role_arn != ''
91
110
  env:
92
111
  REPRO_LAMBDA_BUCKET: ${{ inputs.prod_bucket }}
112
+ REPRO_LAMBDA_SOURCES_TOKEN: ${{ secrets.sources_token }}
93
113
  run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
94
114
 
95
115
  - name: Commit catalog drift (master only, dev bot)
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.1 - 2026-06-21
4
+
5
+ ### Fixed
6
+ - Send a `User-Agent` header on every source fetch. The GitHub REST API rejects requests with no User-Agent (HTTP 403), which broke `github_release` source resolution (the asset lookup against `api.github.com`). The header is now `repro-lambda/<version>` on all requests (harmless for plain `https` sources, required for the API). Does not affect any content hash (request headers are not part of artifact identity).
7
+
8
+ ## v0.5.0 - 2026-06-21
9
+
10
+ ### Added
11
+ - Declarative sources DSL: `[[lambda.source]]` fetches a pinned external artifact into the package before the container build, replacing consumer-side download scripts. Two source types - `github_release` (`repo`/`tag`/`asset`, resolved via the GitHub API) and `https` (a direct `url`). Each source is PINNED: `sha256` is verified before extraction; `extract` is `zip`/`tar.gz`/`none`; an optional `member` extracts a single file or a (versioned) directory subtree to `dest` (package-root-relative); `executable` sets +x. Source names are required and unique per lambda; dest collisions (including tree overlaps and overlaps with the staged source) are refused.
12
+ - `version_from` (single-level): a source can derive its `version` at lock time from an asdf-style `key value` line in a referenced source's file (read relative to that source's member-stripped tree). `{version}` is substituted into `url`/`tag`/`asset`/`member`. `version_from` is a lock input and never participates in the content hash.
13
+ - `lock --sources` re-resolves `version_from`, re-downloads each source, recomputes its sha256, and rewrites `lambdas.toml` in place with tomlkit (comments preserved, atomic). It is idempotent: a run that changes nothing leaves the file byte-for-byte unchanged (no spurious PR). `lock` keeps regenerating requirements locks too; use `--no-requirements` / `--no-sources` to scope it.
14
+ - The reusable `build.yml` gains a generic `sources_token` secret (exported as `REPRO_LAMBDA_SOURCES_TOKEN`, used only for `github_release` API calls) and an arch-scoped `actions/cache` for the content-addressed source cache.
15
+
16
+ ### Security
17
+ - SSRF-hardened fetcher: HTTPS-only with a manual redirect loop; each hop resolves the hostname, refuses any non-global-unicast / reserved / private / loopback / link-local / IPv4-mapped address, and connects to that pinned IP while verifying the TLS cert against the original hostname (no second DNS lookup a rebind could poison). `Authorization` is stripped on any cross-host redirect (e.g. the GitHub API -> asset host), so a private token never leaks. Download size is capped and log URLs are redacted (userinfo + query stripped).
18
+ - Hardened extraction: sha256 is verified before any archive is opened; entries with absolute / `..` / symlink / hardlink / device / fifo paths are rejected; total bytes, entry count, and per-entry bytes are bounded (decompression-bomb limits, with streaming tar reads); files are written to a temp tree then promoted with normalized mtime + perms. The sha256-keyed cache is re-verified on every use (anti cache-poisoning).
19
+ - Content hashing folds the RESOLVED source metadata (not the bytes), so the artifact key is computable offline and a `member`/`extract`/`dest`/`sha256`/version change re-keys, while a re-fetch alone does not.
20
+
3
21
  ## v0.4.2 - 2026-06-21
4
22
 
5
23
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repro-lambda
3
- Version: 0.4.2
3
+ Version: 0.5.1
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
@@ -20,6 +20,7 @@ Classifier: Topic :: Software Development :: Build Tools
20
20
  Requires-Python: >=3.11
21
21
  Requires-Dist: boto3>=1.34
22
22
  Requires-Dist: repro-zipfile>=0.3.1
23
+ Requires-Dist: tomlkit>=0.13
23
24
  Requires-Dist: typer>=0.12
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: moto[s3]>=5; extra == 'dev'
@@ -330,6 +330,82 @@ The resolved per-lambda builder (base-image digest + include/exclude lists +
330
330
  builder version) folds into the content hash, so changing an override re-keys
331
331
  that lambda's artifact while leaving the others untouched.
332
332
 
333
+ ## Declarative sources — `[[lambda.source]]`
334
+
335
+ A lambda can bundle pinned external artifacts (a release tarball, a vendored CLI
336
+ binary) fetched at build time, instead of a hand-rolled download script. Each
337
+ `[[lambda.source]]` is fully pinned and fetched + verified + extracted into the
338
+ package before the container build:
339
+
340
+ ```toml
341
+ [[lambda]]
342
+ logical_name = "app"
343
+ source_dir = "src/app"
344
+ requirements_lock = "src/app/requirements.${arch}.lock"
345
+ runtime = "python3.13"
346
+ arch = "arm64"
347
+ handler = "app.lambda_handler"
348
+
349
+ # A private GitHub release tarball -> staged at package path "vendor".
350
+ [[lambda.source]]
351
+ name = "vendor"
352
+ type = "github_release"
353
+ repo = "owner/vendor-tool"
354
+ tag = "vendor-v{version}"
355
+ asset = "vendor-{version}.tar.gz"
356
+ sha256 = "<64-hex; written by `repro-lambda lock`>"
357
+ extract = "tar.gz"
358
+ member = "vendor-{version}" # map the versioned top dir to dest
359
+ dest = "vendor"
360
+ version = "1.4.0" # bump this one line; lock re-pins everything
361
+
362
+ # A public binary whose version is derived from the vendor release's .tool-versions.
363
+ [[lambda.source]]
364
+ name = "terraform"
365
+ type = "https"
366
+ url = "https://releases.hashicorp.com/terraform/{version}/terraform_{version}_linux_arm64.zip"
367
+ sha256 = "<64-hex; written by lock>"
368
+ extract = "zip"
369
+ member = "terraform"
370
+ dest = "bin/terraform"
371
+ executable = true
372
+ version = "1.9.0"
373
+ [lambda.source.version_from]
374
+ source = "vendor" # read from the vendor source's extracted tree
375
+ file = ".tool-versions" # relative to its member-stripped root
376
+ key = "terraform"
377
+ ```
378
+
379
+ - **Pinning.** `sha256` is verified before the archive is opened. `extract` is
380
+ `zip` / `tar.gz` / `none`. `member` extracts one file or a directory subtree to
381
+ `dest`; omit it to extract the whole archive under `dest`. Source names are
382
+ unique per lambda and dests may not overlap each other or the staged source.
383
+ - **`version_from`** (single-level) derives a source's `version` from an asdf-style
384
+ `key value` line in another source's file, so bumping the root `version`
385
+ cascades to dependents. It is a lock input - it never affects the artifact hash.
386
+ - **`repro-lambda lock`** re-resolves `version_from`, re-downloads, recomputes each
387
+ `sha256`, and rewrites this file (comment-preserving, atomic, idempotent). Run it
388
+ after bumping a `version`. Pass `REPRO_LAMBDA_SOURCES_TOKEN` for private
389
+ `github_release` sources.
390
+ - **Security.** Fetches are HTTPS-only with an SSRF guard (no private/loopback/
391
+ link-local/metadata IPs), strip `Authorization` on cross-host redirects, verify
392
+ sha256 before extraction, and reject path-traversal / link / device entries with
393
+ decompression-bomb bounds. See `src/repro_lambda/sources.py`.
394
+
395
+ In CI, pass the token to the reusable `build.yml` as the `sources_token` secret:
396
+
397
+ ```yaml
398
+ jobs:
399
+ build:
400
+ uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
401
+ with:
402
+ manifest_path: lambdas.toml
403
+ aws_dev_role_arn: arn:aws:iam::<account>:role/<dev-builder-role>
404
+ dev_bucket: <env>-my-lambda-artifacts
405
+ secrets:
406
+ sources_token: ${{ secrets.MY_RELEASE_TOKEN }}
407
+ ```
408
+
333
409
  ## Terraform consumer — `s3_existing_package`
334
410
 
335
411
  In the Terraform that creates your Lambda function, point at the artifact in
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repro-lambda"
3
- version = "0.4.2"
3
+ version = "0.5.1"
4
4
  description = "Build reproducible AWS Lambda packages outside Terraform, optimized for terraform-aws-lambda by serverless.tf."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "typer>=0.12",
22
22
  "repro-zipfile>=0.3.1",
23
23
  "boto3>=1.34",
24
-
24
+ "tomlkit>=0.13",
25
25
  ]
26
26
 
27
27
  [project.optional-dependencies]
@@ -1,3 +1,3 @@
1
1
  """repro-lambda — reproducible AWS Lambda packaging outside Terraform."""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.5.1"
@@ -88,6 +88,7 @@ def compute_sha_for(
88
88
  payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
89
89
  include_patterns=builder.include_patterns,
90
90
  exclude_patterns=builder.exclude_patterns,
91
+ sources=spec.sources,
91
92
  )
92
93
 
93
94
 
@@ -100,6 +101,8 @@ def build_one(
100
101
  catalog: Catalog,
101
102
  source_commit: str,
102
103
  dry_run: bool = False,
104
+ sources_token: str | None = None,
105
+ sources_cache: Path | None = None,
103
106
  ) -> BuildOutcome:
104
107
  """Build one lambda end-to-end. Returns BuildOutcome with sha + cache verdict."""
105
108
  builder = resolve_builder(builder, spec)
@@ -132,6 +135,7 @@ def build_one(
132
135
  payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
133
136
  include_patterns=builder.include_patterns,
134
137
  exclude_patterns=builder.exclude_patterns,
138
+ sources=spec.sources,
135
139
  )
136
140
  bucket_key = f"lambdas/{spec.logical_name}/{sha}.zip"
137
141
 
@@ -143,6 +147,18 @@ def build_one(
143
147
  _record(catalog, spec, sha, source_commit, builder)
144
148
  return BuildOutcome(BuildResult.CACHE_HIT, sha, bucket_key)
145
149
 
150
+ # Cache miss only: fetch + verify + extract declarative sources into the staged
151
+ # tree (post-filter; collides loudly with already-staged source/payload files).
152
+ if spec.sources:
153
+ from repro_lambda.sources import fetch_sources
154
+
155
+ fetch_sources(
156
+ sources=spec.sources,
157
+ dest_root=stage_dir / "source",
158
+ cache_dir=sources_cache or (repo_root / "builds" / ".sources-cache"),
159
+ github_token=sources_token,
160
+ )
161
+
146
162
  out_zip = stage_dir / "lambda.zip"
147
163
  if spec.package_manager == "pip":
148
164
  build_python_lambda(
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import subprocess
6
7
  from pathlib import Path
7
8
  from typing import Annotated
@@ -128,6 +129,8 @@ def build(
128
129
  except subprocess.CalledProcessError:
129
130
  source_commit = "unknown"
130
131
 
132
+ sources_token = os.environ.get("REPRO_LAMBDA_SOURCES_TOKEN") or None
133
+
131
134
  summary = []
132
135
  for spec in selected:
133
136
  outcome = build_one(
@@ -138,6 +141,7 @@ def build(
138
141
  catalog=catalog,
139
142
  source_commit=source_commit,
140
143
  dry_run=dry_run,
144
+ sources_token=sources_token,
141
145
  )
142
146
  summary.append(
143
147
  {
@@ -261,13 +265,39 @@ def promote(
261
265
  @app.command()
262
266
  def lock(
263
267
  manifest: Annotated[Path, typer.Option("--manifest", "-m")] = Path("lambdas.toml"),
268
+ requirements: Annotated[
269
+ bool,
270
+ typer.Option(
271
+ "--requirements/--no-requirements",
272
+ help="Regenerate per-arch requirements.${arch}.lock via uv pip compile.",
273
+ ),
274
+ ] = True,
275
+ sources: Annotated[
276
+ bool,
277
+ typer.Option(
278
+ "--sources/--no-sources",
279
+ help="Re-resolve version_from + re-pin [[lambda.source]] sha256 in the manifest.",
280
+ ),
281
+ ] = True,
264
282
  ) -> None:
265
- """Regenerate per-arch requirements.${arch}.lock files via `uv pip compile`."""
283
+ """Lock pinned inputs: per-arch requirements locks and/or declarative source pins."""
266
284
  from repro_lambda.manifest import load_manifest
267
285
 
268
286
  parsed = load_manifest(manifest)
269
287
  repo_root = manifest.parent.resolve()
270
288
 
289
+ if requirements:
290
+ _lock_requirements(parsed, repo_root)
291
+
292
+ if sources:
293
+ from repro_lambda.source_locker import lock_sources
294
+
295
+ token = os.environ.get("REPRO_LAMBDA_SOURCES_TOKEN") or None
296
+ changed = lock_sources(manifest, token)
297
+ typer.echo(f"lock sources: {'updated ' + str(manifest) if changed else 'no changes'}")
298
+
299
+
300
+ def _lock_requirements(parsed, repo_root: Path) -> None:
271
301
  for spec in parsed.lambdas:
272
302
  if spec.package_manager == "npm":
273
303
  typer.echo(
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import hashlib
6
6
  from pathlib import Path
7
7
 
8
- from repro_lambda.manifest import LambdaSpec
8
+ from repro_lambda.manifest import LambdaSpec, Source
9
9
 
10
10
 
11
11
  def _sha256_file(path: Path) -> str:
@@ -27,6 +27,7 @@ def compute_content_hash(
27
27
  payload_exec: list[tuple[str, bool]] | None = None,
28
28
  include_patterns: list[str] | None = None,
29
29
  exclude_patterns: list[str] | None = None,
30
+ sources: tuple[Source, ...] | None = None,
30
31
  ) -> str:
31
32
  """
32
33
  sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
@@ -46,6 +47,12 @@ def compute_content_hash(
46
47
  not re-key) and only when not None, so an explicit empty list (replace-with-empty)
47
48
  hashes differently from an unset/None filter. Callers passing None omit the
48
49
  section entirely, preserving hashes for code paths that do not resolve a builder.
50
+
51
+ sources are the declarative [[lambda.source]] entries. Their RESOLVED metadata
52
+ (the {version}-substituted url/tag/asset/member plus type/repo/extract/dest/
53
+ executable/sha256), sorted by name, folds in - NOT the downloaded bytes, so the
54
+ hash stays download-free. version_from is a lock input and never folded; a
55
+ member/extract/dest/sha256 change re-keys. Omitted entirely when there are none.
49
56
  """
50
57
  h = hashlib.sha256()
51
58
 
@@ -102,4 +109,22 @@ def compute_content_hash(
102
109
  for dest, executable in sorted(payload_exec):
103
110
  h.update(f"{dest}={int(executable)}\n".encode())
104
111
 
112
+ # Declarative sources: fold resolved metadata only (no bytes), sorted by name.
113
+ # version_from is intentionally excluded (it is a lock input, not artifact identity).
114
+ if sources:
115
+ h.update(b"---sources---\n")
116
+ for s in sorted(sources, key=lambda s: s.name):
117
+ h.update(f"name={s.name}\n".encode())
118
+ h.update(f"type={s.type}\n".encode())
119
+ h.update(f"repo={s.repo}\n".encode())
120
+ h.update(f"url={s.resolved_url}\n".encode())
121
+ h.update(f"tag={s.resolved_tag}\n".encode())
122
+ h.update(f"asset={s.resolved_asset}\n".encode())
123
+ h.update(f"member={s.resolved_member or ''}\n".encode())
124
+ h.update(f"extract={s.extract}\n".encode())
125
+ h.update(f"dest={s.dest}\n".encode())
126
+ h.update(f"executable={int(s.executable)}\n".encode())
127
+ h.update(f"sha256={s.sha256}\n".encode())
128
+ h.update(b"--\n")
129
+
105
130
  return h.hexdigest()