repro-lambda 0.9.0__tar.gz → 0.10.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 (86) hide show
  1. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/build.yml +53 -15
  2. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/promote.yml +19 -4
  3. repro_lambda-0.10.0/.release-please-manifest.json +3 -0
  4. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/CHANGELOG.md +23 -0
  5. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/PKG-INFO +2 -2
  6. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/USAGE.md +37 -7
  7. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/pyproject.toml +4 -2
  8. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/__init__.py +1 -1
  9. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/build.py +70 -8
  10. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/catalog.py +15 -0
  11. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/cli.py +56 -1
  12. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/hasher.py +5 -1
  13. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/promote.py +23 -2
  14. repro_lambda-0.10.0/src/repro_lambda/s3_uploader.py +161 -0
  15. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/verify.py +3 -0
  16. repro_lambda-0.10.0/tests/test_artifact_integrity.py +337 -0
  17. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_build_integration.py +3 -0
  18. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_hash_contract.py +15 -2
  19. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_promote.py +140 -9
  20. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/uv.lock +2 -2
  21. repro_lambda-0.9.0/.release-please-manifest.json +0 -3
  22. repro_lambda-0.9.0/src/repro_lambda/s3_uploader.py +0 -72
  23. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/ci.yml +0 -0
  24. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/move-major-tag.yml +0 -0
  25. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/publish.yml +0 -0
  26. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/release.yml +0 -0
  27. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.gitignore +0 -0
  28. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.pre-commit-config.yaml +0 -0
  29. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/LICENSE +0 -0
  30. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/README.md +0 -0
  31. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/SETUP.md +0 -0
  32. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/assets/social-preview.html +0 -0
  33. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/assets/social-preview.png +0 -0
  34. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/README.md +0 -0
  35. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/builds/catalog.json +0 -0
  36. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/lambdas.toml +0 -0
  37. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/main.tf +0 -0
  38. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/outputs.tf +0 -0
  39. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/src/app/app.py +0 -0
  40. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/src/app/requirements.arm64.lock +0 -0
  41. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/variables.tf +0 -0
  42. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/versions.tf +0 -0
  43. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/release-please-config.json +0 -0
  44. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/__main__.py +0 -0
  45. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/docker_runner.py +0 -0
  46. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/git_guard.py +0 -0
  47. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/manifest.py +0 -0
  48. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/source_locker.py +0 -0
  49. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/source_stager.py +0 -0
  50. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/sources.py +0 -0
  51. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/zip_packager.py +0 -0
  52. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/__init__.py +0 -0
  53. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/conftest.py +0 -0
  54. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
  55. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
  56. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
  57. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
  58. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
  59. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
  60. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
  61. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
  62. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_build_nodejs.py +0 -0
  63. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_catalog.py +0 -0
  64. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_cli_build.py +0 -0
  65. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_cli_lock.py +0 -0
  66. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_cli_smoke.py +0 -0
  67. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_compile_bytecode.py +0 -0
  68. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_docker_runner.py +0 -0
  69. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_docker_runner_nodejs.py +0 -0
  70. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_e2e_nodejs_lambda.py +0 -0
  71. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_e2e_python_lambda.py +0 -0
  72. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_extra_files.py +0 -0
  73. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_git_guard.py +0 -0
  74. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_hasher.py +0 -0
  75. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_manifest.py +0 -0
  76. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_per_lambda_builder.py +0 -0
  77. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_python_byte_compat_regression.py +0 -0
  78. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_s3_uploader.py +0 -0
  79. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_source_locker.py +0 -0
  80. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_source_stager.py +0 -0
  81. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_sources.py +0 -0
  82. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_sources_hash.py +0 -0
  83. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_sources_schema.py +0 -0
  84. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_verify.py +0 -0
  85. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_zip_excludes.py +0 -0
  86. {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_zip_packager.py +0 -0
@@ -7,6 +7,14 @@ on:
7
7
  type: string
8
8
  default: lambdas.toml
9
9
  description: Path to lambdas.toml in the caller repo.
10
+ repro_lambda_version:
11
+ type: string
12
+ required: false
13
+ default: "0.10.0" # bump by hand on release: the release token cannot edit workflow files
14
+ description: >-
15
+ Exact repro-lambda release run by uvx. Defaults to the release that shipped this
16
+ workflow, so a caller pinning `uses:` to a commit also pins the tool. Callers
17
+ that pin anything else (e.g. a pin job's dry run) must pass the same version.
10
18
  aws_dev_role_arn:
11
19
  type: string
12
20
  required: true
@@ -61,8 +69,8 @@ jobs:
61
69
  outputs:
62
70
  arches: ${{ steps.parse.outputs.arches }}
63
71
  steps:
64
- - uses: actions/checkout@v7
65
- - uses: astral-sh/setup-uv@v8.2.0
72
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
73
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
66
74
  - id: parse
67
75
  shell: bash
68
76
  run: |
@@ -89,38 +97,68 @@ jobs:
89
97
  permissions:
90
98
  id-token: write
91
99
  contents: write
100
+ env:
101
+ # AWS, uploads and catalog commits run only for a push or a manual dispatch on the
102
+ # caller's default branch. Every other event (pull_request, pull_request_target,
103
+ # other branches, tags) gets no role: artifact keys are input hashes anyone with the
104
+ # source can compute and the buckets are first-write-wins.
105
+ TRUSTED: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}
106
+ REPRO_LAMBDA_FROM: repro-lambda==${{ inputs.repro_lambda_version }}
92
107
  steps:
93
- - uses: actions/checkout@v7
94
- - uses: astral-sh/setup-uv@v8.2.0
108
+ # Validate before any step interpolates it (passed through env:, never inlined).
109
+ - name: Validate repro_lambda_version
110
+ env:
111
+ V: ${{ inputs.repro_lambda_version }}
112
+ run: |
113
+ [[ "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::repro_lambda_version must be X.Y.Z, got '$V'"; exit 1; }
114
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
115
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
95
116
 
96
117
  # Content-addressed source cache, scoped per-arch (a source's sha256 is its key, so a
97
118
  # re-pin changes the manifest hash -> fresh fetch; restore-keys reuses the prior cache).
98
119
  - name: Cache fetched sources
99
- uses: actions/cache@v4
120
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
100
121
  with:
101
122
  path: builds/.sources-cache
102
123
  key: repro-sources-${{ matrix.arch }}-${{ hashFiles(inputs.manifest_path) }}
103
124
  restore-keys: |
104
125
  repro-sources-${{ matrix.arch }}-
105
126
 
127
+ # Untrusted events never assume the dev role or write the artifact bucket (see
128
+ # TRUSTED). A pull request only runs the local reproducibility check below. Callers
129
+ # should also scope the role's OIDC trust to their default branch.
106
130
  - name: Configure AWS credentials (dev)
107
- uses: aws-actions/configure-aws-credentials@v6
131
+ if: env.TRUSTED == 'true'
132
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
108
133
  with:
109
134
  role-to-assume: ${{ inputs.aws_dev_role_arn }}
110
135
  aws-region: eu-west-1
111
136
 
112
137
  - name: Build (dev bucket)
138
+ if: env.TRUSTED == 'true'
113
139
  env:
114
140
  REPRO_LAMBDA_BUCKET: ${{ inputs.dev_bucket }}
115
141
  REPRO_LAMBDA_SOURCES_TOKEN: ${{ secrets.sources_token }}
116
- run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}" --json-out "$RUNNER_TEMP/build-summary.json"
142
+ run: uvx --from "$REPRO_LAMBDA_FROM" repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}" --reproduce-cache-hits --json-out "$RUNNER_TEMP/build-summary.json"
117
143
 
