Skip to content

Commit 7e5ea5c

Browse files
ci: avoid redundant S3 uploads (#326)
Co-authored-by: Hermes Agent <hermes-agent@users.noreply.github.com>
1 parent a2c7078 commit 7e5ea5c

4 files changed

Lines changed: 205 additions & 2 deletions

File tree

.github/workflows/build_master.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,20 @@ jobs:
153153
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
154154
aws-region: us-east-1
155155

156-
# Sync the build to S3
156+
# Sync the build to S3. mdBook refreshes mtimes on every build, so first
157+
# age byte-identical files based on S3 ETags; `s3 sync` then uploads only
158+
# genuinely changed/new output while retaining normal --delete behavior.
157159
- name: Sync to S3
158-
run: aws s3 sync ./book s3://hacktricks-cloud/en --delete
160+
run: |
161+
aws s3api list-objects-v2 \
162+
--bucket hacktricks-cloud \
163+
--prefix en/ \
164+
--output json > /tmp/s3-en-manifest.json
165+
python3 scripts/mark_unchanged_s3_files.py \
166+
--source ./book \
167+
--manifest /tmp/s3-en-manifest.json \
168+
--remote-prefix en/
169+
aws s3 sync ./book s3://hacktricks-cloud/en --delete
159170
160171
- name: Upload root sitemap index
161172
run: |

.github/workflows/translate_all.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ jobs:
8484
wget -O /tmp/compare_and_fix_refs.py https://raw.githubusercontent.com/HackTricks-wiki/hacktricks-cloud/master/scripts/compare_and_fix_refs.py
8585
wget -O /tmp/translator.py https://raw.githubusercontent.com/HackTricks-wiki/hacktricks-cloud/master/scripts/translator.py
8686
wget -O /tmp/seo_postprocess.py https://raw.githubusercontent.com/HackTricks-wiki/hacktricks-cloud/master/scripts/seo_postprocess.py
87+
cp scripts/mark_unchanged_s3_files.py /tmp/mark_unchanged_s3_files.py
8788
8889
- name: Run get_and_save_refs.py
8990
run: |
@@ -272,6 +273,16 @@ jobs:
272273
echo "Current branch:"
273274
git rev-parse --abbrev-ref HEAD
274275
echo "Syncing $BRANCH to S3"
276+
# mdBook refreshes mtimes on every build. Age byte-identical files
277+
# based on single-part S3 ETags so `s3 sync` skips redundant PUTs.
278+
aws s3api list-objects-v2 \
279+
--bucket hacktricks-cloud \
280+
--prefix "$BRANCH/" \
281+
--output json > /tmp/s3-branch-manifest.json
282+
python3 /tmp/mark_unchanged_s3_files.py \
283+
--source ./book \
284+
--manifest /tmp/s3-branch-manifest.json \
285+
--remote-prefix "$BRANCH/"
275286
aws s3 sync ./book s3://hacktricks-cloud/$BRANCH --delete
276287
echo "Sync completed"
277288
echo "Cat 3 files from the book"

scripts/mark_unchanged_s3_files.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env python3
2+
"""Avoid redundant S3 uploads by aging byte-identical local files.
3+
4+
`aws s3 sync` uploads a local file when its size differs from the remote object or
5+
when the local modification time is newer. mdBook rebuilds refresh mtimes even
6+
when output bytes are unchanged, which causes unnecessary PutObject requests.
7+
8+
Given a ListObjectsV2 manifest, this script compares local MD5 digests with
9+
single-part S3 ETags. Byte-identical files are assigned an old mtime so the
10+
following `aws s3 sync --delete` skips them. New, changed, multipart, and
11+
otherwise unverifiable objects are left untouched and therefore upload normally.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
import hashlib
18+
import json
19+
import os
20+
from pathlib import Path
21+
from typing import Any
22+
23+
OLD_MTIME_SECONDS = 1
24+
25+
26+
def file_md5(path: Path, chunk_size: int = 1024 * 1024) -> str:
27+
digest = hashlib.md5(usedforsecurity=False)
28+
with path.open("rb") as handle:
29+
for chunk in iter(lambda: handle.read(chunk_size), b""):
30+
digest.update(chunk)
31+
return digest.hexdigest()
32+
33+
34+
def load_remote_objects(manifest_path: Path, remote_prefix: str) -> dict[str, dict[str, Any]]:
35+
if remote_prefix.startswith("/"):
36+
raise ValueError("remote prefix must not start with '/'")
37+
if remote_prefix and not remote_prefix.endswith("/"):
38+
remote_prefix += "/"
39+
40+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
41+
contents = payload.get("Contents", [])
42+
if contents is None:
43+
contents = []
44+
if not isinstance(contents, list):
45+
raise ValueError("manifest Contents must be a list")
46+
47+
objects: dict[str, dict[str, Any]] = {}
48+
for item in contents:
49+
if not isinstance(item, dict):
50+
continue
51+
key = str(item.get("Key") or "")
52+
if not key.startswith(remote_prefix):
53+
continue
54+
relative_key = key[len(remote_prefix):]
55+
if not relative_key or relative_key.endswith("/"):
56+
continue
57+
objects[relative_key] = item
58+
return objects
59+
60+
61+
def mark_unchanged_files(source: Path, remote: dict[str, dict[str, Any]]) -> dict[str, int]:
62+
if not source.is_dir():
63+
raise ValueError(f"source is not a directory: {source}")
64+
65+
stats = {"local_files": 0, "unchanged": 0, "upload_candidates": 0}
66+
for path in source.rglob("*"):
67+
if not path.is_file():
68+
continue
69+
stats["local_files"] += 1
70+
relative_key = path.relative_to(source).as_posix()
71+
item = remote.get(relative_key)
72+
if not item:
73+
stats["upload_candidates"] += 1
74+
continue
75+
76+
etag = str(item.get("ETag") or "").strip('"').lower()
77+
raw_size = item.get("Size")
78+
try:
79+
remote_size = int(raw_size) if raw_size is not None else -1
80+
except (TypeError, ValueError):
81+
remote_size = -1
82+
83+
# A dashed ETag is normally multipart and is not the object's MD5.
84+
verifiable = len(etag) == 32 and "-" not in etag and all(c in "0123456789abcdef" for c in etag)
85+
if verifiable and remote_size == path.stat().st_size and file_md5(path) == etag:
86+
os.utime(path, (OLD_MTIME_SECONDS, OLD_MTIME_SECONDS), follow_symlinks=True)
87+
stats["unchanged"] += 1
88+
else:
89+
stats["upload_candidates"] += 1
90+
return stats
91+
92+
93+
def main() -> int:
94+
parser = argparse.ArgumentParser(description=__doc__)
95+
parser.add_argument("--source", type=Path, required=True)
96+
parser.add_argument("--manifest", type=Path, required=True)
97+
parser.add_argument("--remote-prefix", default="")
98+
args = parser.parse_args()
99+
100+
remote = load_remote_objects(args.manifest, args.remote_prefix)
101+
stats = mark_unchanged_files(args.source, remote)
102+
stats["remote_objects"] = len(remote)
103+
print(json.dumps(stats, sort_keys=True))
104+
return 0
105+
106+
107+
if __name__ == "__main__":
108+
raise SystemExit(main())
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import hashlib
2+
import json
3+
import os
4+
import tempfile
5+
import unittest
6+
from pathlib import Path
7+
8+
from scripts.mark_unchanged_s3_files import OLD_MTIME_SECONDS, load_remote_objects, mark_unchanged_files
9+
10+
11+
class MarkUnchangedS3FilesTest(unittest.TestCase):
12+
def setUp(self) -> None:
13+
self.temp_dir = tempfile.TemporaryDirectory()
14+
self.root = Path(self.temp_dir.name)
15+
self.source = self.root / "book"
16+
self.source.mkdir()
17+
18+
def tearDown(self) -> None:
19+
self.temp_dir.cleanup()
20+
21+
@staticmethod
22+
def _etag(data: bytes) -> str:
23+
return hashlib.md5(data, usedforsecurity=False).hexdigest()
24+
25+
def _write_manifest(self, contents: list[dict]) -> Path:
26+
path = self.root / "manifest.json"
27+
path.write_text(json.dumps({"Contents": contents}), encoding="utf-8")
28+
return path
29+
30+
def test_only_byte_identical_single_part_objects_are_aged(self) -> None:
31+
unchanged = self.source / "nested" / "same.html"
32+
changed = self.source / "changed.html"
33+
multipart = self.source / "large.bin"
34+
new = self.source / "new.txt"
35+
unchanged.parent.mkdir()
36+
unchanged.write_bytes(b"same")
37+
changed.write_bytes(b"local")
38+
multipart.write_bytes(b"large")
39+
new.write_bytes(b"new")
40+
for path in (unchanged, changed, multipart, new):
41+
os.utime(path, (1000, 1000))
42+
43+
manifest = self._write_manifest([
44+
{"Key": "en/nested/same.html", "Size": 4, "ETag": f'"{self._etag(b"same")}"'},
45+
{"Key": "en/changed.html", "Size": 5, "ETag": f'"{self._etag(b"other")}"'},
46+
{"Key": "en/large.bin", "Size": 5, "ETag": '"0123456789abcdef0123456789abcdef-2"'},
47+
{"Key": "other/ignored.txt", "Size": 1, "ETag": '"0cc175b9c0f1b6a831c399e269772661"'},
48+
])
49+
50+
remote = load_remote_objects(manifest, "en")
51+
stats = mark_unchanged_files(self.source, remote)
52+
53+
self.assertEqual(int(unchanged.stat().st_mtime), OLD_MTIME_SECONDS)
54+
self.assertEqual(int(changed.stat().st_mtime), 1000)
55+
self.assertEqual(int(multipart.stat().st_mtime), 1000)
56+
self.assertEqual(int(new.stat().st_mtime), 1000)
57+
self.assertEqual(stats, {"local_files": 4, "unchanged": 1, "upload_candidates": 3})
58+
self.assertNotIn("ignored.txt", remote)
59+
60+
def test_rejects_absolute_remote_prefix(self) -> None:
61+
manifest = self._write_manifest([])
62+
with self.assertRaises(ValueError):
63+
load_remote_objects(manifest, "/en/")
64+
65+
def test_rejects_non_list_contents(self) -> None:
66+
path = self.root / "manifest.json"
67+
path.write_text('{"Contents": {}}', encoding="utf-8")
68+
with self.assertRaises(ValueError):
69+
load_remote_objects(path, "en/")
70+
71+
72+
if __name__ == "__main__":
73+
unittest.main()

0 commit comments

Comments
 (0)