repro-lambda 0.4.1__tar.gz → 0.5.0__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.1 → repro_lambda-0.5.0}/.github/workflows/build.yml +20 -0
  2. repro_lambda-0.5.0/.github/workflows/move-major-tag.yml +28 -0
  3. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/CHANGELOG.md +19 -1
  4. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/PKG-INFO +2 -1
  5. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/SETUP.md +114 -10
  6. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/pyproject.toml +2 -2
  7. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/__init__.py +1 -1
  8. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/build.py +23 -1
  9. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/cli.py +31 -1
  10. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/hasher.py +47 -2
  11. repro_lambda-0.5.0/src/repro_lambda/manifest.py +478 -0
  12. repro_lambda-0.5.0/src/repro_lambda/source_locker.py +149 -0
  13. repro_lambda-0.5.0/src/repro_lambda/sources.py +465 -0
  14. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/verify.py +2 -1
  15. repro_lambda-0.5.0/tests/test_per_lambda_builder.py +231 -0
  16. repro_lambda-0.5.0/tests/test_source_locker.py +137 -0
  17. repro_lambda-0.5.0/tests/test_sources.py +442 -0
  18. repro_lambda-0.5.0/tests/test_sources_hash.py +109 -0
  19. repro_lambda-0.5.0/tests/test_sources_schema.py +246 -0
  20. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/uv.lock +12 -1
  21. repro_lambda-0.4.1/src/repro_lambda/manifest.py +0 -180
  22. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.github/workflows/ci.yml +0 -0
  23. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.github/workflows/promote.yml +0 -0
  24. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.github/workflows/publish.yml +0 -0
  25. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.gitignore +0 -0
  26. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/.pre-commit-config.yaml +0 -0
  27. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/LICENSE +0 -0
  28. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/README.md +0 -0
  29. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/__main__.py +0 -0
  30. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/catalog.py +0 -0
  31. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/docker_runner.py +0 -0
  32. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/git_guard.py +0 -0
  33. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/promote.py +0 -0
  34. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/s3_uploader.py +0 -0
  35. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/source_stager.py +0 -0
  36. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/src/repro_lambda/zip_packager.py +0 -0
  37. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/__init__.py +0 -0
  38. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/conftest.py +0 -0
  39. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
  40. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
  41. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
  42. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
  43. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
  44. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
  45. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
  46. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
  47. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_build_integration.py +0 -0
  48. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_build_nodejs.py +0 -0
  49. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_catalog.py +0 -0
  50. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_cli_build.py +0 -0
  51. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_cli_lock.py +0 -0
  52. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_cli_smoke.py +0 -0
  53. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_docker_runner.py +0 -0
  54. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_docker_runner_nodejs.py +0 -0
  55. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_e2e_nodejs_lambda.py +0 -0
  56. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_e2e_python_lambda.py +0 -0
  57. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_extra_files.py +0 -0
  58. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_git_guard.py +0 -0
  59. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_hasher.py +0 -0
  60. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_manifest.py +0 -0
  61. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_promote.py +0 -0
  62. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_python_byte_compat_regression.py +0 -0
  63. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_s3_uploader.py +0 -0
  64. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_source_stager.py +0 -0
  65. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_verify.py +0 -0
  66. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/tests/test_zip_excludes.py +0 -0
  67. {repro_lambda-0.4.1 → repro_lambda-0.5.0}/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)
@@ -0,0 +1,28 @@
1
+ name: move-major-tag
2
+
3
+ # On every semver release tag (vX.Y.Z), force-move the sliding MAJOR tag vX to it,
4
+ # so consumers can pin `@v0` (or `@v1` once 1.x ships) and always get the latest
5
+ # backward-compatible reusable workflows. While the project is 0.x the major is `v0`.
6
+ #
7
+ # Safe by construction: publish.yml triggers on `v*.*.*` only, so moving a non-semver
8
+ # tag (v0) never loop-triggers a publish; and a tag pushed by GITHUB_TOKEN does not
9
+ # retrigger any workflow.
10
+ on:
11
+ push:
12
+ tags: ["v*.*.*"]
13
+
14
+ permissions:
15
+ contents: write
16
+
17
+ jobs:
18
+ move:
19
+ runs-on: ubuntu-24.04
20
+ steps:
21
+ - uses: actions/checkout@v7
22
+ - name: Force-move sliding major tag
23
+ env:
24
+ TAG: ${{ github.ref_name }}
25
+ run: |
26
+ MAJOR="${TAG%%.*}" # v0.4.1 -> v0
27
+ git tag -f "$MAJOR" "$TAG"
28
+ git push -f origin "refs/tags/$MAJOR"
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.0 - 2026-06-21
4
+
5
+ ### Added
6
+ - 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.
7
+ - `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.
8
+ - `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.
9
+ - 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.
10
+
11
+ ### Security
12
+ - 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).
13
+ - 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).
14
+ - 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.
15
+
16
+ ## v0.4.2 - 2026-06-21
17
+
18
+ ### Added
19
+ - Per-lambda builder overrides: any `[[lambda]]` may now set `base_image_python`, `include_patterns`, or `exclude_patterns` to override the `[builder]` defaults for itself. An override fully REPLACES the matching default (lists are not merged); an unset field inherits `[builder]`. A per-lambda `base_image_python` must still be digest-pinned (validated at manifest load). This lets one lambda build on its own base image or filter its source more tightly than the others - e.g. a lambda that bundles a large prebuilt tree can narrow `include_patterns` to just its runtime modules so unrelated file changes no longer re-key its artifact. The resolved per-lambda builder (base-image digest + include/exclude lists + builder version) folds into the content hash, so changing an override re-keys only that lambda. Manifests with no per-lambda overrides resolve to the `[builder]` defaults unchanged. The builder version bump re-keys all content hashes, as expected.
20
+
3
21
  ## v0.4.1 - 2026-06-21