118
144
  # Render the dev build's content shas + a copy-paste promotion command per function
119
145
  # into the run's Step Summary (the build JSON is otherwise buried in step logs).
120
146
  # NB: the JSON lives in RUNNER_TEMP (outside the workspace), so hashFiles() can't guard
121
147
  # it - run on success() and skip inside the script if the file is somehow absent.
148
+ # The build JSON carries each artifact's zip_sha256 / code_sha256: bytes this run
149
+ # built, or rebuilt and matched (--reproduce-cache-hits), never a writer's record.
150
+ # A caller's pin job downloads it to pin those bytes, not just the input-hash key.
151
+ - name: Upload build summary (pin input)
152
+ if: env.TRUSTED == 'true'
153
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
154
+ with:
155
+ name: repro-lambda-build-summary-${{ matrix.arch }}
156
+ path: ${{ runner.temp }}/build-summary.json
157
+ if-no-files-found: error
158
+ retention-days: 7
159
+
122
160
  - name: Build summary (content shas + promote commands)
123
- if: success()
161
+ if: success() && env.TRUSTED == 'true'
124
162
  env:
125
163
  SUMMARY_JSON: ${{ runner.temp }}/build-summary.json
126
164
  PROMOTION_REPO: ${{ inputs.promotion_repo }}
@@ -188,24 +226,24 @@ jobs:
188
226
 
189
227
  - name: Verify reproducible (PR only)
190
228
  if: github.event_name == 'pull_request'
