repro-lambda 0.1.0__tar.gz → 0.2.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 (56) hide show
  1. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.github/workflows/build.yml +4 -7
  2. repro_lambda-0.2.1/CHANGELOG.md +72 -0
  3. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/PKG-INFO +11 -5
  4. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/README.md +10 -4
  5. repro_lambda-0.2.1/SETUP.md +482 -0
  6. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/pyproject.toml +5 -1
  7. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/__init__.py +1 -1
  8. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/build.py +62 -17
  9. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/cli.py +6 -0
  10. repro_lambda-0.2.1/src/repro_lambda/docker_runner.py +290 -0
  11. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/hasher.py +20 -1
  12. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/manifest.py +36 -8
  13. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/source_stager.py +13 -0
  14. repro_lambda-0.2.1/src/repro_lambda/verify.py +85 -0
  15. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/zip_packager.py +8 -0
  16. repro_lambda-0.2.1/tests/fixtures/sample_nodejs_lambda/handler/index.js +4 -0
  17. repro_lambda-0.2.1/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +18 -0
  18. repro_lambda-0.2.1/tests/fixtures/sample_nodejs_lambda/handler/package.json +9 -0
  19. repro_lambda-0.2.1/tests/fixtures/sample_nodejs_lambda/lambdas.toml +18 -0
  20. repro_lambda-0.2.1/tests/test_build_nodejs.py +99 -0
  21. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_cli_lock.py +29 -0
  22. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_cli_smoke.py +1 -1
  23. repro_lambda-0.2.1/tests/test_docker_runner_nodejs.py +89 -0
  24. repro_lambda-0.2.1/tests/test_e2e_nodejs_lambda.py +75 -0
  25. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_hasher.py +108 -0
  26. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_manifest.py +64 -0
  27. repro_lambda-0.2.1/tests/test_python_byte_compat_regression.py +109 -0
  28. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_source_stager.py +28 -0
  29. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_verify.py +61 -0
  30. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_zip_packager.py +18 -0
  31. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/uv.lock +1 -1
  32. repro_lambda-0.1.0/CHANGELOG.md +0 -26
  33. repro_lambda-0.1.0/src/repro_lambda/docker_runner.py +0 -122
  34. repro_lambda-0.1.0/src/repro_lambda/verify.py +0 -61
  35. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.github/workflows/ci.yml +0 -0
  36. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.github/workflows/publish.yml +0 -0
  37. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.gitignore +0 -0
  38. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/.pre-commit-config.yaml +0 -0
  39. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/LICENSE +0 -0
  40. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/__main__.py +0 -0
  41. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/catalog.py +0 -0
  42. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/git_guard.py +0 -0
  43. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/src/repro_lambda/s3_uploader.py +0 -0
  44. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/__init__.py +0 -0
  45. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/conftest.py +0 -0
  46. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
  47. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
  48. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
  49. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
  50. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_build_integration.py +0 -0
  51. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_catalog.py +0 -0
  52. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_cli_build.py +0 -0
  53. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_docker_runner.py +0 -0
  54. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_e2e_python_lambda.py +0 -0
  55. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_git_guard.py +0 -0
  56. {repro_lambda-0.1.0 → repro_lambda-0.2.1}/tests/test_s3_uploader.py +0 -0
@@ -9,7 +9,7 @@ on:
9
9
  description: Path to lambdas.toml in the caller repo.
10
10
  repro_lambda_version:
11
11
  type: string
12
- default: "0.1.0"
12
+ default: "0.2.1"
13
13
  description: Pinned repro-lambda PyPI version.
14
14
  secrets:
15
15
  aws-dev-role-arn:
@@ -55,9 +55,6 @@ jobs:
55
55
  - uses: actions/checkout@v4
56
56
  - uses: astral-sh/setup-uv@v3
57
57
 
58
- - name: Install repro-lambda
59
- run: uv pip install --system "repro-lambda==${{ inputs.repro_lambda_version }}"
60
-
61
58
  - name: Configure AWS credentials (dev)
62
59
  uses: aws-actions/configure-aws-credentials@v4
63
60
  with:
@@ -67,11 +64,11 @@ jobs:
67
64
  - name: Build (dev bucket)
68
65
  env:
69
66
  REPRO_LAMBDA_BUCKET: dev-ctf-lambda-artifacts
70
- run: repro-lambda build --manifest "${{ inputs.manifest_path }}"
67
+ run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}"
71
68
 
