Skip to content

Commit 123cfaf

Browse files
committed
feat: add extra_files manifest field (bundle prebuilt files/binaries into the package)
A [[lambda.extra_files]] entry stages a prebuilt file or directory (src, repo-root-relative; dest, package-root-relative; optional +x) into the package source tree, so it ships in the zip and folds into the content hash. The executable bit folds into the hash separately. Lets a lambda vendor CI-built binaries / release trees while the tool stays free of download logic. Specs with no extra_files hash byte-identically to before.
1 parent b0208e5 commit 123cfaf

8 files changed

Lines changed: 317 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## v0.4.0 - 2026-06-21
4+
5+
### Added
6+
- `extra_files` manifest field: bundle prebuilt files or directories into a lambda package alongside its source. Each `[[lambda.extra_files]]` entry has `src` (repo-root-relative, where CI materialized it - e.g. a digest-pinned binary or an extracted release tree), `dest` (package-root-relative), and an optional `executable` flag (sets +x on a file; ignored for directories, which keep source perms). The bytes fold into the content hash via the staged source tree, and the executable bit folds in separately, so flipping it changes the artifact hash even when bytes are identical. This lets a lambda ship vendored CLIs or release trees the consumer's CI downloads and verifies, while the tool itself stays free of any network/tool-download logic. Paths are validated as relative and `..`-free. Specs without `extra_files` hash byte-identically to before.
7+
38
## v0.3.0 - 2026-06-21
49

510
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "repro-lambda"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
description = "Build reproducible AWS Lambda packages outside Terraform, optimized for terraform-aws-lambda by serverless.tf."
55
readme = "README.md"
66
requires-python = ">=3.11"

src/repro_lambda/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""repro-lambda — reproducible AWS Lambda packaging outside Terraform."""
22

3-
__version__ = "0.3.0"
3+
__version__ = "0.4.0"

src/repro_lambda/build.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def compute_sha_for(
7575
builder=builder,
7676
stage_dir=stage_dir,
7777
extra_files=extras,
78+
payload_files=list(spec.extra_files),
7879
)
7980
return compute_content_hash(
8081
staged_source_root=stage_dir / "source",
@@ -83,6 +84,7 @@ def compute_sha_for(
8384
base_image=primary_base_image,
8485
builder_version=__version__,
8586
extra_files=extras,
87+
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
8688
)
8789

8890

@@ -113,6 +115,7 @@ def build_one(
113115
builder=builder,
114116
stage_dir=stage_dir,
115117
extra_files=extras,
118+
payload_files=list(spec.extra_files),
116119
)
117120

118121
sha = compute_content_hash(
@@ -122,6 +125,7 @@ def build_one(
122125
base_image=primary_base_image,
123126
builder_version=__version__,
124127
extra_files=extras,
128+
payload_exec=[(ef.dest, ef.executable) for ef in spec.extra_files],
125129
)
126130
bucket_key = f"lambdas/{spec.logical_name}/{sha}.zip"
127131

src/repro_lambda/hasher.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def compute_content_hash(
2424
builder_version: str,
2525
*,
2626
extra_files: list[tuple[Path, str]] | None = None,
27+
payload_exec: list[tuple[str, bool]] | None = None,
2728
) -> str:
2829
"""
2930
sha256 over: sorted (relative-path, sha256(content)) tuples for the staged tree
@@ -72,4 +73,13 @@ def compute_content_hash(
7273
h.update(_sha256_file(src).encode("ascii"))
7374
h.update(b"\n")
7475

76+
# Payload extra_files (prebuilt binaries/trees) are already hashed by content
77+
# via the staged source tree above; fold in their executable bit here so that
78+
# flipping +x changes the artifact hash even when bytes are unchanged. Omitted
79+
# entirely when empty, preserving byte-identical hashes for specs with none.
80+
if payload_exec:
81+
h.update(b"---payload-exec---\n")
82+
for dest, executable in sorted(payload_exec):
83+
h.update(f"{dest}={int(executable)}\n".encode())
84+
7585
return h.hexdigest()

src/repro_lambda/manifest.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@
1717
SUPPORTED_PACKAGE_MANAGERS = {"pip", "npm"}
1818

1919

20+
@dataclass(frozen=True)
21+
class ExtraFile:
22+
"""A prebuilt file or directory staged into the package alongside the source.
23+
24+
`src` is relative to the repo root (where the caller's CI materialized it, e.g.
25+
a downloaded + digest-pinned binary or an extracted release tree). `dest` is
26+
where it lands in the package (relative to the package root). For a file,
27+
`executable` sets the +x bit; for a directory, source perms are preserved and
28+
`executable` is ignored. The bytes fold into the content hash via the staged
29+
source tree; the executable flag folds in separately, so flipping it changes
30+
the artifact hash even when bytes are unchanged.
31+
"""
32+
33+
src: str
34+
dest: str
35+
executable: bool = False
36+
37+
2038
@dataclass(frozen=True)
2139
class LambdaSpec:
2240
logical_name: str
@@ -30,6 +48,7 @@ class LambdaSpec:
3048
lambda_at_edge: bool = False
3149
hash_extra: str = ""
3250
package_json: str = ""
51+
extra_files: tuple[ExtraFile, ...] = ()
3352

3453
@property
3554
def resolved_requirements_lock(self) -> str:
@@ -63,6 +82,26 @@ class Manifest:
6382
builder: BuilderConfig
6483

6584

85+
def _parse_extra_files(path: Path, entry: dict) -> tuple[ExtraFile, ...]:
86+
"""Parse + validate a lambda's optional [[lambda.extra_files]] entries."""
87+
parsed: list[ExtraFile] = []
88+
for ef in entry.get("extra_files", []):
89+
src = ef.get("src", "")
90+
dest = ef.get("dest", "")
91+
if not src or not dest:
92+
raise ValueError(
93+
f"{path}: extra_files entry requires non-empty 'src' and 'dest' (got {ef!r})"
94+
)
95+
for field_name, value in (("src", src), ("dest", dest)):
96+
if value.startswith("/") or ".." in Path(value).parts:
97+
raise ValueError(
98+
f"{path}: extra_files {field_name}={value!r} must be a relative path "
99+
f"without '..' (src is repo-root-relative, dest is package-root-relative)"
100+
)
101+
parsed.append(ExtraFile(src=src, dest=dest, executable=bool(ef.get("executable", False))))
102+
return tuple(parsed)
103+
104+
66105
def load_manifest(path: Path) -> Manifest:
67106
"""Parse lambdas.toml and validate semantic invariants."""
68107
with path.open("rb") as f:
@@ -119,6 +158,8 @@ def load_manifest(path: Path) -> Manifest:
119158
f"point it at the lambda's package.json relative to repo root"
120159
)
121160