191
- run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}" --verify --dry-run
229
+ run: uvx --from "$REPRO_LAMBDA_FROM" repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}" --verify --dry-run
192
230
 
193
231
  - name: Configure AWS credentials (prod)
194
- if: github.ref == 'refs/heads/master' && inputs.aws_prod_role_arn != ''
195
- uses: aws-actions/configure-aws-credentials@v6
232
+ if: env.TRUSTED == 'true' && inputs.aws_prod_role_arn != ''
233
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
196
234
  with:
197
235
  role-to-assume: ${{ inputs.aws_prod_role_arn }}
198
236
  aws-region: eu-west-1
199
237
 
200
238
  - name: Build (prod bucket)
201
- if: github.ref == 'refs/heads/master' && inputs.aws_prod_role_arn != ''
239
+ if: env.TRUSTED == 'true' && inputs.aws_prod_role_arn != ''
202
240
  env:
203
241
  REPRO_LAMBDA_BUCKET: ${{ inputs.prod_bucket }}
204
242
  REPRO_LAMBDA_SOURCES_TOKEN: ${{ secrets.sources_token }}
205
- run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
243
+ run: uvx --from "$REPRO_LAMBDA_FROM" repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}" --reproduce-cache-hits
206
244
 
207
- - name: Commit catalog drift (master only, dev bot)
208
- if: github.ref == 'refs/heads/master'
245
+ - name: Commit catalog drift (default branch only, dev bot)
246
+ if: env.TRUSTED == 'true'
209
247
  run: |
210
248
  if [[ -n "$(git status --porcelain builds/catalog.json)" ]]; then
211
249
  git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -10,6 +10,14 @@ on:
10
10
  type: string
11
11
  default: lambdas.toml
12
12
  description: Path to lambdas.toml in the caller repo.
