Files
hermes-agent/tests/scripts/test_release_handoff.py
ethernet 7547d72763 feat(release): write the stable archive under the attempt ref
The archive readers already keyed every object on releases/tag/<attempt>;
the writers still keyed them on the plain payload tag, so a stable run
wrote releases/tag/vX.Y.Z/ while publish read releases/tag/rc.N-vX.Y.Z/
and found nothing. Derive one archive ref at admission (validate emits
archive-tag, jobs export HERMES_ARCHIVE_TAG) and use it at every writer
and reader of a releases/tag/<x>/ key in the stable path. The plain
vX.Y.Z keeps naming the payload identity: build stamps, package
versions, feed versions, and GitHub release bodies. Canary and channel
builds keep archive == payload tag, so their keys are unchanged.
2026-09-23 10:51:01 -04:00

254 lines
13 KiB
Python

"""Release handoffs use exact R2 bytes without advancing a channel."""
import copy
import json
from urllib.parse import unquote
import pytest
from scripts.releases import handoff, r2
from tests.scripts.test_release_r2 import r2_server # noqa: F401
@pytest.mark.parametrize('tag', ['v1.2.3', 'v1.2.3+canary.20260908T232538Z', 'rc.1-v1.2.3', None])
def test_stage_and_fetch_bind_tag_commit_and_files_without_feed_writes(tmp_path, monkeypatch, r2_server, tag):
commit = "a" * 40
identity = ['--tag', tag, '--commit', commit] if tag else ['--commit-build', commit]
prefix = f'releases/tag/{tag}/' if tag else f'releases/commit/{commit}/'
monkeypatch.setenv('GITHUB_SHA', 'c' * 40)
root = tmp_path / "built"
root.mkdir()
data = b"multi-chunk package transport fixture\n" * 90000
(root / "app.msix").write_bytes(data)
(root / "Store-app.msix").write_bytes(b"store transport fixture")
(root / "metadata-windows-x64.json").write_text("{}", encoding="utf-8")
nested = root / "apt" / "pool build" / "InRelease"
nested.parent.mkdir(parents=True)
nested.write_bytes(b"APT index transport fixture")
handoff.main(["stage", *identity, "--name", "win32-x64",
"--root", str(root), "--include", "*.msix", "--include", "metadata-*.json", "--include", "apt/**/*"])
receipt_key = prefix + 'handoff-win32-x64.json'
receipt = json.loads(r2_server.store[receipt_key][0])
assert receipt['schema'] == (1 if tag else 2) and receipt['commit'] == commit
assert receipt['tag'] == tag if tag else 'tag' not in receipt
assert {row["path"] for row in receipt["files"]} == {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()}
assert all(key.startswith(prefix) for key in r2_server.store)
puts = [path for method, path, _ in r2_server.requests if method == "PUT"]
assert unquote(puts[-1]).endswith(receipt_key)
assert all(headers.get("If-None-Match") == "*" for method, _, headers in r2_server.requests if method == "PUT")
downloaded = tmp_path / "downloaded"
handoff.main(["fetch", *identity, "--name", "win32-x64",
"--root", str(downloaded), "--include", "*.msix"])
assert (downloaded / "app.msix").read_bytes() == data
assert (downloaded / "Store-app.msix").read_bytes() == b"store transport fixture"
assert not (downloaded / "metadata-windows-x64.json").exists()
handoff.main(['stage', *identity, *([] if tag else ['--commit', commit]), '--name', 'win32-x64',
'--root', str(root), '--include', '*.msix', '--include', 'metadata-*.json', '--include', 'apt/**/*'])
handoff.main(['fetch', *identity, '--name', 'win32-x64', '--root', str(downloaded), '--include', 'apt/*'])
assert (downloaded / nested.relative_to(root)).read_bytes() == nested.read_bytes()
fetch = lambda target: handoff.main(['fetch', *identity, '--name', 'win32-x64', '--root', str(target)])
for changed in ({'schema': 2 if tag else 1}, {'commit': 'b' * 40}, {'tag': 'v9.9.9'}, {'name': 'wrong'}):
r2_server.store[receipt_key] = (json.dumps({**receipt, **changed}).encode(), '"e"')
with pytest.raises(ValueError, match='identity') as error:
fetch(tmp_path / 'wrong')
assert not isinstance(error.value, handoff.MissingReceipt)
for bad_path in ("../outside", "C:/outside", "dir\\outside", "a//b", "./a", "a%2fb", "a:b", "a?b", "a#b", '', '/abs.msix', 'NUL'):
bad = copy.deepcopy(receipt)
bad["files"][0]["path"] = bad_path
r2_server.store[receipt_key] = (json.dumps(bad).encode(), '"e"')
r2_server.requests.clear()
with pytest.raises(ValueError, match="path"):
fetch(tmp_path / 'unsafe')
assert len(r2_server.requests) == 1
bad = copy.deepcopy(receipt)
bad['files'] += [{**bad['files'][0], 'path': bad['files'][0]['path'].upper()}]
r2_server.store[receipt_key] = (json.dumps(bad).encode(), '"e"')
with pytest.raises(ValueError, match='Duplicate'):
fetch(tmp_path / 'duplicate')
r2_server.store[receipt_key] = (json.dumps(receipt).encode(), '"e"')
r2_server.store[prefix + 'app.msix'] = (b"different bytes", '"e"')
with pytest.raises(ValueError, match="checksum mismatch"):
fetch(downloaded)
assert (downloaded / "app.msix").read_bytes() == data
@pytest.mark.parametrize('commit_only', [False, True])
def test_failed_stage_never_publishes_a_receipt_or_channel(tmp_path, r2_server, commit_only):
tag, commit = "v1.2.3", "a" * 40
(tmp_path / "one.msix").write_bytes(b"first")
(tmp_path / "two.msix").write_bytes(b"second")
prefix = f'releases/commit/{commit}/' if commit_only else f'releases/tag/{tag}/'
identity = ['--commit-build', commit] if commit_only else ['--tag', tag, '--commit', commit]
r2_server.fail_put = prefix + 'two.msix'
with pytest.raises(r2.R2RequestError):
handoff.main(['stage', *identity, '--name', 'win32-x64', '--root', str(tmp_path), '--include', '*.msix'])
assert set(r2_server.store) == {prefix + 'one.msix'}
r2_server.requests.clear()
with pytest.raises(ValueError, match="No files"):
handoff.stage(tag, commit, "win32-x64", tmp_path, ["*.zip"])
assert r2_server.requests == []
with pytest.raises(ValueError, match="identity"):
handoff.stage("../bad", commit, "win32-x64", tmp_path, ["*.msix"])
assert r2_server.requests == []
@pytest.mark.parametrize("commit_only", [False, True])
def test_shared_receipt_files_download_once_and_conflicts_leave_targets_intact(tmp_path, r2_server, commit_only):
from scripts.releases import handoff
commit, tag = "a" * 40, "v1.2.3"
artifact = tmp_path / "shared.bin"
artifact.write_bytes(b"same shared bytes")
if commit_only:
stage = lambda name: handoff.stage_commit_build(commit, name, tmp_path, ["*.bin"])
fetch = lambda target: handoff.fetch_commit_build(commit, ["one", "two"], target)
prefix = r2.commit_prefix_for(commit)
else:
stage = lambda name: handoff.stage(tag, commit, name, tmp_path, ["*.bin"])
fetch = lambda target: handoff.fetch(tag, commit, ["one", "two"], target)
prefix = r2.staging_key_for(tag, "")
stage("one")
stage("two")
r2_server.requests.clear()
target = tmp_path / "download"
fetch(target)
assert (target / "shared.bin").read_bytes() == artifact.read_bytes()
gets = [url for method, url, _ in r2_server.requests if method == "GET"]
assert sum(url.endswith(prefix + "shared.bin") for url in gets) == 1
receipt_key = prefix + "handoff-two.json"
receipt = json.loads(r2_server.store[receipt_key][0])
receipt["files"][0]["sha256"] = "0" * 64
r2_server.store[receipt_key] = (json.dumps(receipt).encode(), '"changed"')
before = {file.name: file.read_bytes() for file in target.iterdir()}
with pytest.raises(ValueError, match="Conflicting"):
fetch(target)
assert {file.name: file.read_bytes() for file in target.iterdir()} == before
def test_commit_identity_and_simultaneous_namespaces(tmp_path, r2_server):
commit = 'a' * 40
for bad in ('abc', commit[:39], 'g' * 40, '', None, ' ' * 40):
with pytest.raises(ValueError):
handoff.validate_commit_identity(bad, 'win32-x64')
with pytest.raises(ValueError):
handoff.validate_commit_identity(commit, 'Bad Name')
(tmp_path / 'app.msix').write_bytes(b'commit bytes')
args = ['stage', '--commit-build', commit, '--name', 'win32-x64', '--root', str(tmp_path), '--include', '*.msix']
for conflict in (['--commit', 'b' * 40], ['--tag', 'v1.2.3']):
with pytest.raises(SystemExit):
handoff.main([*args, *conflict])
assert r2_server.requests == []
handoff.main(args)
(tmp_path / 'app.msix').write_bytes(b'tag bytes')
handoff.stage('v1.2.3', commit, 'win32-x64', tmp_path, ['*.msix'])
for identity, data in ((['--commit-build', commit], b'commit bytes'),
(['--tag', 'v1.2.3', '--commit', commit], b'tag bytes')):
handoff.main(['fetch', *identity, '--name', 'win32-x64', '--root', str(tmp_path / 'out')])
assert (tmp_path / 'out/app.msix').read_bytes() == data
with pytest.raises(handoff.MissingReceipt):
handoff.read_commit_receipt('b' * 40, 'win32-x64')
r2_server.store[f'releases/commit/{commit}/handoff-win32-x64.json'] = (b'not-json', '"e"')
with pytest.raises(json.JSONDecodeError):
handoff.read_commit_receipt(commit, 'win32-x64')
@pytest.mark.parametrize('tag', ['v1.2.3', None])
def test_public_handoff_downloads_exact_staged_bytes_without_credentials(tmp_path, monkeypatch, r2_server, tag):
import os
from pathlib import Path
import subprocess
import sys
commit = 'a' * 40
identity = ['--tag', tag, '--commit', commit] if tag else ['--commit-build', commit]
prefix = f'releases/tag/{tag}/' if tag else f'releases/commit/{commit}/'
built = tmp_path / 'built'
built.mkdir()
payload = b'public artifact transport fixture\n' * 90000
(built / 'app x64.msix').write_bytes(payload)
handoff.main(['stage', *identity, '--name', 'win32-x64', '--root', str(built), '--include', '*.msix'])
for key in ('CLOUDFLARE_R2_ACCOUNT_ID', 'CLOUDFLARE_R2_ACCESS_KEY_ID',
'CLOUDFLARE_R2_SECRET_ACCESS_KEY', 'CLOUDFLARE_R2_BUCKET'):
monkeypatch.delenv(key)
r2_server.requests.clear()
download = tmp_path / 'downloaded'
base = f'http://127.0.0.1:{r2_server.server_port}/hermes-releases'
args = ['fetch', *identity, '--public-base', base, '--name', 'win32-x64',
'--root', str(download), '--include', '*.msix']
handoff.main(args)
assert (download / 'app x64.msix').read_bytes() == payload
receipt_file = download / 'handoff-win32-x64.json'
original_receipt = receipt_file.read_bytes()
assert json.loads(original_receipt)['commit'] == commit
assert all(method == 'GET' and not any(k.lower() == 'authorization' for k in headers)
for method, _, headers in r2_server.requests)
assert any(path.endswith('/app%20x64.msix') for _, path, _ in r2_server.requests)
result = subprocess.run([sys.executable, '-m', 'scripts.releases.handoff', *args],
cwd=Path(__file__).resolve().parents[2], env=os.environ,
capture_output=True, text=True, encoding='utf-8', timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
assert (download / 'app x64.msix').read_bytes() == payload
# A bad CDN object cannot replace a previously verified local download.
r2_server.store[prefix + 'app x64.msix'] = (b'corrupt', '"changed"')
with pytest.raises(ValueError, match='checksum mismatch'):
handoff.main(args)
assert (download / 'app x64.msix').read_bytes() == payload
assert receipt_file.read_bytes() == original_receipt
assert sorted(p.name for p in download.iterdir()) == ['app x64.msix', receipt_file.name]
bad = json.loads(original_receipt)
bad['commit'] = 'b' * 40
r2_server.store[prefix + receipt_file.name] = (json.dumps(bad).encode(), '"bad"')
r2_server.requests.clear()
with pytest.raises(ValueError, match='identity'):
handoff.main(args)
assert len(r2_server.requests) == 1
bad = json.loads(original_receipt)
bad['files'][0]['path'] = '../outside.msix'
r2_server.store[prefix + receipt_file.name] = (json.dumps(bad).encode(), '"bad"')
r2_server.requests.clear()
with pytest.raises(ValueError, match='path'):
handoff.main(args)
assert len(r2_server.requests) == 1
assert not (tmp_path / 'outside.msix').exists()
def test_public_handoff_rejects_unsafe_origins_paths_and_redirects(tmp_path, r2_server):
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
commit = 'a' * 40
args = ['fetch', '--commit-build', commit, '--name', 'win32-x64', '--root', str(tmp_path / 'out')]
for base in ('http://cdn.example', 'https://user:password@cdn.example', 'file:///tmp',
'https://cdn.example/?token=private', 'https://cdn.example/#fragment',
'https://cdn.example/../escape', 'https://cdn.example/%2e%2e/escape'):
with pytest.raises(ValueError, match='public'):
handoff.main([*args, '--public-base', base])
assert r2_server.requests == []
class Redirect(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header('Location', f'http://127.0.0.1:{r2_server.server_port}/hermes-releases/unwanted')
self.send_header('Content-Length', '0')
self.end_headers()
def log_message(self, format, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Redirect)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with pytest.raises(r2.R2RequestError) as error:
handoff.main([*args, '--public-base', f'http://127.0.0.1:{server.server_port}'])
assert error.value.status == 302
assert r2_server.requests == []
assert not (tmp_path / 'out').exists()
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)