161+
extra_files = _parse_extra_files(path, entry)
162+
122163
lambdas.append(
123164
LambdaSpec(
124165
logical_name=entry["logical_name"],
@@ -132,6 +173,7 @@ def load_manifest(path: Path) -> Manifest:
132173
package_manager=pkg,
133174
lambda_at_edge=bool(entry.get("lambda_at_edge", False)),
134175
hash_extra=entry.get("hash_extra", ""),
176+
extra_files=extra_files,
135177
)
136178
)
137179

src/repro_lambda/source_stager.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import subprocess
88
from pathlib import Path
99

10-
from repro_lambda.manifest import BuilderConfig
10+
from repro_lambda.manifest import BuilderConfig, ExtraFile
1111

1212

1313
def _git_ls_files(repo_root: Path, source_dir: str) -> list[str]:
@@ -37,20 +37,46 @@ def _filter_paths(paths: list[str], include: list[str], exclude: list[str]) -> l
3737
return kept
3838

3939

40+
def _stage_payload_files(
41+
repo_root: Path, target_root: Path, payload_files: list[ExtraFile]
42+
) -> None:
43+
"""Stage prebuilt files/dirs (CI-materialized, not git-tracked) into the package.
44+
45+
Each lands at target_root/<dest> (the staged source tree, so it ships in the zip
46+
and folds into the content hash). Files get the +x bit when `executable`; dirs
47+
are copied recursively with source perms preserved.
48+
"""
49+
for ef in payload_files:
50+
src = repo_root / ef.src
51+
dest = target_root / ef.dest
52+
if src.is_dir():
53+
shutil.copytree(src, dest, dirs_exist_ok=True)
54+
elif src.is_file():
55+
dest.parent.mkdir(parents=True, exist_ok=True)
56+
shutil.copy2(src, dest)
57+
if ef.executable:
58+
dest.chmod(dest.stat().st_mode | 0o111)
59+
else:
60+
raise FileNotFoundError(f"extra_files src not found: {src} (declared src={ef.src!r})")
61+
62+
4063
def stage_source(
4164
repo_root: Path,
4265
source_dir: str,
4366
builder: BuilderConfig,
4467
stage_dir: Path,
4568
*,
4669
extra_files: list[tuple[Path, str]] | None = None,
70+
payload_files: list[ExtraFile] | None = None,
4771
) -> list[str]:
4872
"""
4973
Copy git-tracked files under source_dir into stage_dir/source/, preserving perms.
5074
51-
Optionally copy additional files (outside source_dir) directly into stage_dir.
52-
Each entry in extra_files is (src_path, rel_name) where rel_name is the
53-
destination path relative to stage_dir (not stage_dir/source/).
75+
`extra_files` are build inputs (e.g. the requirements lock) copied to
76+
stage_dir/<rel_name> - consumed by the container, not shipped in the zip.
77+
78+
`payload_files` are prebuilt artifacts copied into stage_dir/source/<dest> so
79+
they ship in the zip and fold into the content hash.
5480
5581
Returns the sorted list of relative paths (from repo_root) that were staged.
5682
"""
@@ -71,6 +97,8 @@ def stage_source(
7197
if src_mode & 0o111:
7298
dst.chmod(dst.stat().st_mode | 0o111)
7399

100+
_stage_payload_files(repo_root, target_root, payload_files or [])
101+
74102
for src_path, rel_name in extra_files or []:
75103
if not src_path.is_file():
76104
raise FileNotFoundError(f"extra_files source not found: {src_path}")

0 commit comments

Comments
 (0)