repro-lambda 0.2.3__tar.gz → 0.3.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 (57) hide show
  1. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/.github/workflows/build.yml +19 -23
  2. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/.github/workflows/ci.yml +2 -2
  3. repro_lambda-0.3.0/.github/workflows/promote.yml +75 -0
  4. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/.github/workflows/publish.yml +5 -3
  5. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/CHANGELOG.md +23 -0
  6. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/PKG-INFO +1 -1
  7. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/pyproject.toml +1 -1
  8. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/__init__.py +1 -1
  9. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/cli.py +103 -1
  10. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/docker_runner.py +3 -9
  11. repro_lambda-0.3.0/src/repro_lambda/promote.py +91 -0
  12. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/s3_uploader.py +19 -0
  13. repro_lambda-0.3.0/tests/test_promote.py +228 -0
  14. repro_lambda-0.3.0/tests/test_zip_excludes.py +38 -0
  15. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/uv.lock +1 -1
  16. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/.gitignore +0 -0
  17. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/.pre-commit-config.yaml +0 -0
  18. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/LICENSE +0 -0
  19. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/README.md +0 -0
  20. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/SETUP.md +0 -0
  21. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/__main__.py +0 -0
  22. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/build.py +0 -0
  23. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/catalog.py +0 -0
  24. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/git_guard.py +0 -0
  25. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/hasher.py +0 -0
  26. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/manifest.py +0 -0
  27. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/source_stager.py +0 -0
  28. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/verify.py +0 -0
  29. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/src/repro_lambda/zip_packager.py +0 -0
  30. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/__init__.py +0 -0
  31. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/conftest.py +0 -0
  32. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/fixtures/sample_nodejs_lambda/handler/index.js +0 -0
  33. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/fixtures/sample_nodejs_lambda/handler/package-lock.json +0 -0
  34. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/fixtures/sample_nodejs_lambda/handler/package.json +0 -0
  35. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/fixtures/sample_nodejs_lambda/lambdas.toml +0 -0
  36. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/fixtures/sample_python_lambda/handler/app.py +0 -0
  37. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/fixtures/sample_python_lambda/handler/requirements.arm64.lock +0 -0
  38. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/fixtures/sample_python_lambda/handler/requirements.in +0 -0
  39. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/fixtures/sample_python_lambda/lambdas.toml +0 -0
  40. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_build_integration.py +0 -0
  41. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_build_nodejs.py +0 -0
  42. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_catalog.py +0 -0
  43. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_cli_build.py +0 -0
  44. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_cli_lock.py +0 -0
  45. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_cli_smoke.py +0 -0
  46. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_docker_runner.py +0 -0
  47. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_docker_runner_nodejs.py +0 -0
  48. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_e2e_nodejs_lambda.py +0 -0
  49. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_e2e_python_lambda.py +0 -0
  50. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_git_guard.py +0 -0
  51. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_hasher.py +0 -0
  52. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_manifest.py +0 -0
  53. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_python_byte_compat_regression.py +0 -0
  54. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_s3_uploader.py +0 -0
  55. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_source_stager.py +0 -0
  56. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_verify.py +0 -0
  57. {repro_lambda-0.2.3 → repro_lambda-0.3.0}/tests/test_zip_packager.py +0 -0
@@ -7,24 +7,20 @@ 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
- default: "0.2.3"
13
- description: Pinned repro-lambda PyPI version.
14
- aws-dev-role-arn:
10
+ aws_dev_role_arn:
15
11
  type: string
16
12
  required: true
17
13
  description: ARN of the dev OIDC role assumed for artifact upload. Not a secret (the security boundary is the OIDC trust policy + bucket immutability), so callers pass it as a plain input.
18
- aws-prod-role-arn:
14
+ aws_prod_role_arn:
19
15
  type: string
20
16
  required: false
21
17
  default: ""
22
18
  description: ARN of the prod OIDC role (master push only). Empty string disables the prod upload steps.
23
- dev-bucket:
19
+ dev_bucket:
24
20
  type: string
25
21
  required: true
26
22
  description: S3 bucket name for dev Lambda artifacts (set as REPRO_LAMBDA_BUCKET on the dev upload). Caller-supplied so the reusable workflow stays consumer-agnostic.
27
- prod-bucket:
23
+ prod_bucket:
28
24
  type: string
29
25
  required: false
30
26
  default: ""
@@ -36,8 +32,8 @@ jobs:
36
32
  outputs:
37
33
  arches: ${{ steps.parse.outputs.arches }}
38
34
  steps:
39
- - uses: actions/checkout@v4
40
- - uses: astral-sh/setup-uv@v3
35
+ - uses: actions/checkout@v7
36
+ - uses: astral-sh/setup-uv@v8.2.0
41
37
  - id: parse
42
38
  shell: bash
43
39
  run: |
@@ -65,36 +61,36 @@ jobs:
65
61
  id-token: write
66
62
  contents: write
67
63
  steps:
68
- - uses: actions/checkout@v4
69
- - uses: astral-sh/setup-uv@v3
64
+ - uses: actions/checkout@v7
65
+ - uses: astral-sh/setup-uv@v8.2.0
70
66
 
71
67
  - name: Configure AWS credentials (dev)
72
- uses: aws-actions/configure-aws-credentials@v4
68
+ uses: aws-actions/configure-aws-credentials@v6
73
69
  with:
74
- role-to-assume: ${{ inputs.aws-dev-role-arn }}
70
+ role-to-assume: ${{ inputs.aws_dev_role_arn }}
75
71
  aws-region: eu-west-1
76
72
 
77
73
  - name: Build (dev bucket)
78
74
  env:
79
- REPRO_LAMBDA_BUCKET: ${{ inputs.dev-bucket }}
80
- run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
75
+ REPRO_LAMBDA_BUCKET: ${{ inputs.dev_bucket }}
76
+ run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
81
77
 
82
78
  - name: Verify reproducible (PR only)
83
79
  if: github.event_name == 'pull_request'
84
- run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}" --verify --dry-run
80
+ run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}" --verify --dry-run
85
81
 
86
82
  - name: Configure AWS credentials (prod)
87
- if: github.ref == 'refs/heads/master' && inputs.aws-prod-role-arn != ''
88
- uses: aws-actions/configure-aws-credentials@v4
83
+ if: github.ref == 'refs/heads/master' && inputs.aws_prod_role_arn != ''
84
+ uses: aws-actions/configure-aws-credentials@v6
89
85
  with:
90
- role-to-assume: ${{ inputs.aws-prod-role-arn }}
86
+ role-to-assume: ${{ inputs.aws_prod_role_arn }}
91
87
  aws-region: eu-west-1
92
88
 
93
89
  - name: Build (prod bucket)
94
- if: github.ref == 'refs/heads/master' && inputs.aws-prod-role-arn != ''
90
+ if: github.ref == 'refs/heads/master' && inputs.aws_prod_role_arn != ''
95
91
  env:
96
- REPRO_LAMBDA_BUCKET: ${{ inputs.prod-bucket }}
97
- run: uvx --from "repro-lambda==${{ inputs.repro_lambda_version }}" repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
92
+ REPRO_LAMBDA_BUCKET: ${{ inputs.prod_bucket }}
93
+ run: uvx --from repro-lambda repro-lambda build --manifest "${{ inputs.manifest_path }}" --arch "${{ matrix.arch }}"
98
94
 
99
95
  - name: Commit catalog drift (master only, dev bot)
100
96
  if: github.ref == 'refs/heads/master'
@@ -9,8 +9,8 @@ jobs:
9
9
  lint-and-test:
10
10
  runs-on: ubuntu-24.04
11
11
  steps:
12
- - uses: actions/checkout@v4
13
- - uses: astral-sh/setup-uv@v3
12
+ - uses: actions/checkout@v7
13
+ - uses: astral-sh/setup-uv@v8.2.0
14
14
  with:
15
15
  enable-cache: true
16
16
  - run: uv sync --all-extras