4
22
 
5
23
  ### Fixed
@@ -21,7 +39,7 @@
21
39
 
22
40
  jobs:
23
41
  promote:
24
- uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@v1
42
+ uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@v0
25
43
  with:
26
44
  source_sha: ${{ inputs.source_sha }}
27
45
  promoter_role_arn: arn:aws:iam::<account>:role/<promoter-role>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repro-lambda
3
- Version: 0.4.1
3
+ Version: 0.5.0
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'
@@ -260,20 +260,21 @@ on:
260
260
 
261
261
  jobs:
262
262
  build:
263
- uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0.2.1
263
+ uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
264
264
  with:
265
265
  manifest_path: lambdas.toml
266
- repro_lambda_version: "0.2.1"
267
- secrets:
268
- aws-dev-role-arn: ${{ secrets.AWS_DEV_ROLE_ARN }}
269
- aws-prod-role-arn: ${{ secrets.AWS_PROD_ROLE_ARN }}
266
+ aws_dev_role_arn: arn:aws:iam::<dev-account-id>:role/gha-lambda-builder-dev
267
+ dev_bucket: <env>-lambda-artifacts
268
+ # Optional - master-push upload to a prod bucket:
269
+ # aws_prod_role_arn: arn:aws:iam::<prod-account-id>:role/gha-lambda-builder-prod
270
+ # prod_bucket: <env>-lambda-artifacts
270
271
  ```
271
272
 
272
- Store the role ARNs (from the Terraform outputs above) as repo or organization
273
- secrets:
274
-
275
- - `AWS_DEV_ROLE_ARN` `gha-lambda-builder-dev` role from the dev account
276
- - `AWS_PROD_ROLE_ARN` `gha-lambda-builder-prod` role from the prod account
273
+ Pin the reusable workflow with the sliding major tag `@v0` - it auto-moves to the
274
+ latest backward-compatible 0.x release on every tag (switch to `@v1` once repro-lambda
275
+ ships 1.0). The role ARNs are not secrets: the boundary is the OIDC trust policy plus
276
+ the key-level bucket immutability, so they are plain inputs (only the account IDs,
277
+ which are public), not stored secrets.
277
278
 
278
279
  ## Per-Lambda manifest
279
280
 
@@ -302,6 +303,109 @@ Pin the `base_image_python` to a specific digest with `docker pull <image> &&
302
303
  docker inspect --format='{{index .RepoDigests 0}}' <image>` — never use a
303
304
  floating tag in production.
304
305
 
306
+ ### Per-lambda builder overrides
307
+
308
+ `[builder]` sets the defaults for every lambda. Any `[[lambda]]` may override
309
+ `base_image_python`, `include_patterns`, or `exclude_patterns` for itself. An
310
+ override REPLACES the default for that field (lists are not merged); an unset
311
+ field inherits `[builder]`. Use this when one lambda needs a different base image
312
+ or a tighter file filter (so it re-hashes only on changes that affect it):
313
+
314
+ ```toml
315
+ [[lambda]]
316
+ logical_name = "worker"
317
+ source_dir = "src/worker"
318
+ requirements_lock = "src/worker/requirements.${arch}.lock"
319
+ runtime = "python3.13"
320
+ arch = "arm64"
321
+ handler = "worker.handler"
322
+ # Override: only this lambda's runtime modules trigger a rebuild, and it builds
323
+ # on its own pinned base image. base_image_python must still be digest-pinned.
324
+ base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<other-digest>"
325
+ include_patterns = ["worker/**/*.py", "worker/**/*.json"]
326
+ exclude_patterns = ["**/tests/**"]
327
+ ```
328
+
329
+ The resolved per-lambda builder (base-image digest + include/exclude lists +
330
+ builder version) folds into the content hash, so changing an override re-keys
331
+ that lambda's artifact while leaving the others untouched.
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
+
305
409
  ## Terraform consumer — `s3_existing_package`
306
410
 
307
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.1"
3
+ version = "0.5.0"
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.1"
3
+ __version__ = "0.5.0"
@@ -12,7 +12,7 @@ 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
14
  from repro_lambda.hasher import compute_content_hash
15
- from repro_lambda.manifest import BuilderConfig, LambdaSpec
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
18
18
 
@@ -61,6 +61,7 @@ def compute_sha_for(
61
61
  builder: BuilderConfig,
62
62
  ) -> str:
63
63
  """Stage source and compute the content hash; tempdir disposed on exit."""
64
+ builder = resolve_builder(builder, spec)
64
65
  with tempfile.TemporaryDirectory(prefix="repro-lambda-") as td:
65
66
  stage_dir = Path(td)
66
67
  lock_path = repo_root / spec.resolved_requirements_lock
@@ -85,6 +86,9 @@ def compute_sha_for(
85
86
  builder_version=__version__,
86
87
  extra_files=extras,
87
88
  payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
89
+ include_patterns=builder.include_patterns,
90
+ exclude_patterns=builder.exclude_patterns,
91
+ sources=spec.sources,
88
92
  )
89
93
 
90
94
 
@@ -97,8 +101,11 @@ def build_one(
97
101
  catalog: Catalog,
98
102
  source_commit: str,
99
103
  dry_run: bool = False,
104
+ sources_token: str | None = None,
105
+ sources_cache: Path | None = None,
100
106
  ) -> BuildOutcome:
101
107
  """Build one lambda end-to-end. Returns BuildOutcome with sha + cache verdict."""
108
+ builder = resolve_builder(builder, spec)
102
109
  target_bucket = _bucket_for(spec, bucket)
103
110
 
104
111
  with tempfile.TemporaryDirectory(prefix="repro-lambda-") as td:
@@ -126,6 +133,9 @@ def build_one(
126
133
  builder_version=__version__,
127
134
  extra_files=extras,
128
135
  payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
136
+ include_patterns=builder.include_patterns,
137
+ exclude_patterns=builder.exclude_patterns,
138
+ sources=spec.sources,
129
139
  )
130
140
  bucket_key = f"lambdas/{spec.logical_name}/{sha}.zip"
131
141
 
@@ -137,6 +147,18 @@ def build_one(
137
147
  _record(catalog, spec, sha, source_commit, builder)
138
148
  return BuildOutcome(BuildResult.CACHE_HIT, sha, bucket_key)
139
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
+
140
162
  out_zip = stage_dir / "lambda.zip"
141
163
  if spec.package_manager == "pip":
142
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:
@@ -25,11 +25,15 @@ def compute_content_hash(
25
25
  *,
26
26
  extra_files: list[tuple[Path, str]] | None = None,
27
27
  payload_exec: list[tuple[str, bool]] | None = None,
28
+ include_patterns: list[str] | None = None,
29
+ exclude_patterns: list[str] | None = None,
30
+ sources: tuple[Source, ...] | None = None,
28
31
  ) -> str:
29
32
  """
30
33
  sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
31
34
  + sha256(requirements_lock) + spec scalars + base_image + builder_version
32
- + optional extra_files keyed by destination relname (e.g. "package.json").
35
+ + optional resolved include/exclude filter lists + optional extra_files keyed by
36
+ destination relname (e.g. "package.json").
33
37
 
34
38
  Inputs are concatenated with newline separators in a fixed order, then hashed.
35
39
 
@@ -37,6 +41,18 @@ def compute_content_hash(
37
41
  (dest_relname, file_bytes) only - not the host path - so the hash is
38
42
  host-path-independent. Callers with no extras produce byte-identical hashes
39
43
  to v0.1 (the extras section is omitted entirely when extra_files is falsy).
44
+
45
+ include_patterns / exclude_patterns are the RESOLVED per-lambda filter lists.
46
+ They are folded sorted (membership is order-independent, so a pure reorder does
47
+ not re-key) and only when not None, so an explicit empty list (replace-with-empty)
48
+ hashes differently from an unset/None filter. Callers passing None omit the
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.
40
56
  """
41
57
  h = hashlib.sha256()
42
58
 
@@ -62,6 +78,17 @@ def compute_content_hash(
62
78
  h.update(f"base_image={base_image}\n".encode())
63
79
  h.update(f"builder_version={builder_version}\n".encode())
64
80
 
81
+ if include_patterns is not None:
82
+ h.update(b"---include---\n")
83
+ for pat in sorted(include_patterns):
84
+ h.update(pat.encode("utf-8"))
85
+ h.update(b"\n")
86
+ if exclude_patterns is not None:
87
+ h.update(b"---exclude---\n")
88
+ for pat in sorted(exclude_patterns):
89
+ h.update(pat.encode("utf-8"))
90
+ h.update(b"\n")
91
+
65
92
  if extra_files:
66
93
  h.update(b"---extras---\n")
67
94
  # Sort by relname so staging order does not perturb the hash.
@@ -82,4 +109,22 @@ def compute_content_hash(
82
109
  for dest, executable in sorted(payload_exec):
83
110
  h.update(f"{dest}={int(executable)}\n".encode())
84
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
+
85
130
  return h.hexdigest()