72
69
  - name: Verify reproducible (PR only)
73
70
  if: github.event_name == 'pull_request'
74
- run: repro-lambda build --manifest "${{ inputs.manifest_path }}" --verify --dry-run
71
+ run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}" --verify --dry-run
75
72
 
76
73
  - name: Configure AWS credentials (prod)
77
74
  if: github.ref == 'refs/heads/master' && secrets.aws-prod-role-arn != ''
@@ -84,7 +81,7 @@ jobs:
84
81
  if: github.ref == 'refs/heads/master' && secrets.aws-prod-role-arn != ''
85
82
  env:
86
83
  REPRO_LAMBDA_BUCKET: prod-ctf-lambda-artifacts
87
- run: repro-lambda build --manifest "${{ inputs.manifest_path }}"
84
+ run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}"
88
85
 
89
86
  - name: Commit catalog drift (master only, dev bot)
90
87
  if: github.ref == 'refs/heads/master'
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ ## v0.2.1 - 2026-05-27
4
+
5
+ ### Changed
6
+ - CI workflow: `uvx --from "repro-lambda==<v>" repro-lambda <args>` replaces `uv pip install --system "repro-lambda==<v>"`. uv 0.11+ deprecates the `uv pip` legacy interface for install/uninstall/sync.
7
+
8
+ ### Docs
9
+ - README install instruction switches to `uv tool install repro-lambda` (plus `uvx repro-lambda` ephemeral alternative).
10
+ - SETUP.md examples bumped to `@v0.2.1` / `repro_lambda_version: "0.2.1"`.
11
+
12
+ ### Consumer migration
13
+ - Consumer repos must bump their workflow ref to `uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0.2.1` to receive the uvx-based install. The v0.2.0 workflow ref still works but invokes the deprecated install command.
14
+
15
+ ## v0.2.0 - 2026-05-27
16
+
17
+ ### Added
18
+ - Node.js Lambda packaging (`nodejs20.x`, `nodejs22.x`) via `npm ci --omit=dev --ignore-scripts --cpu=${arch} --os=linux` in the digest-pinned Node base image.
19
+ - Two-container Node build: install in the Node base image, pack the resulting `pkg/` directory inside the digest-pinned Python base image (the Python image's zlib is the only deflate implementation invoked, so macOS arm64 hosts and Linux x86_64 CI produce byte-identical output).
20
+ - `BuilderConfig.base_image_nodejs` (required when any lambda uses `package_manager = "npm"`).
21
+ - `LambdaSpec.package_json` (required for npm specs) + `package_json_resolved` property.
22
+ - `ARCH_TO_NPM_CPU` mapping (`arm64` -> `arm64`, `x86_64` -> `x64`).
23
+ - `build_nodejs_lambda` + `install_nodejs_dependencies` + `pack_in_python_sidecar` in `docker_runner`.
24
+ - `stage_source(... extra_files=[(host_path, dest_relname), ...])` for staging artifacts outside `source_dir` (e.g. `package.json`, `package-lock.json`).
25
+ - `compute_content_hash(... extra_files=...)` keyed by destination relname so npm `package.json` edits bump the cache key.
26
+ - SETUP.md sections for Node.js + Lambda@Edge usage + caveats.
27
+ - Docker-gated end-to-end Node.js reproducibility test (`test_e2e_nodejs_lambda.py`) using a `tslib@2.7.0` fixture.
28
+ - Python byte-compat regression test (`test_python_byte_compat_regression.py`) pinning v0.1 zip output against a digest-pinned base image.
29
+
30
+ ### Changed
31
+ - `build.py` and `verify.py` route per `spec.package_manager` (pip | npm). Both pre-stage source + extras BEFORE the cache-key hash so cache-hit and cache-miss branches see the same hash inputs.
32
+ - `manifest.py` accepts `nodejs20.x` and `nodejs22.x` runtimes and `package_manager = "npm"`.
33
+ - `lock` subcommand skips npm specs (npm uses package-lock.json directly; regenerate with `npm install` upstream).
34
+ - `zip_packager.pack_directory` skips symlinks with a stderr warning (zip cannot preserve link semantics).
35
+ - Docker `--user $(id -u):$(id -g)` on POSIX (skipped on Windows; `sys.platform == "win32"` gate).
36
+ - `_PYTHON_INSTALL_SCRIPT` now declares the full pip platform quartet (`--platform`, `--abi`, `--python-version`, `--implementation`) and strips `REQUESTED` files alongside `RECORD` / `INSTALLER` / `direct_url.json`. Module-level invariance assert keeps `ARCH_TO_DOCKER_PLATFORM` / `ARCH_TO_PIP_PLATFORM` / `ARCH_TO_NPM_CPU` keys in lockstep.
37
+
38
+ ### Compatibility
39
+ - v0.1 zip byte-output preserved (regression-tested by `test_python_byte_compat_regression.py` once the operator records the v0.1.0 reference sha against the pinned base image digest).
40
+ - Content-hash key shifts ONCE at the v0.1 -> v0.2 cut-line because the lockfile is now hashed via the unified `extra_files` channel. v0.1-built zips remain pullable by their old keys (content-addressed S3 storage), so the shift only affects the next rebuild.
41
+
42
+ ### Known caveats
43
+ - npm workspaces NOT supported (single `package.json` per Lambda only).
44
+ - Native deps must ship a `linux-${arch}` binary via `optionalDependencies` in `package-lock.json`; npm cannot cross-compile native modules.
45
+ - Symlinks inside `source_dir` are skipped (zip cannot preserve link semantics). Replace with file contents if your build relies on them.
46
+ - Per-arch lockfiles remain Python-only.
47
+ - Windows host: the `--user` flag is skipped; Docker Desktop's default user mapping applies.
48
+
49
+ ## v0.1.0 - 2026-05-27
50
+
51
+ Initial public release.
52
+
53
+ ### Features
54
+
55
+ - Python 3.11/3.12/3.13 Lambda packaging with `pip install --require-hashes`
56
+ - Byte-reproducible zips via deterministic `zipfile.ZipFile` writes
57
+ (sorted entries, fixed mtime 1980-01-01, 0o755 dirs / 0o644 files / 0o755 executables)
58
+ - Content-hash sha256 cache key (source tree + lockfile + spec + base image digest + builder version)
59
+ - Idempotent S3 upload via `If-None-Match=*`, designed for bucket-policy-enforced immutability
60
+ - `--verify` two-pass byte-reproducibility check
61
+ - `--dry-run` for hash + catalog inspection
62
+ - `--allow-dirty` for local iteration
63
+ - `builds/catalog.json` with bounded 10-entry history per lambda
64
+ - Per-arch lockfile generation via `uv pip compile`
65
+ - Reusable GitHub Actions workflow at `.github/workflows/build.yml`
66
+ - Native arm64 + x86_64 build matrix (no QEMU)
67
+
68
+ ### Not yet supported (planned for v0.2)
69
+
70
+ - Node.js / npm packaging
71
+ - Lambda@Edge-specific constraints (us-east-1 routing, no env vars)
72
+ - Rust runtime
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repro-lambda
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
33
33
  # repro-lambda
34
34
 
35
35
  Build reproducible AWS Lambda packages outside Terraform, optimized for
36
- [terraform-aws-lambda by serverless.tf](https://registry.terraform.io/modules/terraform-aws-modules/lambda/aws/latest).
36
+ [terraform-aws-lambda](https://registry.terraform.io/modules/terraform-aws-modules/lambda/aws/latest) by [serverless.tf](https://serverless.tf/).
37
37
 
38
38
  Produces byte-identical zip files across local dev (macOS) and CI (Linux),
39
39
  uploads to S3 by content-hash key, and lets Terraform read `s3_existing_package`
@@ -54,10 +54,10 @@ See `docs/` for full design.
54
54
 
55
55
  ## Release
56
56
 
57
- Releases are tag-driven. To cut v0.1.1:
57
+ Releases are tag-driven. To cut v0.2.1:
58
58
 
59
- git tag v0.1.1
60
- git push origin v0.1.1
59
+ git tag v0.2.1
60
+ git push origin v0.2.1
61
61
 
62
62
  The `publish.yml` workflow uses PyPI Trusted Publishing (OIDC) — no PyPI token
63
63
  needed in repo secrets. Configure once via PyPI's "Publishing" panel:
@@ -66,3 +66,9 @@ needed in repo secrets. Configure once via PyPI's "Publishing" panel:
66
66
  - Repository: `repro-lambda`
67
67
  - Workflow: `publish.yml`
68
68
  - Environment: (leave blank)
69
+
70
+ ## Setup
71
+
72
+ See [SETUP.md](./SETUP.md) for a copy-paste-able guide to provisioning the
73
+ S3 buckets, IAM OIDC role, and CI workflow needed to use `repro-lambda` in
74
+ your project.
@@ -1,7 +1,7 @@
1
1
  # repro-lambda
2
2
 
3
3
  Build reproducible AWS Lambda packages outside Terraform, optimized for
4
- [terraform-aws-lambda by serverless.tf](https://registry.terraform.io/modules/terraform-aws-modules/lambda/aws/latest).
4
+ [terraform-aws-lambda](https://registry.terraform.io/modules/terraform-aws-modules/lambda/aws/latest) by [serverless.tf](https://serverless.tf/).
5
5
 
6
6
  Produces byte-identical zip files across local dev (macOS) and CI (Linux),
7
7
  uploads to S3 by content-hash key, and lets Terraform read `s3_existing_package`
@@ -22,10 +22,10 @@ See `docs/` for full design.
22
22
 
23
23
  ## Release
24
24
 
25
- Releases are tag-driven. To cut v0.1.1:
25
+ Releases are tag-driven. To cut v0.2.1:
26
26
 
27
- git tag v0.1.1
28
- git push origin v0.1.1
27
+ git tag v0.2.1
28
+ git push origin v0.2.1
29
29
 
30
30
  The `publish.yml` workflow uses PyPI Trusted Publishing (OIDC) — no PyPI token
31
31
  needed in repo secrets. Configure once via PyPI's "Publishing" panel:
@@ -34,3 +34,9 @@ needed in repo secrets. Configure once via PyPI's "Publishing" panel:
34
34
  - Repository: `repro-lambda`
35
35
  - Workflow: `publish.yml`
36
36
  - Environment: (leave blank)
37
+
38
+ ## Setup
39
+
40
+ See [SETUP.md](./SETUP.md) for a copy-paste-able guide to provisioning the
41
+ S3 buckets, IAM OIDC role, and CI workflow needed to use `repro-lambda` in
42
+ your project.
@@ -0,0 +1,482 @@
1
+ # Setting up `repro-lambda` for your project
2
+
3
+ `repro-lambda` builds and uploads Lambda artifacts to S3 outside of Terraform.
4
+ For Terraform to read those artifacts via `s3_existing_package`, you need to
5
+ provision the supporting AWS infrastructure once per AWS account and region.
6
+
7
+ This guide is a copy-paste-able reference. Adapt the names and account IDs to
8
+ your environment.
9
+
10
+ ## Architecture
11
+
12
+ For each environment (e.g. `dev`, `prod`) you typically need:
13
+
14
+ ```
15
+ ${env}-my-lambda-artifacts S3 bucket, region = eu-west-1 (or your primary region)
16
+ ${env}-my-lambda-artifacts-us-east-1 S3 bucket, region = us-east-1 (for Lambda@Edge — optional)
17
+
18
+ gha-lambda-builder-${env} IAM role, assumed via GitHub OIDC by source-repo CI to upload
19
+ ```
20
+
21
+ Both buckets enforce **key-level immutability** via bucket policy:
22
+
23
+ - `s3:PutObject` denied unless `If-None-Match=*` is set (writes are first-write-wins)
24
+ - `s3:DeleteObject` and `s3:DeleteObjectVersion` denied
25
+ - The Lambda service is allowed `s3:GetObject` so functions can load their zip
26
+
27
+ Once an artifact with a content-hash key (`lambdas/<name>/<sha256>.zip`) is
28
+ uploaded, the key is permanently bound to those bytes. `repro-lambda` treats
29
+ HTTP 412 PreconditionFailed on a duplicate upload as success.
30
+
31
+ ## Terraform — per-account bootstrap
32
+
33
+ The Terraform below assumes you already have a configured AWS provider in the
34
+ target account. Drop it into your bootstrap directory (or anywhere you provision
35
+ account-wide infrastructure that has no dependencies on other Terraform state).
36
+
37
+ ```hcl
38
+ # us-east-1 is needed only for Lambda@Edge artifacts. Skip if you don't use L@E.
39
+ provider "aws" {
40
+ alias = "us_east_1"
41
+ region = "us-east-1"
42
+ }
43
+
44
+ locals {
45
+ env = "dev" # change per environment
46
+ lambda_artifacts_bucket_primary = "${local.env}-my-lambda-artifacts"
47
+ lambda_artifacts_bucket_us_east_1 = "${local.env}-my-lambda-artifacts-us-east-1"
48
+
49
+ # GitHub OIDC subjects allowed to assume the builder role.
50
+ # Pattern: repo:<owner>/<repo>:* — narrow to specific refs in production.
51
+ lambda_builder_oidc_subjects = [
52
+ "repo:my-org/my-source-repo:*",
53
+ ]
54
+ }
55
+
56
+ # Immutability policy applied to every artifact bucket.
57
+ data "aws_iam_policy_document" "lambda_artifacts_immutability" {
58
+ for_each = toset([
59
+ local.lambda_artifacts_bucket_primary,
60
+ local.lambda_artifacts_bucket_us_east_1,
61
+ ])
62
+
63
+ statement {
64
+ sid = "DenyOverwrites"
65
+ effect = "Deny"
66
+
67
+ principals {
68
+ type = "*"
69
+ identifiers = ["*"]
70
+ }
71
+
72
+ actions = ["s3:PutObject"]
73
+ resources = ["arn:aws:s3:::${each.value}/*"]
74
+
75
+ condition {
76
+ test = "StringNotEquals"
77
+ variable = "s3:If-None-Match"
78
+ values = ["*"]
79
+ }
80
+ }
81
+
82
+ statement {
83
+ sid = "DenyDelete"
84
+ effect = "Deny"
85
+
86
+ principals {
87
+ type = "*"
88
+ identifiers = ["*"]
89
+ }
90
+
91
+ actions = [
92
+ "s3:DeleteObject",
93
+ "s3:DeleteObjectVersion",
94
+ ]
95
+ resources = ["arn:aws:s3:::${each.value}/*"]
96
+ }
97
+
98
+ statement {
99
+ sid = "AllowLambdaServiceRead"
100
+ effect = "Allow"
101
+
102
+ principals {
103
+ type = "Service"
104
+ identifiers = ["lambda.amazonaws.com"]
105
+ }
106
+
107
+ actions = ["s3:GetObject"]
108
+ resources = ["arn:aws:s3:::${each.value}/*"]
109
+
110
+ condition {
111
+ test = "StringEquals"
112
+ variable = "aws:SourceAccount"
113
+ values = [data.aws_caller_identity.current.account_id]
114
+ }
115
+ }
116
+ }
117
+
118
+ data "aws_caller_identity" "current" {}
119
+
120
+ # Primary-region bucket
121
+ module "lambda_artifacts_bucket_primary" {
122
+ source = "terraform-aws-modules/s3-bucket/aws"
123
+ version = "~> 4.0"
124
+
125
+ bucket = local.lambda_artifacts_bucket_primary
126
+
127
+ versioning = {
128
+ enabled = false # sha-as-key IS the versioning
129
+ }
130
+
131
+ server_side_encryption_configuration = {
132
+ rule = {
133
+ apply_server_side_encryption_by_default = {
134
+ sse_algorithm = "AES256"
135
+ }
136
+ }
137
+ }
138
+
139
+ attach_policy = true
140
+ policy = data.aws_iam_policy_document.lambda_artifacts_immutability[local.lambda_artifacts_bucket_primary].json
141
+
142
+ block_public_acls = true
143
+ block_public_policy = true
144
+ ignore_public_acls = true
145
+ restrict_public_buckets = true
146
+
147
+ force_destroy = false
148
+ }
149
+
150
+ # us-east-1 bucket — only needed for Lambda@Edge. Remove if not using L@E.
151
+ module "lambda_artifacts_bucket_us_east_1" {
152
+ source = "terraform-aws-modules/s3-bucket/aws"
153
+ version = "~> 4.0"
154
+
155
+ providers = {
156
+ aws = aws.us_east_1
157
+ }
158
+
159
+ bucket = local.lambda_artifacts_bucket_us_east_1
160
+
161
+ versioning = {
162
+ enabled = false
163
+ }
164
+
165
+ server_side_encryption_configuration = {
166
+ rule = {
167
+ apply_server_side_encryption_by_default = {
168
+ sse_algorithm = "AES256"
169
+ }
170
+ }
171
+ }
172
+
173
+ attach_policy = true
174
+ policy = data.aws_iam_policy_document.lambda_artifacts_immutability[local.lambda_artifacts_bucket_us_east_1].json
175
+
176
+ block_public_acls = true
177
+ block_public_policy = true
178
+ ignore_public_acls = true
179
+ restrict_public_buckets = true
180
+
181
+ force_destroy = false
182
+ }
183
+
184
+ # OIDC role assumed by GitHub Actions in source repos to upload artifacts.
185
+ module "gha_lambda_builder_role" {
186
+ source = "terraform-aws-modules/iam/aws//modules/iam-role"
187
+ version = "~> 6.0"
188
+
189
+ name = "gha-lambda-builder-${local.env}"
190
+
191
+ enable_github_oidc = true
192
+ oidc_wildcard_subjects = local.lambda_builder_oidc_subjects
193
+
194
+ create_inline_policy = true
195
+ inline_policy_permissions = {
196
+ WriteLambdaArtifacts = {
197
+ actions = [
198
+ "s3:PutObject",
199
+ "s3:GetObject",
200
+ "s3:HeadObject",
201
+ "s3:ListBucket",
202
+ ]
203
+ resources = [
204
+ module.lambda_artifacts_bucket_primary.s3_bucket_arn,
205
+ "${module.lambda_artifacts_bucket_primary.s3_bucket_arn}/*",
206
+ module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn,
207
+ "${module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn}/*",
208
+ ]
209
+ }
210
+ }
211
+ }
212
+
213
+ output "lambda_artifacts_bucket_primary_arn" {
214
+ value = module.lambda_artifacts_bucket_primary.s3_bucket_arn
215
+ }
216
+
217
+ output "lambda_artifacts_bucket_us_east_1_arn" {
218
+ value = module.lambda_artifacts_bucket_us_east_1.s3_bucket_arn
219
+ }
220
+
221
+ output "gha_lambda_builder_role_arn" {
222
+ value = module.gha_lambda_builder_role.arn
223
+ }
224
+ ```
225
+
226
+ > Note: if you only use a single region (no Lambda@Edge), delete the
227
+ > `lambda_artifacts_bucket_us_east_1` module and the `us_east_1` provider alias.
228
+ > The OIDC role policy only needs the primary bucket then.
229
+
230
+ Apply the Terraform once per environment. The bucket names and role ARN are
231
+ referenced by your source repos' CI workflows below.
232
+
233
+ ## GitHub OIDC provider
234
+
235
+ If your account doesn't already have the GitHub Actions OIDC provider, the
236
+ `terraform-aws-modules/iam` collection includes a module for it:
237
+
238
+ ```hcl
239
+ module "iam_github_oidc_provider" {
240
+ source = "terraform-aws-modules/iam/aws//modules/iam-oidc-provider"
241
+ version = "~> 6.0"
242
+ }
243
+ ```
244
+
245
+ The provider is shared per AWS account — declare it once, not per environment.
246
+
247
+ ## Source-repo CI workflow
248
+
249
+ In each repo that builds a Lambda, add a thin caller workflow that delegates to
250
+ the reusable workflow in `antonbabenko/repro-lambda`:
251
+
252
+ ```yaml
253
+ # .github/workflows/build-lambdas.yml
254
+ name: build-lambdas
255
+
256
+ on:
257
+ pull_request:
258
+ push:
259
+ branches: [master]
260
+
261
+ jobs:
262
+ build:
263
+ uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0.2.1
264
+ with:
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 }}
270
+ ```
271
+
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
277
+
278
+ ## Per-Lambda manifest
279
+
280
+ Each consumer repo defines a `lambdas.toml` at its root:
281
+
282
+ ```toml
283
+ [[lambda]]
284
+ logical_name = "app"
285
+ source_dir = "src/app"
286
+ requirements_lock = "src/app/requirements.${arch}.lock"
287
+ runtime = "python3.13"
288
+ arch = "arm64"
289
+ handler = "app.lambda_handler"
290
+ region = "eu-west-1"
291
+ package_manager = "pip"
292
+ lambda_at_edge = false
293
+ hash_extra = ""
294
+
295
+ [builder]
296
+ base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<pinned-digest>"
297
+ include_patterns = ["**/*.py", "**/*.json"]
298
+ exclude_patterns = [".venv/**", "__pycache__/**", "*.pyc", ".git/**", ".env*"]
299
+ ```
300
+
301
+ Pin the `base_image_python` to a specific digest with `docker pull <image> &&
302
+ docker inspect --format='{{index .RepoDigests 0}}' <image>` — never use a
303
+ floating tag in production.
304
+
305
+ ## Terraform consumer — `s3_existing_package`
306
+
307
+ In the Terraform that creates your Lambda function, point at the artifact in
308
+ S3 instead of building inline:
309
+
310
+ ```hcl
311
+ locals {
312
+ lambda_manifest = jsondecode(file("${path.module}/builds/catalog.json"))
313
+ }
314
+
315
+ module "lambda_app" {
316
+ source = "terraform-aws-modules/lambda/aws"
317
+ version = "~> 8.0"
318
+
319
+ function_name = "my-app"
320
+ runtime = "python3.13"
321
+ architectures = ["arm64"]
322
+ handler = "app.lambda_handler"
323
+ publish = true
324
+
325
+ s3_existing_package = {
326
+ bucket = "${var.env}-my-lambda-artifacts"
327
+ key = "lambdas/app/${local.lambda_manifest.lambdas.app.current}.zip"
328
+ object_version = null
329
+ }
330
+ }
331
+ ```
332
+
333
+ For Lambda@Edge, point at the `-us-east-1` bucket and set `lambda_at_edge = true`
334
+ in the Terraform module.
335
+
336
+ ## Smoke test
337
+
338
+ After applying the bootstrap Terraform and configuring CI secrets:
339
+
340
+ ```bash
341
+ # In your consumer repo
342
+ gh workflow run build-lambdas.yml
343
+ # Wait for completion, then check that the artifact landed
344
+ aws s3 ls s3://dev-my-lambda-artifacts/lambdas/app/
345
+ ```
346
+
347
+ The first PR after migration should show a Terraform plan whose only diff is the
348
+ `s3_key` change. If you see `last_modified`, `qualified_arn`, `version`,
349
+ `local_file.archive_plan`, or `null_resource.archive` mentioned, the migration
350
+ is incomplete — review your Lambda module call and remove the legacy build
351
+ attributes (`source_path`, `build_in_docker`, `trigger_on_package_timestamp`,
352
+ `ignore_source_code_hash`, `hash_extra`, `local_existing_package`,
353
+ `store_on_s3`).
354
+
355
+ ## Troubleshooting
356
+
357
+ - **`403 AccessDenied` on upload**: the OIDC role doesn't have `s3:PutObject` on
358
+ the bucket. Check the inline policy resources include both the bucket ARN and
359
+ `${bucket_arn}/*`.
360
+ - **`PreconditionFailed` on every upload**: the artifact already exists with
361
+ that sha. This is the expected cache-hit behavior; `repro-lambda` treats it
362
+ as success.
363
+ - **AWS CLI multipart upload fails with 403**: bucket policy denies multipart
364
+ parts. Pin the multipart threshold above your Lambda size cap:
365
+ `aws configure set s3.multipart_threshold 300MB`.
366
+ - **Plan still shows noisy diff after migration**: verify the consumer Terraform
367
+ removed every legacy attribute and that the catalog sha read by `jsondecode`
368
+ matches the sha in the S3 key. The plan diff should be exactly `~ s3_key =
369
+ "<old>.zip" -> "<new>.zip"` and nothing else.
370
+
371
+ ## Node.js (npm) Lambdas
372
+
373
+ `repro-lambda` v0.2 adds Node.js Lambda packaging. The build runs `npm ci` in
374
+ the digest-pinned Node base image, then packs the resulting `pkg/` directory
375
+ inside the digest-pinned Python base image (Python is the only language with
376
+ deterministic-zip tooling pre-installed in the AWS Lambda runtime images, so
377
+ its zlib is the only deflate implementation invoked - macOS arm64 hosts and
378
+ Linux x86_64 CI produce byte-identical output).
379
+
380
+ ### Manifest fields for npm specs
381
+
382
+ ```toml
383
+ [[lambda]]
384
+ logical_name = "api"
385
+ source_dir = "src/api"
386
+ requirements_lock = "src/api/package-lock.json" # npm lockfile
387
+ package_json = "src/api/package.json" # REQUIRED for npm specs
388
+ runtime = "nodejs22.x" # or "nodejs20.x"
389
+ arch = "x86_64" # or "arm64"
390
+ handler = "index.handler"
391
+ region = "eu-west-1"
392
+ package_manager = "npm"
393
+ lambda_at_edge = false
394
+ hash_extra = ""
395
+
396
+ [builder]
397
+ base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:<pinned-digest>"
398
+ base_image_nodejs = "public.ecr.aws/lambda/nodejs:22@sha256:<pinned-digest>"
399
+ include_patterns = ["**/*.js", "**/*.json"]
400
+ exclude_patterns = [".git/**", "node_modules/**", "*.md", "LICENSE*", "CHANGELOG*"]
401
+ ```
402
+
403
+ Pin both base images by digest:
404
+
405
+ ```bash
406
+ docker pull public.ecr.aws/lambda/nodejs:22
407
+ docker inspect --format='{{index .RepoDigests 0}}' public.ecr.aws/lambda/nodejs:22
408
+ ```
409
+
410
+ ### Lockfile regeneration
411
+
412
+ `repro-lambda lock` only regenerates per-arch Python lockfiles via `uv pip
413
+ compile`. For npm specs it prints `skip <name>: npm uses package-lock.json
414
+ directly`. Regenerate the npm lockfile upstream with:
415
+
416
+ ```bash
417
+ cd src/api
418
+ npm install --package-lock-only
419
+ git add package-lock.json
420
+ ```
421
+
422
+ The lockfile and `package.json` both contribute to the artifact content hash;
423
+ editing either bumps the S3 key.
424
+
425
+ ## Lambda@Edge example
426
+
427
+ Lambda@Edge functions must be deployed to `us-east-1`, regardless of where the
428
+ CloudFront distribution serves traffic. Set `region = "us-east-1"` and
429
+ `lambda_at_edge = true` in the manifest; `repro-lambda` will upload to the
430
+ `*-us-east-1` artifact bucket automatically.
431
+
432
+ ```toml
433
+ [[lambda]]
434
+ logical_name = "edge"
435
+ source_dir = "src/edge"
436
+ requirements_lock = "src/edge/package-lock.json"
437
+ package_json = "src/edge/package.json"
438
+ runtime = "nodejs22.x"
439
+ arch = "x86_64" # L@E currently requires x86_64
440
+ handler = "index.handler"
441
+ region = "us-east-1"
442
+ package_manager = "npm"
443
+ lambda_at_edge = true
444
+ ```
445
+
446
+ Consumer Terraform points at the us-east-1 bucket:
447
+
448
+ ```hcl
449
+ module "lambda_edge" {
450
+ source = "terraform-aws-modules/lambda/aws"
451
+ version = "~> 8.0"
452
+ providers = { aws = aws.us_east_1 }
453
+
454
+ function_name = "my-edge"
455
+ runtime = "nodejs22.x"
456
+ architectures = ["x86_64"]
457
+ handler = "index.handler"
458
+ publish = true
459
+ lambda_at_edge = true
460
+
461
+ s3_existing_package = {
462
+ bucket = "${var.env}-my-lambda-artifacts-us-east-1"
463
+ key = "lambdas/edge/${local.lambda_manifest.lambdas.edge.current}.zip"
464
+ }
465
+ }
466
+ ```
467
+
468
+ ## Caveats
469
+
470
+ - **No npm workspaces.** v0.2 supports a single `package.json` per Lambda. If
471
+ your repo uses workspaces, copy the published package into a single-package
472
+ layout per Lambda before invoking `repro-lambda`.
473
+ - **Native dependencies need `optionalDependencies` arms.** `npm ci --cpu=${arch}
474
+ --os=linux` cannot cross-compile native modules. A dep with native code must
475
+ ship a `linux-${arch}` binary via its `package-lock.json`
476
+ `optionalDependencies` arm (the lockfile-v3 standard mechanism). If it
477
+ doesn't, the build runs on the host arch and may produce a non-portable
478
+ artifact.
479
+ - **Symlinks in source are skipped.** `pack_directory` skips symlinks and
480
+ prints a stderr warning; the resulting zip cannot preserve link semantics.
481
+ If your build relies on symlinks (e.g. monorepo references), replace them
482
+ with the file contents.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repro-lambda"
3
- version = "0.1.0"
3
+ version = "0.2.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"
@@ -56,6 +56,10 @@ target-version = "py311"
56
56
  [tool.ruff.lint]
57
57
  select = ["E", "F", "I", "B", "UP", "SIM"]
58
58
 
59
+ [tool.ruff.lint.per-file-ignores]
60
+ # Shell-script string literals inside docker_runner cannot be line-wrapped.
61
+ "src/repro_lambda/docker_runner.py" = ["E501"]
62
+
59
63
  [tool.pytest.ini_options]
60
64
  testpaths = ["tests"]
61
65
  addopts = "-ra --strict-markers"
@@ -1,3 +1,3 @@
1
1
  """repro-lambda — reproducible AWS Lambda packaging outside Terraform."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.1"