@@ -0,0 +1,75 @@
1
+ name: promote-lambdas (reusable)
2
+
3
+ # Promote already-built dev artifacts to prod by content-addressed S3 copy.
4
+ # No rebuild: the exact lambdas/<name>/<sha>.zip tested in dev is copied to the
5
+ # prod bucket, so a cross-architecture prod runner can never diverge from dev.
6
+ on:
7
+ workflow_call:
8
+ inputs:
9
+ manifest_path:
10
+ type: string
11
+ default: lambdas.toml
12
+ description: Path to lambdas.toml in the caller repo.
13
+ source_sha:
14
+ type: string
15
+ required: true
16
+ description: 40-char hex commit on the caller's master that produced the dev artifacts. Validated and checked out before reading the manifest/catalog.
17
+ promoter_role_arn:
18
+ type: string
19
+ required: true
20
+ description: ARN of the prod OIDC role assumed for the cross-account dev-read + prod-write copy. Not a secret (the boundary is the OIDC trust policy + bucket immutability).
21
+ dev_bucket:
22
+ type: string
23
+ required: true
24
+ description: Source (dev) base S3 bucket; us-east-1 variant auto-derived for Lambda@Edge.
25
+ prod_bucket:
26
+ type: string
27
+ required: true
28
+ description: Destination (prod) base S3 bucket; us-east-1 variant auto-derived for Lambda@Edge.
29
+
30
+ permissions:
31
+ contents: read
32
+ id-token: write
33
+
34
+ jobs:
35
+ promote:
36
+ runs-on: ubuntu-24.04
37
+ env:
38
+ SOURCE_SHA: ${{ inputs.source_sha }}
39
+ steps:
40
+ # Validate source_sha BEFORE checkout to defend against script injection
41
+ # via the inputs.source_sha expression (passed through env: + bash regex).
42
+ - name: Validate source_sha shape (40-char hex)
43
+ run: |
44
+ if ! [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]; then
45
+ echo "::error::source_sha must be 40-char lowercase hex; got '$SOURCE_SHA'"
46
+ exit 1
47
+ fi
48
+
49
+ - uses: actions/checkout@v7
50
+ with:
51
+ ref: ${{ env.SOURCE_SHA }}
52
+ fetch-depth: 0
53
+
54
+ - name: Validate source_sha is reachable from origin/master
55
+ run: |
56
+ git fetch origin master
57
+ if ! git merge-base --is-ancestor "$SOURCE_SHA" origin/master; then
58
+ echo "::error::source_sha=$SOURCE_SHA not on master lineage"
59
+ exit 1
60
+ fi
61
+
62
+ - uses: astral-sh/setup-uv@v8.2.0
63
+
64
+ - name: Configure AWS credentials (prod promoter)
65
+ uses: aws-actions/configure-aws-credentials@v6
66
+ with:
67
+ role-to-assume: ${{ inputs.promoter_role_arn }}
68
+ aws-region: eu-west-1
69
+
70
+ - name: Promote dev -> prod (content-addressed copy)
71
+ env:
72
+ REPRO_LAMBDA_DEV_BUCKET: ${{ inputs.dev_bucket }}
73
+ REPRO_LAMBDA_PROD_BUCKET: ${{ inputs.prod_bucket }}
74
+ MANIFEST_PATH: ${{ inputs.manifest_path }}
75
+ run: uvx --from repro-lambda repro-lambda promote --manifest "$MANIFEST_PATH"
@@ -2,7 +2,9 @@ name: publish
2
2
 
3
3
  on:
4
4
  push:
5
- tags: ["v*"]
5
+ # Full semver tags only (vX.Y.Z) trigger a PyPI release. The sliding major
6
+ # tag (v1), which consumer workflows pin via @v1, must NOT trigger publish.
7
+ tags: ["v*.*.*"]
6
8
 
7
9
  jobs:
8
10
  build-and-publish:
@@ -11,8 +13,8 @@ jobs:
11
13
  id-token: write
12
14
  contents: read
13
15
  steps:
14
- - uses: actions/checkout@v4
15
- - uses: astral-sh/setup-uv@v3
16
+ - uses: actions/checkout@v7
17
+ - uses: astral-sh/setup-uv@v8.2.0
16
18
  - run: uv sync --all-extras
17
19
 
18
20
  - name: Verify tag matches package version
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.0 - 2026-06-21
4
+
5
+ ### Added
6
+ - `promote` command: copy an already-built artifact from the dev bucket to the prod bucket by content hash, with no rebuild. The sha per lambda is read from `builds/catalog.json`, so the promoted object is byte-for-byte the one built and tested in dev. Idempotent (skips keys already present); Lambda@Edge specs resolve to the `-us-east-1` bucket variant on both sides. `S3Uploader.copy()` performs the server-side `CopyObject`.
7
+ - Reusable workflow `promote.yml`: validates `source_sha` (40-char hex + master-lineage), checks it out, assumes a caller-supplied promoter role, and runs `promote`. Inputs: `manifest_path`, `source_sha`, `promoter_role_arn`, `dev_bucket`, `prod_bucket`.
8
+
9
+ ### Consumer migration
10
+ - Replace a rebuild-on-prod `promote-to-prod` job with a call to the reusable workflow:
11
+
12
+ jobs:
13
+ promote:
14
+ uses: antonbabenko/repro-lambda/.github/workflows/promote.yml@v1
15
+ with:
16
+ source_sha: ${{ inputs.source_sha }}
17
+ promoter_role_arn: arn:aws:iam::<account>:role/<promoter-role>
18
+ dev_bucket: <env>-my-lambda-artifacts
19
+ prod_bucket: <env>-my-lambda-artifacts
20
+
21
+ ## v0.2.4 - 2026-06-20
22
+
23
+ ### Fixed
24
+ - Container build no longer shells out to `find`/`xargs` (both absent from the minimal AWS Lambda base images, which caused `find: command not found`). The post-install cleanup (Python caches + non-deterministic `*.dist-info` metadata: RECORD, INSTALLER, direct_url.json, REQUESTED) now happens in the Python zip step via exclude globs, producing the same artifact bytes.
25
+
3
26
  ## v0.2.3 - 2026-06-20
4
27
 
5
28
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repro-lambda
3
- Version: 0.2.3
3
+ Version: 0.3.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repro-lambda"
3
- version = "0.2.3"
3
+ version = "0.3.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"
@@ -1,3 +1,3 @@
1
1
  """repro-lambda — reproducible AWS Lambda packaging outside Terraform."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.3.0"
@@ -169,6 +169,95 @@ def build(
169
169
  typer.echo(json.dumps(summary, indent=2))
170
170
 
171
171
 
172
+ @app.command()
173
+ def promote(
174
+ target: Annotated[str, typer.Argument(help="Lambda logical_name or 'all'.")] = "all",
175
+ manifest: Annotated[
176
+ Path,
177
+ typer.Option("--manifest", "-m", help="Path to lambdas.toml."),
178
+ ] = Path("lambdas.toml"),
179
+ dev_bucket: Annotated[
180
+ str,
181
+ typer.Option(
182
+ "--dev-bucket",
183
+ envvar="REPRO_LAMBDA_DEV_BUCKET",
184
+ help="Source (dev) base bucket; us-east-1 variant auto-derived.",
185
+ ),
186
+ ] = "",
187
+ prod_bucket: Annotated[
188
+ str,
189
+ typer.Option(
190
+ "--prod-bucket",
191
+ envvar="REPRO_LAMBDA_PROD_BUCKET",
192
+ help="Destination (prod) base bucket; us-east-1 variant auto-derived.",
193
+ ),
194
+ ] = "",
195
+ dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
196
+ ) -> None:
197
+ """Copy built artifacts dev -> prod by content hash (no rebuild).
198
+
199
+ The sha per lambda is read from builds/catalog.json, so the artifact promoted
200
+ is exactly the one the source commit built and tested in dev.
201
+ """
202
+ import json
203
+
204
+ from repro_lambda.manifest import load_manifest
205
+ from repro_lambda.promote import (
206
+ MissingSourceArtifactError,
207
+ UnknownShaError,
208
+ promote_one,
209
+ sha_from_catalog,
210
+ )
211
+
212
+ repo_root = manifest.parent.resolve()
213
+ parsed = load_manifest(manifest)
214
+
215
+ selected = (
216
+ parsed.lambdas
217
+ if target == "all"
218
+ else [s for s in parsed.lambdas if s.logical_name == target]
219
+ )
220
+ if not selected:
221
+ typer.echo(f"no lambda named {target!r} in {manifest}", err=True)
222
+ raise typer.Exit(2)
223
+
224
+ if not dry_run and (not dev_bucket or not prod_bucket):
225
+ typer.echo(
226
+ "--dev-bucket and --prod-bucket (or REPRO_LAMBDA_DEV_BUCKET / "
227
+ "REPRO_LAMBDA_PROD_BUCKET) are required for non-dry-run",
228
+ err=True,
229
+ )
230
+ raise typer.Exit(2)
231
+
232
+ catalog_path = repo_root / "builds" / "catalog.json"
233
+ summary = []
234
+ for spec in selected:
235
+ try:
236
+ sha = sha_from_catalog(catalog_path, spec.logical_name)
237
+ outcome = promote_one(
238
+ spec=spec,
239
+ sha=sha,
240
+ dev_bucket=dev_bucket,
241
+ prod_bucket=prod_bucket,
242
+ dry_run=dry_run,
243
+ )
244
+ except (MissingSourceArtifactError, UnknownShaError) as e:
245
+ typer.echo(str(e), err=True)
246
+ raise typer.Exit(1) from e
247
+ summary.append(
248
+ {
249
+ "logical_name": spec.logical_name,
250
+ "outcome": outcome.outcome.value,
251
+ "sha256": outcome.sha256,
252
+ "bucket_key": outcome.bucket_key,
253
+ "src_bucket": outcome.src_bucket,
254
+ "dst_bucket": outcome.dst_bucket,
255
+ }
256
+ )
257
+
258
+ typer.echo(json.dumps(summary, indent=2))
259
+
260
+
172
261
  @app.command()
173
262
  def lock(
174
263
  manifest: Annotated[Path, typer.Option("--manifest", "-m")] = Path("lambdas.toml"),
@@ -220,11 +309,24 @@ def init() -> None:
220
309
  raise typer.Exit(0)
221
310
 
222
311
 
312
+ # Stripped from every lambda zip so the container build needs no findutils/xargs
313
+ # (absent from minimal Lambda base images): Python caches and the non-deterministic
314
+ # dist-info metadata files pip writes (RECORD, INSTALLER, direct_url.json, REQUESTED).
315
+ _LAMBDA_ZIP_EXCLUDES = [
316
+ "*__pycache__*",
317
+ "*.pyc",
318
+ "*.dist-info/RECORD",
319
+ "*.dist-info/INSTALLER",
320
+ "*.dist-info/direct_url.json",
321
+ "*.dist-info/REQUESTED",
322
+ ]
323
+
324
+
223
325
  def _zip_impl(src: Path, out: Path) -> None:
224
326
  """Pack a directory into a deterministic zip (used inside container)."""
225
327
  from repro_lambda.zip_packager import pack_directory
226
328
 
227
- pack_directory(src, out)
329
+ pack_directory(src, out, exclude_glob=_LAMBDA_ZIP_EXCLUDES)
228
330
 
229
331
 
230
332
  @app.command(name="zip")
@@ -50,15 +50,9 @@ pip install \
50
50
  --target "$PKG" \
51
51
  --requirement /src/requirements.lock
52
52
 
53
- # v0.1 byte-output cleanup: strip non-deterministic install metadata + caches.
54
- find "$PKG" -type d -name "__pycache__" -prune -exec sh -c 'for d; do rm -rf -- "$d"; done' _ {} +
55
- find "$PKG" -type f -name "*.pyc" -delete
56
- find "$PKG" -type d -name "*.dist-info" -exec sh -c '
57
- for d; do
58
- rm -f -- "$d/RECORD" "$d/INSTALLER" "$d/direct_url.json" "$d/REQUESTED"
59
- done
60
- ' _ {} +
61
-
53
+ # Byte-output cleanup (caches + non-deterministic dist-info metadata) happens in the
54
+ # Python zip step below, so this script needs no findutils/xargs (both absent from the
55
+ # minimal AWS Lambda base images).
62
56
  python3 -m repro_lambda zip --src "$PKG" --out /out/lambda.zip
63
57
  """
64
58
 
@@ -0,0 +1,91 @@
1
+ """Promote a built lambda artifact from the dev bucket to the prod bucket.
2
+
3
+ Promotion is a content-addressed S3 copy: the exact `lambdas/<name>/<sha>.zip`
4
+ object already built and verified against the dev bucket is copied byte-for-byte
5
+ to the prod bucket. There is no rebuild, so a cross-architecture prod runner can
6
+ never produce a different artifact than the one tested in dev.
7
+
8
+ The sha to promote comes from `builds/catalog.json` (the bounded build history
9
+ committed to the source repo), so a promote always targets the artifact the
10
+ source commit recorded.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import enum
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from repro_lambda.build import _bucket_for
20
+ from repro_lambda.catalog import load_catalog
21
+ from repro_lambda.manifest import LambdaSpec
22
+ from repro_lambda.s3_uploader import S3Uploader, UploadResult
23
+
24
+
25
+ class PromoteResult(enum.Enum):
26
+ PROMOTED = "promoted"
27
+ ALREADY_PRESENT = "already_present"
28
+ DRY_RUN = "dry_run"
29
+
30
+
31
+ @dataclass
32
+ class PromoteOutcome:
33
+ outcome: PromoteResult
34
+ sha256: str
35
+ bucket_key: str
36
+ src_bucket: str
37
+ dst_bucket: str
38
+
39
+
40
+ class MissingSourceArtifactError(RuntimeError):
41
+ """The dev artifact to promote does not exist (build dev first)."""
42
+
43
+
44
+ class UnknownShaError(RuntimeError):
45
+ """builds/catalog.json has no current sha for the requested lambda."""
46
+
47
+
48
+ def sha_from_catalog(catalog_path: Path, logical_name: str) -> str:
49
+ """Return the current sha recorded for `logical_name`, or raise UnknownShaError."""
50
+ catalog = load_catalog(catalog_path)
51
+ lc = catalog.lambdas.get(logical_name)
52
+ if lc is None or not lc.current:
53
+ raise UnknownShaError(
54
+ f"no catalog entry for {logical_name!r} in {catalog_path}; "
55
+ f"build the dev artifact before promoting"
56
+ )
57
+ return lc.current
58
+
59
+
60
+ def promote_one(
61
+ *,
62
+ spec: LambdaSpec,
63
+ sha: str,
64
+ dev_bucket: str,
65
+ prod_bucket: str,
66
+ uploader: S3Uploader | None = None,
67
+ dry_run: bool = False,
68
+ ) -> PromoteOutcome:
69
+ """Copy one lambda's `<sha>.zip` from the dev bucket to the prod bucket.
70
+
71
+ Lambda@Edge specs (region us-east-1) resolve to the `-us-east-1` bucket
72
+ variant on both sides, matching the build-side key scheme exactly.
73
+ """
74
+ src_bucket = _bucket_for(spec, dev_bucket)
75
+ dst_bucket = _bucket_for(spec, prod_bucket)
76
+ key = f"lambdas/{spec.logical_name}/{sha}.zip"
77
+
78
+ if dry_run:
79
+ return PromoteOutcome(PromoteResult.DRY_RUN, sha, key, src_bucket, dst_bucket)
80
+
81
+ up = uploader or S3Uploader(region=spec.region)
82
+ if not up.exists(bucket=src_bucket, key=key):
83
+ raise MissingSourceArtifactError(
84
+ f"{spec.logical_name}: source artifact missing at s3://{src_bucket}/{key}"
85
+ )
86
+
87
+ result = up.copy(src_bucket=src_bucket, dst_bucket=dst_bucket, key=key)
88
+ outcome = (
89
+ PromoteResult.PROMOTED if result is UploadResult.UPLOADED else PromoteResult.ALREADY_PRESENT
90
+ )
91
+ return PromoteOutcome(outcome, sha, key, src_bucket, dst_bucket)
@@ -28,6 +28,25 @@ class S3Uploader:
28
28
  return False
29
29
  raise
30
30
 
31
+ def copy(self, *, src_bucket: str, dst_bucket: str, key: str) -> UploadResult:
32
+ """
33
+ Server-side copy of the same key from src_bucket to dst_bucket.
34
+
35
+ Idempotent via a destination existence pre-check; safe because artifact
36
+ buckets are content-addressed and immutable (the same key never changes
37
+ bytes). Returns ALREADY_PRESENT if the destination key already exists,
38
+ UPLOADED otherwise.
39
+ """
40
+ if self.exists(bucket=dst_bucket, key=key):
41
+ return UploadResult.ALREADY_PRESENT
42
+ self._client.copy_object(
43
+ Bucket=dst_bucket,
44
+ Key=key,
45
+ CopySource={"Bucket": src_bucket, "Key": key},
46
+ ServerSideEncryption="AES256",
47
+ )
48
+ return UploadResult.UPLOADED
49
+
31
50
  def upload(self, *, bucket: str, key: str, body_path: Path) -> UploadResult:
32
51
  """
33
52
  PutObject with If-None-Match=*.
@@ -0,0 +1,228 @@
1
+ """Promote (dev -> prod copy by content hash) unit + CLI coverage."""
2
+
3
+ from pathlib import Path
4
+
5
+ import boto3
6
+ import pytest
7
+ from moto import mock_aws
8
+ from typer.testing import CliRunner
9
+
10
+ from repro_lambda.cli import app
11
+ from repro_lambda.manifest import LambdaSpec
12
+ from repro_lambda.promote import (
13
+ MissingSourceArtifactError,
14
+ PromoteResult,
15
+ UnknownShaError,
16
+ promote_one,
17
+ sha_from_catalog,
18
+ )
19
+ from repro_lambda.s3_uploader import S3Uploader, UploadResult
20
+
21
+ runner = CliRunner()
22
+
23
+ DEV = "dev-test-lambda-artifacts"
24
+ PROD = "prod-test-lambda-artifacts"
25
+
26
+
27
+ def _make_bucket(client, name: str, region: str) -> None:
28
+ if region == "us-east-1":
29
+ client.create_bucket(Bucket=name)
30
+ else:
31
+ client.create_bucket(Bucket=name, CreateBucketConfiguration={"LocationConstraint": region})
32
+
33
+
34
+ def _spec(name: str, *, region: str = "eu-west-1", at_edge: bool = False) -> LambdaSpec:
35
+ return LambdaSpec(
36
+ logical_name=name,
37
+ source_dir="handler",
38
+ requirements_lock="handler/requirements.${arch}.lock",
39
+ runtime="python3.13",
40
+ arch="x86_64" if at_edge else "arm64",
41
+ handler="app.lambda_handler",
42
+ region=region,
43
+ lambda_at_edge=at_edge,
44
+ )
45
+
46
+
47
+ # --- S3Uploader.copy -------------------------------------------------------
48
+
49
+
50
+ def test_copy_uploads_then_reports_already_present():
51
+ with mock_aws():
52
+ c = boto3.client("s3", region_name="eu-west-1")
53
+ _make_bucket(c, DEV, "eu-west-1")
54
+ _make_bucket(c, PROD, "eu-west-1")
55
+ c.put_object(Bucket=DEV, Key="lambdas/app/abc.zip", Body=b"PK\x05\x06" + b"\x00" * 18)
56
+
57
+ up = S3Uploader(region="eu-west-1")
58
+ first = up.copy(src_bucket=DEV, dst_bucket=PROD, key="lambdas/app/abc.zip")
59
+ second = up.copy(src_bucket=DEV, dst_bucket=PROD, key="lambdas/app/abc.zip")
60
+
61
+ assert first == UploadResult.UPLOADED
62
+ assert second == UploadResult.ALREADY_PRESENT
63
+ assert up.exists(bucket=PROD, key="lambdas/app/abc.zip")
64
+
65
+
66
+ # --- promote_one -----------------------------------------------------------
67
+
68
+
69
+ def test_promote_one_copies_regional_lambda():
70
+ with mock_aws():
71
+ c = boto3.client("s3", region_name="eu-west-1")
72
+ _make_bucket(c, DEV, "eu-west-1")
73
+ _make_bucket(c, PROD, "eu-west-1")
74
+ c.put_object(Bucket=DEV, Key="lambdas/app/sha1.zip", Body=b"z")
75
+
76
+ out = promote_one(spec=_spec("app"), sha="sha1", dev_bucket=DEV, prod_bucket=PROD)
77
+
78
+ assert out.outcome == PromoteResult.PROMOTED
79
+ assert out.src_bucket == DEV
80
+ assert out.dst_bucket == PROD
81
+ assert out.bucket_key == "lambdas/app/sha1.zip"
82
+ assert c.head_object(Bucket=PROD, Key="lambdas/app/sha1.zip")
83
+
84
+
85
+ def test_promote_one_edge_lambda_uses_us_east_1_bucket_variant():
86
+ with mock_aws():
87
+ eu = boto3.client("s3", region_name="eu-west-1")
88
+ us = boto3.client("s3", region_name="us-east-1")
89
+ _make_bucket(eu, DEV, "eu-west-1")
90
+ _make_bucket(eu, PROD, "eu-west-1")
91
+ _make_bucket(us, f"{DEV}-us-east-1", "us-east-1")
92
+ _make_bucket(us, f"{PROD}-us-east-1", "us-east-1")
93
+ us.put_object(Bucket=f"{DEV}-us-east-1", Key="lambdas/edge/e1.zip", Body=b"z")
94
+
95
+ out = promote_one(
96
+ spec=_spec("edge", region="us-east-1", at_edge=True),
97
+ sha="e1",
98
+ dev_bucket=DEV,
99
+ prod_bucket=PROD,
100
+ )
101
+
102
+ assert out.src_bucket == f"{DEV}-us-east-1"
103
+ assert out.dst_bucket == f"{PROD}-us-east-1"
104
+ assert us.head_object(Bucket=f"{PROD}-us-east-1", Key="lambdas/edge/e1.zip")
105
+
106
+
107
+ def test_promote_one_missing_source_raises():
108
+ with mock_aws():
109
+ c = boto3.client("s3", region_name="eu-west-1")
110
+ _make_bucket(c, DEV, "eu-west-1")
111
+ _make_bucket(c, PROD, "eu-west-1")
112
+
113
+ with pytest.raises(MissingSourceArtifactError):
114
+ promote_one(spec=_spec("app"), sha="missing", dev_bucket=DEV, prod_bucket=PROD)
115
+
116
+
117
+ def test_promote_one_dry_run_touches_no_s3():
118
+ out = promote_one(spec=_spec("app"), sha="sha1", dev_bucket=DEV, prod_bucket=PROD, dry_run=True)
119
+ assert out.outcome == PromoteResult.DRY_RUN
120
+ assert out.bucket_key == "lambdas/app/sha1.zip"
121
+
122
+
123
+ # --- sha_from_catalog ------------------------------------------------------
124
+
125
+
126
+ def test_sha_from_catalog_reads_current(tmp_path: Path):
127
+ catalog = tmp_path / "catalog.json"
128
+ catalog.write_text(
129
+ '{"schema_version": 1, "lambdas": {"app": {"current": "deadbeef", "history": []}}}\n'
130
+ )
131
+ assert sha_from_catalog(catalog, "app") == "deadbeef"
132
+
133
+
134
+ def test_sha_from_catalog_unknown_lambda_raises(tmp_path: Path):
135
+ catalog = tmp_path / "catalog.json"
136
+ catalog.write_text('{"schema_version": 1, "lambdas": {}}\n')
137
+ with pytest.raises(UnknownShaError):
138
+ sha_from_catalog(catalog, "app")
139
+
140
+
141
+ # --- CLI -------------------------------------------------------------------
142
+
143
+
144
+ def _consumer(tmp_path: Path) -> Path:
145
+ repo = tmp_path / "consumer"
146
+ (repo / "handler").mkdir(parents=True)
147
+ (repo / "lambdas.toml").write_text(
148
+ "[[lambda]]\n"
149
+ 'logical_name = "app"\n'
150
+ 'source_dir = "handler"\n'
151
+ 'requirements_lock = "handler/requirements.${arch}.lock"\n'
152
+ 'runtime = "python3.13"\n'
153
+ 'arch = "arm64"\n'
154
+ 'handler = "app.lambda_handler"\n'
155
+ 'region = "eu-west-1"\n'
156
+ "\n"
157
+ "[builder]\n"
158
+ 'base_image_python = "public.ecr.aws/lambda/python:3.13@sha256:' + "0" * 64 + '"\n'
159
+ )
160
+ (repo / "builds").mkdir()
161
+ (repo / "builds" / "catalog.json").write_text(
162
+ '{"schema_version": 1, "lambdas": {"app": {"current": "cafef00d", "history": []}}}\n'
163
+ )
164
+ return repo
165
+
166
+
167
+ def test_cli_promote_copies_from_catalog(tmp_path: Path):
168
+ repo = _consumer(tmp_path)
169
+ with mock_aws():
170
+ c = boto3.client("s3", region_name="eu-west-1")
171
+ _make_bucket(c, DEV, "eu-west-1")
172
+ _make_bucket(c, PROD, "eu-west-1")
173
+ c.put_object(Bucket=DEV, Key="lambdas/app/cafef00d.zip", Body=b"z")
174
+
175
+ result = runner.invoke(
176
+ app,
177
+ [
178
+ "promote",
179
+ "app",
180
+ "--manifest",
181
+ str(repo / "lambdas.toml"),
182
+ "--dev-bucket",
183
+ DEV,
184
+ "--prod-bucket",
185
+ PROD,
186
+ ],
187
+ )
188
+ assert result.exit_code == 0, result.stdout
189
+ assert "promoted" in result.stdout
190
+ assert c.head_object(Bucket=PROD, Key="lambdas/app/cafef00d.zip")
191
+
192
+
193
+ def test_cli_promote_dry_run_touches_no_s3(tmp_path: Path):
194
+ repo = _consumer(tmp_path)
195
+ result = runner.invoke(
196
+ app,
197
+ [
198
+ "promote",
199
+ "app",
200
+ "--manifest",
201
+ str(repo / "lambdas.toml"),
202
+ "--dev-bucket",
203
+ DEV,
204
+ "--prod-bucket",
205
+ PROD,
206
+ "--dry-run",
207
+ ],
208
+ )
209
+ assert result.exit_code == 0, result.stdout
210
+ assert "dry_run" in result.stdout
211
+
212
+
213
+ def test_cli_promote_unknown_target_exits_2(tmp_path: Path):
214
+ repo = _consumer(tmp_path)
215
+ result = runner.invoke(
216
+ app,
217
+ [
218
+ "promote",
219
+ "nope",
220
+ "--manifest",
221
+ str(repo / "lambdas.toml"),
222
+ "--dev-bucket",
223
+ DEV,
224
+ "--prod-bucket",
225
+ PROD,
226
+ ],
227
+ )
228
+ assert result.exit_code == 2
@@ -0,0 +1,38 @@
1
+ from pathlib import Path
2
+ from zipfile import ZipFile
3
+
4
+ from typer.testing import CliRunner
5
+
6
+ from repro_lambda.cli import app
7
+
8
+ runner = CliRunner()
9
+
10
+
11
+ def test_zip_excludes_caches_and_dist_info_metadata(tmp_path: Path):
12
+ """`repro-lambda zip` strips caches + non-deterministic dist-info metadata,
13
+ so the container build needs no find/xargs (absent from minimal base images)."""
14
+ pkg = tmp_path / "pkg"
15
+ (pkg / "mymod").mkdir(parents=True)
16
+ (pkg / "mymod" / "__init__.py").write_text("x = 1\n")
17
+ (pkg / "mymod" / "__pycache__").mkdir()
18
+ (pkg / "mymod" / "__pycache__" / "__init__.cpython-313.pyc").write_bytes(b"\x00")
19
+ (pkg / "mymod" / "stale.pyc").write_bytes(b"\x00")
20
+ dist = pkg / "req-1.0.dist-info"
21
+ dist.mkdir()
22
+ (dist / "RECORD").write_text("mymod/__init__.py,,\n")
23
+ (dist / "INSTALLER").write_text("pip\n")
24
+ (dist / "METADATA").write_text("Name: req\n")
25
+
26
+ out = tmp_path / "lambda.zip"
27
+ result = runner.invoke(app, ["zip", "--src", str(pkg), "--out", str(out)])
28
+ assert result.exit_code == 0, result.stdout
29
+
30
+ names = ZipFile(out).namelist()
31
+ # kept: real code + stable dist-info metadata
32
+ assert "mymod/__init__.py" in names
33
+ assert "req-1.0.dist-info/METADATA" in names
34
+ # stripped: caches + non-deterministic metadata
35
+ assert not any("__pycache__" in n for n in names)
36
+ assert not any(n.endswith(".pyc") for n in names)
37
+ assert "req-1.0.dist-info/RECORD" not in names
38
+ assert "req-1.0.dist-info/INSTALLER" not in names
@@ -646,7 +646,7 @@ wheels = [
646
646
 
647
647
  [[package]]
648
648
  name = "repro-lambda"
649
- version = "0.2.3"
649
+ version = "0.3.0"
650
650
  source = { editable = "." }
651
651
  dependencies = [
652
652
  { name = "boto3" },
File without changes
File without changes
File without changes
File without changes