13
+ repro_lambda_version:
14
+ type: string
15
+ required: false
16
+ default: "0.10.0" # bump by hand on release: the release token cannot edit workflow files
17
+ description: >-
18
+ Exact repro-lambda release run by uvx. Defaults to the release that shipped this
19
+ workflow, so a caller pinning `uses:` to a commit also pins the tool. Callers
20
+ that pin anything else (e.g. a pin job's dry run) must pass the same version.
13
21
  source_sha:
14
22
  type: string
15
23
  required: true
@@ -36,7 +44,14 @@ jobs:
36
44
  runs-on: ubuntu-24.04
37
45
  env:
38
46
  SOURCE_SHA: ${{ inputs.source_sha }}
47
+ REPRO_LAMBDA_FROM: repro-lambda==${{ inputs.repro_lambda_version }}
39
48
  steps:
49
+ # Validate before any step interpolates it (passed through env:, never inlined).
50
+ - name: Validate repro_lambda_version
51
+ env:
52
+ V: ${{ inputs.repro_lambda_version }}
53
+ run: |
54
+ [[ "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::repro_lambda_version must be X.Y.Z, got '$V'"; exit 1; }
40
55
  # Validate source_sha BEFORE checkout to defend against script injection
41
56
  # via the inputs.source_sha expression (passed through env: + bash regex).
42
57
  - name: Validate source_sha shape (40-char hex)
@@ -46,7 +61,7 @@ jobs:
46
61
  exit 1
47
62
  fi
48
63
 
49
- - uses: actions/checkout@v7
64
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
50
65
  with:
51
66
  ref: ${{ env.SOURCE_SHA }}
52
67
  fetch-depth: 0
@@ -59,10 +74,10 @@ jobs:
59
74
  exit 1
60
75
  fi
61
76
 
62
- - uses: astral-sh/setup-uv@v8.2.0
77
+ - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
63
78
 
64
79
  - name: Configure AWS credentials (prod promoter)
65
- uses: aws-actions/configure-aws-credentials@v6
80
+ uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
66
81
  with:
67
82
  role-to-assume: ${{ inputs.promoter_role_arn }}
68
83
  aws-region: eu-west-1
@@ -72,4 +87,4 @@ jobs:
72
87
  REPRO_LAMBDA_DEV_BUCKET: ${{ inputs.dev_bucket }}
73
88
  REPRO_LAMBDA_PROD_BUCKET: ${{ inputs.prod_bucket }}
74
89
  MANIFEST_PATH: ${{ inputs.manifest_path }}
75
- run: uvx --from repro-lambda repro-lambda promote --manifest "$MANIFEST_PATH"
90
+ run: uvx --from "$REPRO_LAMBDA_FROM" repro-lambda promote --manifest "$MANIFEST_PATH"
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.10.0"
3
+ }
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0](https://github.com/antonbabenko/repro-lambda/compare/v0.9.1...v0.10.0) (2026-11-10)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * HASH_CONTRACT is 0.10.0, so every artifact gets a new key on its next build. `promote` now requires a trusted expected sha256 (`--expected-sha256` or the committed catalog) and `--allow-unrecorded` is removed. Trusted builds pass `--reproduce-cache-hits` and a pin-grade row needs a matching S3 ChecksumSHA256.
9
+
10
+ ### Features
11
+
12
+ * verify artifact bytes against a recorded zip sha256 ([#26](https://github.com/antonbabenko/repro-lambda/issues/26)) ([f2166d6](https://github.com/antonbabenko/repro-lambda/commit/f2166d6b76d7cfdf6be6029439ead5feb58a053a))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **release:** stop release-please editing workflow files ([#27](https://github.com/antonbabenko/repro-lambda/issues/27)) ([ca7d18a](https://github.com/antonbabenko/repro-lambda/commit/ca7d18afb0ec512a810ad74d78bb4149812eb96d))
18
+
19
+ ## [0.9.1](https://github.com/antonbabenko/repro-lambda/compare/v0.9.0...v0.9.1) (2026-11-09)
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **deps:** cap typer below 0.29 ([#24](https://github.com/antonbabenko/repro-lambda/issues/24)) ([2e535af](https://github.com/antonbabenko/repro-lambda/commit/2e535afb3bdb9e47c06f8ce32bd23185badcaa0f))
25
+
3
26
  ## [0.9.0](https://github.com/antonbabenko/repro-lambda/compare/v0.8.2...v0.9.0) (2026-10-30)
4
27
 
5
28
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: repro-lambda
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -21,7 +21,7 @@ Requires-Python: >=3.11
21
21
  Requires-Dist: boto3>=1.34
22
22
  Requires-Dist: repro-zipfile>=0.3.1
23
23
  Requires-Dist: tomlkit>=0.13
24
- Requires-Dist: typer>=0.12
24
+ Requires-Dist: typer<0.29,>=0.12
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: moto[s3]>=5; extra == 'dev'
27
27
  Requires-Dist: pre-commit>=3.7; extra == 'dev'
@@ -20,8 +20,14 @@ on:
20
20
 
21
21
  jobs:
22
22
  build:
23
- uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
23
+ # The job that receives id-token: write gets a reusable workflow you cannot move
24
+ # under it: pin the commit of a release tag, and the matching tool version.
25
+ uses: antonbabenko/repro-lambda/.github/workflows/build.yml@<release commit sha> # v0.x.y
26
+ permissions:
27
+ contents: write
28
+ id-token: write
24
29
  with:
30
+ repro_lambda_version: 0.x.y
25
31
  manifest_path: lambdas.toml
26
32
  aws_dev_role_arn: arn:aws:iam::<dev-account-id>:role/gha-lambda-builder-dev
27
33
  dev_bucket: <dev-env>-my-lambda-artifacts
@@ -30,9 +36,13 @@ jobs:
30
36
  # prod_bucket: <prod-env>-my-lambda-artifacts
31
37
  ```
32
38
 
33
- Pin the reusable workflow with the sliding major tag `@v0` - it auto-moves to the
34
- latest backward-compatible 0.x release on every tag (switch to `@v1` once repro-lambda
35
- ships 1.0). The role ARNs are not secrets: the boundary is the OIDC trust policy plus
39
+ Pin the reusable workflow to the commit of a release tag, not the sliding `@v0`:
40
+ the job holds `id-token: write`, so whoever can move the tag chooses the code that
41
+ assumes your role. The workflow skips AWS for every event except a push or manual
42
+ dispatch on your default branch, and still scope the role's OIDC trust to
43
+ `repo:<owner>/<repo>:ref:refs/heads/<default branch>`. It uploads the build JSON
44
+ (`repro-lambda-build-summary-<arch>`), whose `zip_sha256` / `code_sha256` are what a
45
+ pin job should record next to each key. The role ARNs are not secrets: the boundary is the OIDC trust policy plus
36
46
  the key-level bucket immutability, so they are plain inputs (only the account IDs,
37
47
  which are public), not stored secrets.
38
48
 
@@ -185,8 +195,14 @@ In CI, pass the token to the reusable `build.yml` as the `sources_token` secret:
185
195
  ```yaml
186
196
  jobs:
187
197
  build:
188
- uses: antonbabenko/repro-lambda/.github/workflows/build.yml@v0
198
+ # The job that receives id-token: write gets a reusable workflow you cannot move
199
+ # under it: pin the commit of a release tag, and the matching tool version.
200
+ uses: antonbabenko/repro-lambda/.github/workflows/build.yml@<release commit sha> # v0.x.y
201
+ permissions:
202
+ contents: write
203
+ id-token: write
189
204
  with:
205
+ repro_lambda_version: 0.x.y
190
206
  manifest_path: lambdas.toml
191
207
  aws_dev_role_arn: arn:aws:iam::<account>:role/<dev-builder-role>
192
208
  dev_bucket: <dev-env>-my-lambda-artifacts
@@ -252,8 +268,22 @@ attributes (`source_path`, `build_in_docker`, `trigger_on_package_timestamp`,
252
268
  the bucket. Check the inline policy resources include both the bucket ARN and
253
269
  `${bucket_arn}/*`.
254
270
  - **`PreconditionFailed` on every upload**: the artifact already exists with
255
- that sha. This is the expected cache-hit behavior; `repro-lambda` treats it
256
- as success.
271
+ that sha. `repro-lambda` then downloads the existing object and accepts it only
272
+ if its bytes hash to the zip it just built.
273
+ - **`ArtifactIntegrityError`**: an object under the content key does not hold the
274
+ bytes this source builds. Every upload records the zip's sha256 as object
275
+ metadata (`x-amz-meta-zip-sha256`) and as the S3 `ChecksumSHA256` in the same
276
+ first write. A cache hit hashes the object against that record; an object with
277
+ no record is rebuilt and compared byte for byte. The record is written by the
278
+ object's writer, so it only proves the bytes did not change after that write:
279
+ bind them with the `zip_sha256` / `code_sha256` the build JSON reports (pin it
280
+ next to the key). Pin only rows whose `zip_source` is `built` or `reproduced`;
281
+ `build --reproduce-cache-hits` (on by default in the reusable workflow's trusted
282
+ runs) rebuilds every cache hit so none is a bare `record`. `promote` checks the
283
+ artifact against `--expected-sha256` or the `zip_sha256` that `builds/catalog.json`
284
+ records for the key, and refuses without one. The key is an input hash anyone
285
+ with the source can compute, so treat this error as a possibly planted
286
+ artifact: find who wrote the object before doing anything else.
257
287
  - **AWS CLI multipart upload fails with 403**: bucket policy denies multipart
258
288
  parts. Pin the multipart threshold above your Lambda size cap:
259
289
  `aws configure set s3.multipart_threshold 300MB`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repro-lambda"
3
- version = "0.9.0"
3
+ version = "0.10.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"
@@ -18,7 +18,9 @@ classifiers = [
18
18
  "Topic :: Software Development :: Build Tools",
19
19
  ]
20
20
  dependencies = [
21
- "typer>=0.12",
21
+ # shortcut: typer 0.29 imports pydantic, whose compiled core in the runner env
22
+ # cannot load in the container Python; lift once the container zip step stops importing the CLI.
23
+ "typer>=0.12,<0.29",
22
24
  "repro-zipfile>=0.3.1",
23
25
  "boto3>=1.34",
24
26
  "tomlkit>=0.13",
@@ -1,3 +1,3 @@
1
1
  """repro-lambda — reproducible AWS Lambda packaging outside Terraform."""
2
2
 
3
- __version__ = "0.9.0" # x-release-please-version
3
+ __version__ = "0.10.0" # x-release-please-version
@@ -13,7 +13,13 @@ 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 HASH_CONTRACT, compute_content_hash
15
15
  from repro_lambda.manifest import BuilderConfig, LambdaSpec, resolve_builder
16
- from repro_lambda.s3_uploader import S3Uploader, UploadResult
16
+ from repro_lambda.s3_uploader import (
17
+ ArtifactIntegrityError,
18
+ S3Uploader,
19
+ UploadResult,
20
+ code_sha256,
21
+ sha256_hex,
22
+ )
17
23
  from repro_lambda.source_stager import stage_source
18
24
 
19
25
 
@@ -28,6 +34,11 @@ class BuildOutcome:
28
34
  outcome: BuildResult
29
35
  sha256: str
30
36
  bucket_key: str
37
+ # sha256 of the zip bytes behind bucket_key. None for a dry run. This, not the key,
38
+ # is what a pin binds, and only when zip_source is "built" or "reproduced": a
39
+ # "record" value is the object writer's own claim (checked against the body only).
40
+ zip_sha256: str | None = None
41
+ zip_source: str | None = None # "built" | "reproduced" | "record"
31
42
 
32
43
 
33
44
  def _bucket_for(spec: LambdaSpec, base_bucket: str) -> str:
@@ -103,8 +114,14 @@ def build_one(
103
114
  dry_run: bool = False,
104
115
  sources_token: str | None = None,
105
116
  sources_cache: Path | None = None,
117
+ reproduce_cache_hits: bool = False,
106
118
  ) -> BuildOutcome:
107
- """Build one lambda end-to-end. Returns BuildOutcome with sha + cache verdict."""
119
+ """Build one lambda end-to-end. Returns BuildOutcome with sha + cache verdict.
120
+
121
+ ``reproduce_cache_hits`` rebuilds on every cache hit and fails unless the stored
122
+ bytes equal the rebuild, so the reported zip_sha256 is this run's own build. Set it
123
+ on the runs whose output gets pinned.
124
+ """
108
125
  builder = resolve_builder(builder, spec)
109
126
  target_bucket = _bucket_for(spec, bucket)
110
127
 
@@ -143,12 +160,26 @@ def build_one(
143
160
  return BuildOutcome(BuildResult.DRY_RUN, sha, bucket_key)
144
161
 
145
162
  uploader = S3Uploader(region=spec.region)
163
+ # The key is an input hash anyone can compute and the bucket is first-write-wins,
164
+ # so an existing object is never trusted by its key: its body must match the
165
+ # sha256 recorded at first write. With no record (a legacy or foreign object), or
166
+ # when the caller asks to reproduce cache hits, rebuild and compare below.
167
+ compare_sha: str | None = None
146
168
  if uploader.exists(bucket=target_bucket, key=bucket_key):
147
- _record(catalog, spec, sha, source_commit, builder, built=False)
148
- return BuildOutcome(BuildResult.CACHE_HIT, sha, bucket_key)
169
+ actual, recorded = uploader.read(bucket=target_bucket, key=bucket_key)
170
+ if recorded is not None and recorded != actual:
171
+ raise ArtifactIntegrityError(
172
+ f"s3://{target_bucket}/{bucket_key}: body sha256 {actual} != "
173
+ f"recorded sha256 {recorded}"
174
+ )
175
+ if recorded is not None and not reproduce_cache_hits:
176
+ _record(catalog, spec, sha, source_commit, builder, built=False)
177
+ return BuildOutcome(BuildResult.CACHE_HIT, sha, bucket_key, actual, "record")
178
+ compare_sha = actual
149
179
 
150
- # Cache miss only: fetch + verify + extract declarative sources into the staged
151
- # tree (post-filter; collides loudly with already-staged source/payload files).
180
+ # Cache miss (or an unrecorded object to compare): fetch + verify + extract
181
+ # declarative sources into the staged tree (post-filter; collides loudly with
182
+ # already-staged source/payload files).
152
183
  if spec.sources:
153
184
  from repro_lambda.sources import fetch_sources
154
185
 
@@ -181,10 +212,39 @@ def build_one(
181
212
  node_version=node_ver,
182
213
  )
183
214
 
215
+ built_sha = sha256_hex(out_zip.read_bytes())
216
+ if compare_sha is not None:
217
+ if built_sha != compare_sha:
218
+ raise ArtifactIntegrityError(
219
+ f"s3://{target_bucket}/{bucket_key}: existing body sha256 "
220
+ f"{compare_sha} != rebuilt zip sha256 {built_sha}"
221
+ )
222
+ # A missing record cannot be backfilled: the bucket denies any overwrite.
223
+ _require_s3_checksum(uploader, target_bucket, bucket_key, built_sha)
224
+ _record(catalog, spec, sha, source_commit, builder, built=False, zip_sha256=built_sha)
225
+ return BuildOutcome(BuildResult.CACHE_HIT, sha, bucket_key, built_sha, "reproduced")
226
+
184
227
  result = uploader.upload(bucket=target_bucket, key=bucket_key, body_path=out_zip)
185
228
  assert result in {UploadResult.UPLOADED, UploadResult.ALREADY_PRESENT}
186
- _record(catalog, spec, sha, source_commit, builder, built=True)
187
- return BuildOutcome(BuildResult.BUILT_AND_UPLOADED, sha, bucket_key)
229
+ _require_s3_checksum(uploader, target_bucket, bucket_key, built_sha)
230
+ _record(catalog, spec, sha, source_commit, builder, built=True, zip_sha256=built_sha)
231
+ return BuildOutcome(BuildResult.BUILT_AND_UPLOADED, sha, bucket_key, built_sha, "built")
232
+
233
+
234
+ def _require_s3_checksum(uploader: S3Uploader, bucket: str, key: str, zip_sha256: str) -> None:
235
+ """A pin-grade row must match the S3 ChecksumSHA256 the deploy-time checks read.
236
+
237
+ Promotion and the consumer's plan compare the pinned code_sha256 with the object's
238
+ S3 checksum, so an object without it (or with another) is refused here, at build
239
+ time, instead of failing a later plan.
240
+ """
241
+ got = uploader.s3_checksum(bucket=bucket, key=key)
242
+ want = code_sha256(zip_sha256)
243
+ if got != want:
244
+ raise ArtifactIntegrityError(
245
+ f"s3://{bucket}/{key}: S3 ChecksumSHA256 {got!r} != code_sha256 {want!r}; the "
246
+ f"object cannot be pinned (written without a checksum, or other bytes)"
247
+ )
188
248
 
189
249
 
190
250
  def _record(
@@ -195,6 +255,7 @@ def _record(
195
255
  builder: BuilderConfig,
196
256
  *,
197
257
  built: bool,
258
+ zip_sha256: str | None = None,
198
259
  ) -> None:
199
260
  if spec.package_manager == "npm":
200
261
  primary_image = builder.base_image_nodejs
@@ -213,5 +274,6 @@ def _record(
213
274
  builder_version=__version__ if built else f"hash-contract:{HASH_CONTRACT}",
214
275
  base_image_digest=primary_image.split("@", 1)[-1],
215
276
  built_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
277
+ zip_sha256=zip_sha256,
216
278
  ),
217
279
  )
@@ -20,6 +20,9 @@ class CatalogEntry:
20
20
  builder_version: str
21
21
  base_image_digest: str
22
22
  built_at: str # ISO-8601 UTC, e.g. "2026-05-27T09:30:00Z"
23
+ # sha256 of the zip bytes, set only when this run built or reproduced them (never
24
+ # from an object's writer-set record). promote checks the artifact against it.
25
+ zip_sha256: str | None = None
23
26
 
24
27
 
25
28
  @dataclass
@@ -38,6 +41,9 @@ class Catalog:
38
41
  self.lambdas[logical_name] = LambdaCatalog(current=entry.sha256, history=[entry])
39
42
  return
40
43
  if lc.current == entry.sha256:
44
+ # Same artifact: keep its first entry, but learn the reproduced zip sha256.
45
+ if entry.zip_sha256 and lc.history and not lc.history[0].zip_sha256:
46
+ lc.history[0].zip_sha256 = entry.zip_sha256
41
47
  return
42
48
  lc.history.insert(0, entry)
43
49
  lc.current = entry.sha256
@@ -58,6 +64,15 @@ class Catalog:
58
64
  path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
59
65
 
60
66
 
67
+ def zip_sha256_for(catalog: Catalog, logical_name: str, sha: str) -> str | None:
68
+ """The zip sha256 a build of this repo recorded for one artifact key, if any."""
69
+ lc = catalog.lambdas.get(logical_name)
70
+ for e in lc.history if lc else []:
71
+ if e.sha256 == sha and e.zip_sha256:
72
+ return e.zip_sha256
73
+ return None
74
+
75
+
61
76
  def load_catalog(path: Path) -> Catalog:
62
77
  if not path.exists():
63
78
  return Catalog(lambdas={})
@@ -77,6 +77,14 @@ def build(
77
77
  help="Also write the build summary JSON array to this file (for CI Step Summary).",
78
78
  ),
79
79
  ] = None,
80
+ reproduce_cache_hits: Annotated[
81
+ bool,
82
+ typer.Option(
83
+ "--reproduce-cache-hits",
84
+ help="Rebuild on every cache hit and fail unless the stored zip equals the "
85
+ "rebuild, so every reported zip_sha256 is this run's own build (pin runs).",
86
+ ),
87
+ ] = False,
80
88
  ) -> None:
81
89
  """Build one lambda (or all) per manifest and upload to S3."""
82
90
  import json
@@ -85,6 +93,7 @@ def build(
85
93
  from repro_lambda.catalog import load_catalog
86
94
  from repro_lambda.git_guard import DirtyWorktreeError, ensure_clean_worktree
87
95
  from repro_lambda.manifest import load_manifest
96
+ from repro_lambda.s3_uploader import code_sha256
88
97
 
89
98
  repo_root = manifest.parent.resolve()
90
99
  parsed = load_manifest(manifest)
@@ -149,6 +158,7 @@ def build(
149
158
  source_commit=source_commit,
150
159
  dry_run=dry_run,
151
160
  sources_token=sources_token,
161
+ reproduce_cache_hits=reproduce_cache_hits,
152
162
  )
153
163
  summary.append(
154
164
  {
@@ -156,6 +166,21 @@ def build(
156
166
  "outcome": outcome.outcome.value,
157
167
  "sha256": outcome.sha256,
158
168
  "bucket_key": outcome.bucket_key,
169
+ # Region of the artifact bucket (us-east-1 = the -us-east-1 variant).
170
+ "region": spec.region,
171
+ # Bytes behind the key (built, or verified on a cache hit). Pin these:
172
+ # zip_sha256 is hex, code_sha256 the same digest in base64 (S3
173
+ # ChecksumSHA256 / Lambda CodeSha256). Absent on a dry run.
174
+ **(
175
+ {
176
+ "zip_sha256": outcome.zip_sha256,
177
+ "code_sha256": code_sha256(outcome.zip_sha256),
178
+ # Pin only "built" / "reproduced"; "record" is the writer's claim.
179
+ "zip_source": outcome.zip_source,
180
+ }
181
+ if outcome.zip_sha256
182
+ else {}
183
+ ),
159
184
  }
160
185
  )
161
186
 
@@ -215,6 +240,15 @@ def promote(
215
240
  ),
216
241
  ] = "",
217
242
  dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
243
+ expected_sha256: Annotated[
244
+ str,
245
+ typer.Option(
246
+ "--expected-sha256",
247
+ help="64-hex sha256 the dev zip body must match (the pinned zip sha256). "
248
+ "Default: the zip_sha256 builds/catalog.json records for the promoted key; "
249
+ "promote refuses without one. Single lambda target only.",
250
+ ),
251
+ ] = "",
218
252
  ) -> None:
219
253
  """Copy built artifacts dev -> prod by content hash (no rebuild).
220
254
 
@@ -228,11 +262,14 @@ def promote(
228
262
 
229
263
  from repro_lambda.manifest import load_manifest
230
264
  from repro_lambda.promote import (
265
+ MissingExpectedShaError,
231
266
  MissingSourceArtifactError,
232
267
  UnknownShaError,
268
+ expected_from_catalog,
233
269
  promote_one,
234
270
  sha_from_catalog,
235
271
  )
272
+ from repro_lambda.s3_uploader import ArtifactIntegrityError
236
273
 
237
274
  repo_root = manifest.parent.resolve()
238
275
  parsed = load_manifest(manifest)
@@ -254,6 +291,17 @@ def promote(
254
291
  typer.echo(f"--sha must be 64 lowercase hex chars, got {sha!r}", err=True)
255
292
  raise typer.Exit(2)
256
293
 
294
+ if expected_sha256:
295
+ if target == "all" or len(selected) != 1:
296
+ typer.echo("--expected-sha256 requires a single lambda target (not 'all')", err=True)
297
+ raise typer.Exit(2)
298
+ if not re.fullmatch(r"[a-f0-9]{64}", expected_sha256):
299
+ typer.echo(
300
+ f"--expected-sha256 must be 64 lowercase hex chars, got {expected_sha256!r}",
301
+ err=True,
302
+ )
303
+ raise typer.Exit(2)
304
+
257
305
  if not dry_run and (not dev_bucket or not prod_bucket):
258
306
  typer.echo(
259
307
  "--dev-bucket and --prod-bucket (or REPRO_LAMBDA_DEV_BUCKET / "
@@ -273,8 +321,15 @@ def promote(
273
321
  dev_bucket=dev_bucket,
274
322
  prod_bucket=prod_bucket,
275
323
  dry_run=dry_run,
324
+ expected_sha256=expected_sha256
325
+ or expected_from_catalog(catalog_path, spec.logical_name, resolved_sha),
276
326
  )
277
- except (MissingSourceArtifactError, UnknownShaError) as e:
327
+ except (
328
+ MissingExpectedShaError,
329
+ MissingSourceArtifactError,
330
+ UnknownShaError,
331
+ ArtifactIntegrityError,
332
+ ) as e:
278
333
  typer.echo(str(e), err=True)
279
334
  raise typer.Exit(1) from e
280
335
  summary.append(
@@ -14,7 +14,11 @@ from repro_lambda.manifest import LambdaSpec, Source
14
14
  # the fingerprints in tests/test_hash_contract.py with it. Frozen at "0.8.0", the last
15
15
  # package version folded here, so keys minted by 0.8.0 stay valid; the packaging
16
16
  # modules did not change between 0.7.2 and 0.8.1.
17
- HASH_CONTRACT = "0.8.0"
17
+ # "0.10.0": a deliberate re-key with unchanged bytes. Keys minted before 0.10 were
18
+ # writable by any run the builder role trusted and carry no recorded zip sha256, so
19
+ # every artifact is rebuilt under a fresh key, written once with its sha256 recorded,
20
+ # and pinned with that sha256. Old keys are never reused.
21
+ HASH_CONTRACT = "0.10.0"
18
22
 
19
23
 
20
24
  def _sha256_file(path: Path) -> str: