diff --git a/Makefile b/Makefile index 414d9401..7513ecd8 100644 --- a/Makefile +++ b/Makefile @@ -191,6 +191,7 @@ ci-signoff: exit $$result PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-production-image.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/deploy/test_lease.py + PYTHONDONTWRITEBYTECODE=1 python3 scripts/deploy/test_run.py python3 scripts/production-image.py signoff # What CI would run right now, without running any of it. diff --git a/scripts/deploy/run.py b/scripts/deploy/run.py index 5b3459ef..61ab2195 100644 --- a/scripts/deploy/run.py +++ b/scripts/deploy/run.py @@ -29,6 +29,33 @@ def output(*args): return execute(*args, stdout=subprocess.PIPE, text=True).stdout.strip() +# Paths deploy.yml refuses to deploy for, because they cannot change the image. +# Kept in step with its `paths-ignore`; test_run.py asserts the two agree. +UNDEPLOYABLE = ('docs/', 'bench/') + + +def only_undeployable(lease, current, master): + """Whether `master` differs from `current` by nothing that could change the image. + + A documentation merge landing seconds after a code merge used to cancel that + code deploy: the rollout saw a newer master and declined, while the docs push + started no rollout of its own because deploy.yml ignores those paths. The + result was a green deploy job and a production box still running the previous + image, with nothing reporting the gap (2026-09-10, PR #251). + + Fails CLOSED. If the newer commits cannot be fetched or inspected, the answer + is False and the rollout defers exactly as it did before. + """ + try: + lease.git('fetch', '--quiet', '--depth=50', 'origin', master) + changed = lease.git('diff', '--name-only', f'{current}..{master}').splitlines() + except subprocess.CalledProcessError: + return False + if not changed: + return False + return all(path.startswith(UNDEPLOYABLE) or path.endswith('.md') for path in changed) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('mode', choices=('local', 'ci')) @@ -67,7 +94,7 @@ def interrupted(signum, frame): with lease.hold(image) as guard: current = lease.git('rev-parse', 'HEAD') master = lease.remote('refs/heads/master') - if current != master: + if current != master and not only_undeployable(lease, current, master): if local: parser.error('The checkout must be the current merged master commit') print('A newer master commit superseded this rollout; production is unchanged.') diff --git a/scripts/deploy/test_run.py b/scripts/deploy/test_run.py new file mode 100644 index 00000000..5fdc266a --- /dev/null +++ b/scripts/deploy/test_run.py @@ -0,0 +1,69 @@ +"""A docs-only merge must not cancel the code deploy it lands behind.""" +import pathlib +import re +import subprocess +import tempfile +import unittest + +from lease import DeploymentLease +from run import UNDEPLOYABLE, only_undeployable + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def repo(root, *rounds): + """A clone whose origin/master carries each round of paths as one commit.""" + remote, work = root / 'remote', root / 'work' + run = lambda *args, **kw: subprocess.run(['git', *map(str, args)], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kw) + run('init', '--bare', '--initial-branch=master', remote) + run('clone', remote, work) + run('-C', work, 'config', 'user.email', 'test@example.com') + run('-C', work, 'config', 'user.name', 'test') + commits = [] + for round_index, paths in enumerate(rounds): + for path in paths: + target = work / path + target.parent.mkdir(parents=True, exist_ok=True) + # Content must differ per round, or a rewritten path is not a diff. + target.write_text(f'{path} round {round_index}') + run('-C', work, 'add', '-A') + run('-C', work, 'commit', '-m', ' '.join(paths)) + commits.append(subprocess.run(['git', '-C', work, 'rev-parse', 'HEAD'], check=True, text=True, stdout=subprocess.PIPE).stdout.strip()) + run('-C', work, 'push', '--quiet', 'origin', 'master') + return DeploymentLease(work), commits + + +class SupersedeTest(unittest.TestCase): + def check(self, later): + with tempfile.TemporaryDirectory() as directory: + lease, commits = repo(pathlib.Path(directory), ['src/main.rs'], later) + return only_undeployable(lease, commits[0], commits[1]) + + def test_docs_only_does_not_supersede(self): + for paths in (['docs/plan.md'], ['README.md'], ['bench/probe.py'], ['docs/a.md', 'bench/b.py']): + with self.subTest(paths=paths): + self.assertTrue(self.check(paths), f'{paths} cannot change the image, so it must not cancel a rollout') + + def test_code_still_supersedes(self): + for paths in (['src/main.rs'], ['Cargo.toml'], ['docs/a.md', 'src/main.rs']): + with self.subTest(paths=paths): + self.assertFalse(self.check(paths), f'{paths} can change the image, so it must still defer the rollout') + + def test_identical_commits_are_not_treated_as_undeployable(self): + with tempfile.TemporaryDirectory() as directory: + lease, commits = repo(pathlib.Path(directory), ['src/main.rs']) + self.assertFalse(only_undeployable(lease, commits[0], commits[0]), 'an empty diff must not be read as a docs-only difference') + + def test_ignore_list_matches_the_workflow(self): + """Drift here is silent: the workflow would skip a path this still defers on.""" + # Parsed rather than yaml-loaded so the check needs no third-party module. + text = (ROOT / '.github/workflows/deploy.yml').read_text() + block = re.search(r'^\s*paths-ignore:\n((?:\s*-\s.*\n)+)', text, re.M) + self.assertIsNotNone(block, 'deploy.yml no longer declares paths-ignore') + ignored = {re.sub(r"^\s*-\s*|['\"]", '', line) for line in block.group(1).splitlines() if line.strip()} + covered = {p.rstrip('/') + '/**' for p in UNDEPLOYABLE} | {'**/*.md'} + self.assertEqual(ignored, covered, 'deploy.yml paths-ignore and run.py UNDEPLOYABLE disagree') + + +if __name__ == '__main__': + unittest.main()