diff --git a/.github/workflows/call-validation.yml b/.github/workflows/call-validation.yml index 05e395339b..79155629e2 100644 --- a/.github/workflows/call-validation.yml +++ b/.github/workflows/call-validation.yml @@ -109,12 +109,13 @@ jobs: PR_SCOPE: ${{ inputs.pr_scope }} run: ./scripts/meta/resolve/pr/branch_prefix.sh - - name: 🔀 Assert the base branch is fully merged into the PR head + - name: 🔀 Assert the event's base commit is merged into the PR head if: ${{ github.event_name == 'pull_request' && inputs.pr_scope != '' }} working-directory: pr-head env: INFINITO_MAIN_REMOTE: origin INFINITO_MAIN_BRANCH: ${{ github.event.pull_request.base.ref }} + INFINITO_MAIN_COMMIT: ${{ github.event.pull_request.base.sha }} run: bash "${GITHUB_WORKSPACE}/.base-tooling/scripts/git/assert/main_merged.sh" - name: 🔧 Set up Python diff --git a/scripts/git/assert/main_merged.sh b/scripts/git/assert/main_merged.sh index bb63e43b46..ac7471884d 100755 --- a/scripts/git/assert/main_merged.sh +++ b/scripts/git/assert/main_merged.sh @@ -4,23 +4,37 @@ # from the current tip, so a branch can never be pushed while it lags main. # # Env overrides: INFINITO_MAIN_REMOTE (default origin), INFINITO_MAIN_BRANCH -# (default main). +# (default main), INFINITO_MAIN_COMMIT (unset: check the live branch tip; set: +# fetch and check this full commit SHA of that branch instead, e.g. the base +# SHA a CI event carries). set -euo pipefail remote="${INFINITO_MAIN_REMOTE:-origin}" # nocheck: git maintainer tooling knob, not a deployment env key branch="${INFINITO_MAIN_BRANCH:-main}" # nocheck: git maintainer tooling knob, not a deployment env key +if [[ -v INFINITO_MAIN_COMMIT ]]; then + target="${INFINITO_MAIN_COMMIT}" # nocheck: git maintainer tooling knob, not a deployment env key + if [[ ! "$target" =~ ^[0-9a-f]{40}([0-9a-f]{24})?$ ]]; then + echo "❌ assert-main-merged: INFINITO_MAIN_COMMIT must be a full commit SHA, got '${target}'." >&2 + exit 2 + fi + label="${remote}/${branch}@${target}" +else + target="$branch" + label="${remote}/${branch}" +fi + cd "$(git rev-parse --show-toplevel)" -echo "assert-main-merged: fetching ${remote}/${branch} ..." >&2 -git fetch --quiet "$remote" "$branch" +echo "assert-main-merged: fetching ${label} ..." >&2 +git fetch --quiet "$remote" "$target" if git merge-base --is-ancestor FETCH_HEAD HEAD; then - echo "✅ assert-main-merged: ${remote}/${branch} is fully merged into HEAD." >&2 + echo "✅ assert-main-merged: ${label} is fully merged into HEAD." >&2 exit 0 fi behind="$(git rev-list --count HEAD..FETCH_HEAD)" -echo "❌ assert-main-merged: ${remote}/${branch} has ${behind} commit(s) not merged into HEAD." >&2 +echo "❌ assert-main-merged: ${label} has ${behind} commit(s) not merged into HEAD." >&2 echo " Merge upstream main first, e.g.: git pull ${remote} ${branch}" >&2 exit 1 diff --git a/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py b/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py index 16c42ed62b..7395b5f012 100644 --- a/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py +++ b/tests/lint/filesystem/env/test_worktree_swarm_lab_subnet.py @@ -71,10 +71,16 @@ class TestWorktreeSwarmLabSubnet(unittest.TestCase): with self.subTest(slot=slot): subnet = self._subnet(slot) clashes = sorted( - name for name, network in self.reserved.items() if subnet.overlaps(network) + name + for name, network in self.reserved.items() + if subnet.overlaps(network) + ) + self.assertEqual( + clashes, [], f"slot {slot} lab {subnet} overlaps {clashes}" + ) + self.assertNotIn( + subnet, seen, f"slot {slot} repeats slot {seen.get(subnet)}" ) - self.assertEqual(clashes, [], f"slot {slot} lab {subnet} overlaps {clashes}") - self.assertNotIn(subnet, seen, f"slot {slot} repeats slot {seen.get(subnet)}") seen[subnet] = slot diff --git a/tests/unit/python/scripts/git/__init__.py b/tests/unit/python/scripts/git/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/scripts/git/assert/__init__.py b/tests/unit/python/scripts/git/assert/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/scripts/git/assert/test_main_merged.py b/tests/unit/python/scripts/git/assert/test_main_merged.py new file mode 100644 index 0000000000..5a41470cb1 --- /dev/null +++ b/tests/unit/python/scripts/git/assert/test_main_merged.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +from utils.cache.files import PROJECT_ROOT + +SCRIPT = PROJECT_ROOT / "scripts" / "git" / "assert" / "main_merged.sh" +MAIN_COMMIT_ENV = "INFINITO_MAIN_COMMIT" # nocheck: git maintainer tooling knob, not a deployment env key + + +class MainMergedFixture: + """A bare ``origin`` whose ``main`` moved on after ``head`` branched off it. + + Args: + tmp: directory that holds the bare remote and its two clones. + """ + + def __init__(self, tmp: str) -> None: + root = Path(tmp) + inherited = { + k: v + for k, v in os.environ.items() + if not k.startswith(("GIT_", "INFINITO_")) + } + self.env = { + **inherited, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + } + origin = root / "origin.git" + maintainer = root / "maintainer" + self.head = root / "head" + self.git(root, "init", "--bare", "-b", "main", str(origin)) + self.git(root, "init", "-b", "main", str(maintainer)) + self.git(maintainer, "remote", "add", "origin", str(origin)) + self.base = self.commit(maintainer, "base") + self.git(maintainer, "push", "origin", "main") + self.git(root, "clone", str(origin), str(self.head)) + self.git(self.head, "switch", "-c", "feature") + self.commit(self.head, "feature") + self.main_tip = self.commit(maintainer, "main moved on") + self.git(maintainer, "push", "origin", "main") + + def git(self, cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], + cwd=cwd, + env=self.env, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + def commit(self, repo: Path, message: str) -> str: + self.git(repo, "commit", "--allow-empty", "-m", message) + return self.git(repo, "rev-parse", "HEAD") + + def merge_main(self) -> None: + self.git(self.head, "pull", "--no-rebase", "--no-edit", "origin", "main") + + def run_script(self, main_commit: str | None) -> subprocess.CompletedProcess: + """Run the script in ``head``. + + Args: + main_commit: value of the main-commit override; ``None`` leaves it unset. + """ + env = dict(self.env) + if main_commit is not None: + env[MAIN_COMMIT_ENV] = main_commit + return subprocess.run( + ["bash", str(SCRIPT)], + cwd=self.head, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +class TestMainMergedPinnedCommit(unittest.TestCase): + def test_contained_commit_passes_after_main_moved_on(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(fixture.base) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"origin/main@{fixture.base} is fully merged", result.stderr) + + def test_commit_missing_from_head_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(fixture.main_tip) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn( + f"origin/main@{fixture.main_tip} has 1 commit(s) not merged into HEAD", + result.stderr, + ) + + def test_empty_commit_fails_loudly(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script("") + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("must be a full commit SHA", result.stderr) + + +class TestMainMergedLiveTip(unittest.TestCase): + def test_live_tip_missing_from_head_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + result = fixture.run_script(None) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn( + "origin/main has 1 commit(s) not merged into HEAD", result.stderr + ) + + def test_merged_live_tip_passes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fixture = MainMergedFixture(tmp) + fixture.merge_main() + result = fixture.run_script(None) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("origin/main is fully merged into HEAD", result.stderr) + + +if __name__ == "__main__": + unittest.main()