Skip to content

Commit 62ae47a

Browse files
authored
Verify and auto-update native dependencies built in CI. (#197)
1 parent 9604962 commit 62ae47a

File tree

2 files changed

+313
-8
lines changed

2 files changed

+313
-8
lines changed

.github/workflows/deps.yaml

Lines changed: 137 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ jobs:
2626
- name: Install build tools
2727
run: |
2828
sudo apt-get update
29-
sudo apt-get install nasm
29+
sudo apt-get install nasm python3
3030
3131
- name: Build deps
3232
run: |
33+
echo "Starting dependency build..."
3334
export MAKEFLAGS="-j$(nproc)"
3435
./deps/build-deps-linux.sh
36+
echo "Dependency build completed"
3537
3638
- run: |
3739
git status
@@ -48,14 +50,22 @@ jobs:
4850
go build
4951
go test -v
5052
51-
- name: Compress deps
52-
run: tar -czf deps.tar.gz deps/linux
53+
- name: Generate build info
54+
run: |
55+
./deps/verify_deps.py generate \
56+
--deps-dir deps/linux \
57+
--platform linux \
58+
--commit ${{ github.sha }}
59+
60+
- name: Create deps archive
61+
run: |
62+
tar -czf deps-linux.tar.gz deps/linux/
5363
5464
- name: Upload deps artifact
5565
uses: actions/upload-artifact@v4
5666
with:
5767
name: deps-linux.tar.gz
58-
path: deps.tar.gz
68+
path: deps-linux.tar.gz
5969

6070
macos:
6171
name: macOS
@@ -73,8 +83,10 @@ jobs:
7383
7484
- name: Build deps
7585
run: |
86+
echo "Starting dependency build..."
7687
export MAKEFLAGS="-j$(nproc)"
7788
./deps/build-deps-osx.sh
89+
echo "Dependency build completed"
7890
7991
- run: |
8092
git status
@@ -90,12 +102,129 @@ jobs:
90102
run: |
91103
go build
92104
go test -v
93-
94-
- name: Compress deps
95-
run: tar -czf deps.tar.gz deps/osx
105+
106+
- name: Generate build info
107+
run: |
108+
./deps/verify_deps.py generate \
109+
--deps-dir deps/osx \
110+
--platform macos \
111+
--commit ${{ github.sha }}
112+
113+
- name: Create deps archive
114+
run: |
115+
tar -czf deps-macos.tar.gz deps/osx/
96116
97117
- name: Upload deps artifact
98118
uses: actions/upload-artifact@v4
99119
with:
100120
name: deps-macos.tar.gz
101-
path: deps.tar.gz
121+
path: deps-macos.tar.gz
122+
123+
verify:
124+
name: Verify Build Artifacts
125+
needs: [linux, macos]
126+
runs-on: ubuntu-latest
127+
if: github.event_name == 'pull_request'
128+
129+
steps:
130+
- name: Check out repo
131+
uses: actions/checkout@v4
132+
133+
- name: Download Linux artifact
134+
uses: actions/download-artifact@v4
135+
with:
136+
name: deps-linux.tar.gz
137+
path: .
138+
139+
- name: Download macOS artifact
140+
uses: actions/download-artifact@v4
141+
with:
142+
name: deps-macos.tar.gz
143+
path: .
144+
145+
- name: Extract artifacts
146+
run: |
147+
tar xzf deps-linux.tar.gz
148+
tar xzf deps-macos.tar.gz
149+
150+
- name: Verify artifacts match checked-in deps
151+
run: |
152+
python3 ./deps/verify_deps.py verify \
153+
--deps-dir deps/linux \
154+
--build-info deps/linux/build-info.json
155+
156+
python3 ./deps/verify_deps.py verify \
157+
--deps-dir deps/osx \
158+
--build-info deps/osx/build-info.json
159+
160+
update-deps:
161+
name: Update Checked-in Dependencies
162+
needs: [linux, macos]
163+
runs-on: ubuntu-latest
164+
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
165+
timeout-minutes: 15
166+
167+
steps:
168+
- name: Check out repo
169+
uses: actions/checkout@v4
170+
171+
- name: Download Linux artifact
172+
uses: actions/download-artifact@v4
173+
with:
174+
name: deps-linux.tar.gz
175+
path: .
176+
177+
- name: Download macOS artifact
178+
uses: actions/download-artifact@v4
179+
with:
180+
name: deps-macos.tar.gz
181+
path: .
182+
183+
- name: Extract and update deps
184+
run: |
185+
# Remove existing deps directories to avoid stale files
186+
rm -rf deps/linux/* deps/osx/*
187+
188+
tar xzf deps-linux.tar.gz
189+
tar xzf deps-macos.tar.gz
190+
191+
- name: Commit updated deps
192+
run: |
193+
git config --local user.email "github-actions[bot]@users.noreply.github.com"
194+
git config --local user.name "github-actions[bot]"
195+
git config --local commit.gpgsign false
196+
197+
# Stage each type of file explicitly
198+
shopt -s nullglob # Handle case where globs don't match
199+
200+
# Binary files
201+
for f in deps/*/lib/*.{so,so.*,dylib,dylib.*,a}; do
202+
if [ -f "$f" ]; then
203+
git add -f "$f"
204+
fi
205+
done
206+
207+
# Header files
208+
for f in deps/*/include/**/*.h; do
209+
if [ -f "$f" ]; then
210+
git add -f "$f"
211+
fi
212+
done
213+
214+
# Build info
215+
for f in deps/*/build-info.json; do
216+
if [ -f "$f" ]; then
217+
git add -f "$f"
218+
fi
219+
done
220+
221+
# Only commit if there are changes
222+
if ! git diff --cached --quiet; then
223+
git commit -m "Update native dependencies from ${{ github.sha }} [skip ci]
224+
225+
Dependencies built by workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
226+
227+
git push
228+
else
229+
echo "No changes to checked-in dependencies"
230+
fi

deps/verify_deps.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import hashlib
4+
import json
5+
import os
6+
import sys
7+
from pathlib import Path
8+
from typing import Dict, List, NamedTuple, Tuple
9+
10+
11+
class BuildInfo(NamedTuple):
12+
commit_sha: str
13+
platform: str
14+
files: Dict[str, str] # relative path -> sha256
15+
16+
17+
def calculate_checksum(file_path: Path) -> str:
18+
"""Calculate SHA-256 checksum of a file."""
19+
sha256_hash = hashlib.sha256()
20+
with open(file_path, "rb") as f:
21+
# Read in 1MB chunks to handle large files efficiently
22+
for byte_block in iter(lambda: f.read(4096 * 256), b""):
23+
sha256_hash.update(byte_block)
24+
return sha256_hash.hexdigest()
25+
26+
27+
def scan_deps(deps_dir: Path) -> Dict[str, str]:
28+
"""Scan directory for dependency files and calculate their checksums."""
29+
checksums = {}
30+
for file_path in deps_dir.rglob("*"):
31+
if not file_path.is_file():
32+
continue
33+
34+
# Only process shared libraries, static libraries, and headers
35+
if not (
36+
file_path.suffix in [".so", ".dylib", ".a", ".h"]
37+
or (
38+
file_path.suffix.startswith(".so.")
39+
or file_path.suffix.startswith(".dylib.")
40+
)
41+
):
42+
continue
43+
44+
# Get path relative to deps_dir
45+
rel_path = str(file_path.relative_to(deps_dir))
46+
try:
47+
checksums[rel_path] = calculate_checksum(file_path)
48+
except (IOError, OSError) as e:
49+
print(f"Error processing {rel_path}: {e}", file=sys.stderr)
50+
continue
51+
52+
return checksums
53+
54+
55+
def generate_build_info(deps_dir: Path, platform: str, commit_sha: str) -> BuildInfo:
56+
"""Generate build info for the given deps directory."""
57+
checksums = scan_deps(deps_dir)
58+
return BuildInfo(commit_sha=commit_sha, platform=platform, files=checksums)
59+
60+
61+
def verify_deps(deps_dir: Path, build_info: BuildInfo) -> Tuple[bool, List[str]]:
62+
"""Verify deps directory against build info."""
63+
mismatches = []
64+
valid = True
65+
66+
# Get current state of deps directory
67+
current_checksums = scan_deps(deps_dir)
68+
69+
print(f"Found {len(current_checksums)} files to verify")
70+
71+
# Check for missing or mismatched files
72+
for rel_path, expected_checksum in build_info.files.items():
73+
if rel_path not in current_checksums:
74+
mismatches.append(f"{rel_path}: file not found in deps directory")
75+
valid = False
76+
continue
77+
78+
actual_checksum = current_checksums[rel_path]
79+
if actual_checksum != expected_checksum:
80+
mismatches.append(
81+
f"{rel_path}: checksum mismatch\n"
82+
f" expected: {expected_checksum}\n"
83+
f" got: {actual_checksum}"
84+
)
85+
valid = False
86+
else:
87+
print(f"Verified: {rel_path}")
88+
89+
# Check for extra files
90+
for rel_path in current_checksums:
91+
if rel_path not in build_info.files:
92+
mismatches.append(f"{rel_path}: extra file in deps directory")
93+
valid = False
94+
95+
return valid, mismatches
96+
97+
98+
def main():
99+
parser = argparse.ArgumentParser(description="Verify Lilliput dependencies")
100+
101+
# Create subparsers first
102+
subparsers = parser.add_subparsers(dest="command", required=True)
103+
104+
# Generate command
105+
generate_parser = subparsers.add_parser(
106+
"generate", help="Generate build info for dependencies"
107+
)
108+
generate_parser.add_argument(
109+
"--deps-dir", required=True, type=Path, help="Directory containing dependencies"
110+
)
111+
generate_parser.add_argument(
112+
"--platform",
113+
required=True,
114+
choices=["linux", "macos"],
115+
help="Platform identifier",
116+
)
117+
generate_parser.add_argument(
118+
"--commit", required=True, help="Commit SHA that produced the build"
119+
)
120+
generate_parser.add_argument(
121+
"--output", type=Path, help="Output file (default: <deps-dir>/build-info.json)"
122+
)
123+
124+
# Verify command
125+
verify_parser = subparsers.add_parser(
126+
"verify", help="Verify deps against build info"
127+
)
128+
verify_parser.add_argument(
129+
"--deps-dir", required=True, type=Path, help="Directory containing dependencies"
130+
)
131+
verify_parser.add_argument(
132+
"--build-info", required=True, type=Path, help="Path to build info JSON file"
133+
)
134+
135+
args = parser.parse_args()
136+
137+
if not os.path.exists(args.deps_dir):
138+
print(f"Error: deps directory not found: {args.deps_dir}", file=sys.stderr)
139+
sys.exit(1)
140+
141+
if args.command == "generate":
142+
build_info = generate_build_info(args.deps_dir, args.platform, args.commit)
143+
144+
output_file = args.output or args.deps_dir / "build-info.json"
145+
146+
try:
147+
with open(output_file, "w") as f:
148+
json.dump(build_info._asdict(), f, indent=4)
149+
print(f"Build info generated successfully: {output_file}")
150+
except (IOError, OSError) as e:
151+
print(f"Error writing build info: {e}", file=sys.stderr)
152+
sys.exit(1)
153+
154+
elif args.command == "verify":
155+
try:
156+
with open(args.build_info) as f:
157+
build_info_dict = json.load(f)
158+
build_info = BuildInfo(**build_info_dict)
159+
except (IOError, OSError, json.JSONDecodeError) as e:
160+
print(f"Error reading build info: {e}", file=sys.stderr)
161+
sys.exit(1)
162+
163+
print(f"Verifying deps against build from commit {build_info.commit_sha}")
164+
valid, mismatches = verify_deps(args.deps_dir, build_info)
165+
166+
if not valid:
167+
print("\nVerification failed:")
168+
for mismatch in mismatches:
169+
print(f" {mismatch}")
170+
sys.exit(1)
171+
172+
print("\nAll dependencies verified successfully")
173+
174+
175+
if __name__ == "__main__":
176+
main()

0 commit comments

Comments
 (0)