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.
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/build.yml +53 -15
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/promote.yml +19 -4
- repro_lambda-0.10.0/.release-please-manifest.json +3 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/CHANGELOG.md +23 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/PKG-INFO +2 -2
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/USAGE.md +37 -7
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/pyproject.toml +4 -2
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/__init__.py +1 -1
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/build.py +70 -8
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/catalog.py +15 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/cli.py +56 -1
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/hasher.py +5 -1
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/promote.py +23 -2
- repro_lambda-0.10.0/src/repro_lambda/s3_uploader.py +161 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/verify.py +3 -0
- repro_lambda-0.10.0/tests/test_artifact_integrity.py +337 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_build_integration.py +3 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_hash_contract.py +15 -2
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_promote.py +140 -9
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/uv.lock +2 -2
- repro_lambda-0.9.0/.release-please-manifest.json +0 -3
- repro_lambda-0.9.0/src/repro_lambda/s3_uploader.py +0 -72
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/ci.yml +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/move-major-tag.yml +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/publish.yml +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.github/workflows/release.yml +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.gitignore +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/.pre-commit-config.yaml +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/LICENSE +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/README.md +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/SETUP.md +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/assets/social-preview.html +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/assets/social-preview.png +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/README.md +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/builds/catalog.json +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/lambdas.toml +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/main.tf +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/outputs.tf +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/src/app/app.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/src/app/requirements.arm64.lock +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/variables.tf +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/examples/complete/versions.tf +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/release-please-config.json +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/__main__.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/docker_runner.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/git_guard.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/manifest.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/source_locker.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/source_stager.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/sources.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/src/repro_lambda/zip_packager.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/__init__.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/conftest.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_build_nodejs.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_catalog.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_cli_build.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_cli_lock.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_cli_smoke.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_compile_bytecode.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_docker_runner.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_docker_runner_nodejs.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_e2e_nodejs_lambda.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_e2e_python_lambda.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_extra_files.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_git_guard.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_hasher.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_manifest.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_per_lambda_builder.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_python_byte_compat_regression.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_s3_uploader.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_source_locker.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_source_stager.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_sources.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_sources_hash.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_sources_schema.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_verify.py +0 -0
- {repro_lambda-0.9.0 → repro_lambda-0.10.0}/tests/test_zip_excludes.py +0 -0
- {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
|
-
|
|
94
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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 (
|
|
208
|
-
if:
|
|
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
|
|
90
|
+
run: uvx --from "$REPRO_LAMBDA_FROM" repro-lambda promote --manifest "$MANIFEST_PATH"
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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.
|
|
256
|
-
|
|
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.
|
|
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
|
-
|
|
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",
|
|
@@ -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
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
151
|
-
# tree (post-filter; collides loudly with
|
|
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
|
-
|
|
187
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|