Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,39 @@ permissions:
contents: read

jobs:
workflow-guard-submodule-forward-only:
name: workflow-guard-submodule-forward-only
runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-4vcpu-ubuntu-2404' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout with submodules
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# The synthetic PR commit and its first parent are sufficient for
# the merge-base comparison, and keep this guard shallow like the
# neighboring history guard.
fetch-depth: 2
submodules: recursive
persist-credentials: false

- name: Bind submodule guard to synthetic merge parent
run: |
set -euo pipefail
base="$(git rev-parse HEAD^1)"
git cat-file -e "$base^{commit}"
echo "GITHUB_BASE_SHA=$base" >> "$GITHUB_ENV"
echo "GITHUB_HEAD_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"

- name: Validate submodule gitlinks move forward only
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 scripts/ci/submodule_forward_only.py

- name: Test submodule forward-only guard
run: python3 tests/test_submodule_forward_only.py

workflow-guard-tests:
if: ${{ inputs.linux_guard_tests == 'true' }}
name: workflow-guard-tests / ${{ matrix.group }}
Expand Down Expand Up @@ -1199,6 +1232,7 @@ jobs:
guard-status:
name: Guard status
needs:
- workflow-guard-submodule-forward-only
- workflow-guard-tests
- workflow-guard-history
- workflow-guard-cli-scripts
Expand All @@ -1220,13 +1254,18 @@ jobs:
inputs = json.loads(os.environ["GUARD_INPUTS"])
needs = json.loads(os.environ["GUARD_NEEDS"])
route_jobs = {
"submodule_forward_only": "workflow-guard-submodule-forward-only",
"linux_guard_tests": "workflow-guard-tests",
"linux_guard_history": "workflow-guard-history",
"linux_guard_cli": "workflow-guard-cli-scripts",
"linux_guard_source": "workflow-guard-source-lints",
}
bad = {}
for route, job in sorted(route_jobs.items()):
if route == "submodule_forward_only":
if needs[job]["result"] != "success":
bad[job] = needs[job]["result"]
continue
value = inputs.get(route)
result = needs[job]["result"]
if value not in {"true", "false"}:
Expand Down
4 changes: 4 additions & 0 deletions scripts/ci/run_ci_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = ROOT / ".github/workflows/ci-guards.yml"
GUARD_JOBS = (
"workflow-guard-submodule-forward-only",
"workflow-guard-tests",
"workflow-guard-history",
"workflow-guard-cli-scripts",
Expand Down Expand Up @@ -196,8 +197,11 @@ def plan(workflow: dict, base_sha: str, head_sha: str) -> list[Unit]:
context = {
"matrix.group": group or "",
"github.sha": head_sha,
"github.token": "",
"github.event.pull_request.head.sha": head_sha,
"github.event.pull_request.base.sha": base_sha,
"github.event.merge_group.base_sha": base_sha,
"github.event.before": base_sha,
}
steps: list[Step] = []
for raw in job["steps"]:
Expand Down
179 changes: 179 additions & 0 deletions scripts/ci/submodule_forward_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Reject submodule gitlinks that move backwards from the merge base."""
from __future__ import annotations

import argparse
import configparser
import os
import re
import subprocess
import sys
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

MARKER_PREFIX = "submodule-forward-only: allow "


def run(*args: str, cwd: str | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def gitlink(ref: str, path: str) -> str | None:
result = run("git", "rev-parse", f"{ref}:{path}")
if result.returncode:
ref_exists = run("git", "rev-parse", f"{ref}^{{commit}}")
if ref_exists.returncode:
raise RuntimeError(result.stderr.strip() or f"gitlink {ref}:{path} is unavailable")
return None
return result.stdout.strip()


def submodule_paths() -> list[tuple[str, str]]:
parser = configparser.ConfigParser()
parser.read(".gitmodules")
return [(parser[section]["path"], parser[section]["url"])
for section in parser.sections() if parser.has_option(section, "path")]


def merge_base(base: str, head: str) -> str:
result = run("git", "merge-base", base, head)
if result.returncode or not result.stdout.strip():
raise RuntimeError(
f"cannot compute merge base of {base} and {head}: "
f"{result.stderr.strip() or 'git merge-base returned no result'}"
)
return result.stdout.strip()


def local_relation(path: str, base: str, new: str) -> str | None:
fetch = run("git", "-C", path, "fetch", "origin", base, new)
# Fetch failure is expected in partial or shallow clones. Try the checks
# anyway because the objects may already be present locally.
forward = run("git", "-C", path, "merge-base", "--is-ancestor", base, new)
backward = run("git", "-C", path, "merge-base", "--is-ancestor", new, base)
if forward.returncode == 0:
return "forward"
if backward.returncode == 0:
return "backward"
base_exists = run("git", "-C", path, "cat-file", "-e", f"{base}^{{commit}}")
new_exists = run("git", "-C", path, "cat-file", "-e", f"{new}^{{commit}}")
if base_exists.returncode == 0 and new_exists.returncode == 0:
return "diverged"
return None


def github_relation(url: str, new: str, base: str) -> str | None:
match = re.search(r"github\.com[:/]([^/]+)/([^/#]+?)(?:\.git)?$", url)
if not match:
return None
owner, repo = match.groups()
endpoint = f"https://api.github.com/repos/{owner}/{repo}/compare/{new}...{base}"
request = Request(endpoint, headers={"Accept": "application/vnd.github+json"})
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
request.add_header("Authorization", f"Bearer {token}")
try:
import json
with urlopen(request, timeout=15) as response:
data = json.load(response)
except (HTTPError, URLError, OSError, ValueError):
return None
status = data.get("status")
ahead = data.get("ahead_by")
behind = data.get("behind_by")
# The API compares new...base. Thus base ahead means new is backward.
if status == "identical" or (ahead == 0 and behind == 0):
return "unchanged"
if status == "diverged" or (
isinstance(ahead, int) and ahead > 0 and isinstance(behind, int) and behind > 0
):
return "diverged"
if status == "behind" or (isinstance(behind, int) and behind > 0):
return "forward"
if status == "ahead" or (isinstance(ahead, int) and ahead > 0):
return "backward"
return None


def dropped(path: str, new: str, base: str) -> list[str]:
result = run("git", "-C", path, "log", "--format=%s", f"{new}..{base}")
return result.stdout.splitlines() if result.returncode == 0 else []


def rollback_declared(base: str, head: str) -> set[str]:
result = run("git", "log", "--format=%B", f"{base}..{head}")
if result.returncode:
return set()
return {
line.strip()[len(MARKER_PREFIX):].strip()
for line in result.stdout.splitlines()
if line.strip().lower().startswith(MARKER_PREFIX)
and line.strip()[len(MARKER_PREFIX):].strip()
}


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", default=os.environ.get("GITHUB_BASE_SHA") or "origin/main")
parser.add_argument("--head", default=os.environ.get("GITHUB_HEAD_SHA") or "HEAD")
args = parser.parse_args()
try:
modules = submodule_paths()
except (configparser.Error, KeyError) as exc:
print(f"submodule-forward-only: cannot read .gitmodules: {exc}", file=sys.stderr)
return 1
try:
comparison = merge_base(args.base, args.head)
except RuntimeError as exc:
print(f"submodule-forward-only: {exc}", file=sys.stderr)
return 1
declared = rollback_declared(comparison, args.head)
failures = 0
for path, url in modules:
try:
base_sha = gitlink(comparison, path)
new_sha = gitlink(args.head, path)
except RuntimeError as exc:
print(f"submodule-forward-only: {path}: cannot read gitlink: {exc}", file=sys.stderr)
failures += 1
continue
if base_sha is None and new_sha is not None:
print(f"PASS {path}: submodule added at {new_sha}")
continue
if new_sha is None:
print(f"FAIL {path}: gitlink is absent at {args.head} but present at merge base {base_sha}", file=sys.stderr)
failures += 1
continue
if base_sha == new_sha:
print(f"PASS {path}: unchanged at {new_sha}")
continue
relation = local_relation(path, base_sha, new_sha) or github_relation(url, new_sha, base_sha)
if relation == "forward":
print(f"PASS {path}: {base_sha} -> {new_sha} (forward)")
continue
if relation == "unchanged":
print(f"PASS {path}: unchanged at {new_sha}")
continue
if relation in {"backward", "diverged"} and path in declared:
print(f"PASS {path}: {base_sha} -> {new_sha} ({relation}; {MARKER_PREFIX}{path} declared)")
continue
if relation is None:
print(f"FAIL {path}: could not determine ancestry for {base_sha} -> {new_sha}; local git and GitHub compare both failed. Add '{MARKER_PREFIX}{path}' to a branch commit only for a deliberate rollback.", file=sys.stderr)
failures += 1
continue
subjects = dropped(path, new_sha, base_sha)
count = len(subjects)
detail = "; ".join(subjects[:8]) if subjects else "subjects unavailable"
if count > 8:
detail += f"; ... ({count - 8} more)"
print(f"FAIL {path}: {base_sha} -> {new_sha} ({relation}); drops {count} commit(s): {detail}. Usual cause: branch cut before a submodule bump followed by a squash merge. Remedy: merge main into the branch. For a deliberate rollback, add a commit containing '{MARKER_PREFIX}{path}'.", file=sys.stderr)
failures += 1
if failures:
return 1
print("submodule-forward-only: all submodule gitlinks are unchanged or forward")
return 0


if __name__ == "__main__":
raise SystemExit(main())
4 changes: 4 additions & 0 deletions tests/test-execution.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1493,3 +1493,7 @@ lane = "linux-guard"
[[test]]
path = "tests/test_iroh_drift_issue.py"
lane = "linux-guard"

[[test]]
path = "tests/test_submodule_forward_only.py"
lane = "linux-guard"
7 changes: 6 additions & 1 deletion tests/test_ci_change_areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
BROWSER_WORKFLOW = ROOT / ".github" / "workflows" / "cmux-browser.yml"
REMOTE_DAEMON_WORKFLOW = ROOT / ".github" / "workflows" / "remote-daemon.yml"
GUARD_JOBS = (
"workflow-guard-submodule-forward-only",
"workflow-guard-tests",
"workflow-guard-history",
"workflow-guard-cli-scripts",
Expand All @@ -42,6 +43,7 @@
"linux_guard_cli": "workflow-guard-cli-scripts",
"linux_guard_source": "workflow-guard-source-lints",
}
GUARD_ALWAYS_JOBS = ("workflow-guard-submodule-forward-only",)
WEB_JOBS = (
"web-subarea-scope",
"web-typecheck",
Expand Down Expand Up @@ -2268,7 +2270,10 @@ def run_guard_status(
results: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
route_inputs = dict.fromkeys(GUARD_ROUTE_JOBS, "true") if inputs is None else dict(inputs)
job_results = dict.fromkeys(GUARD_ROUTE_JOBS.values(), "success")
job_results = {
**dict.fromkeys(GUARD_ROUTE_JOBS.values(), "success"),
**dict.fromkeys(GUARD_ALWAYS_JOBS, "success"),
}
if results:
job_results.update(results)
script = workflow_job_step_script(
Expand Down
18 changes: 17 additions & 1 deletion tests/test_ci_linux_guard_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,16 @@
GROUPS, GUARD_WORKFLOW, direct_path_owners, groups_for_path, guard_steps, step_owners,
)
JOBS = {
"submodule_forward_only": "workflow-guard-submodule-forward-only",
"linux_guard_tests": "workflow-guard-tests",
"linux_guard_history": "workflow-guard-history",
"linux_guard_cli": "workflow-guard-cli-scripts",
"linux_guard_source": "workflow-guard-source-lints",
"ghosttykit_release": "ghosttykit-release-check",
}
REUSABLE_GUARDS = {
route: job for route, job in JOBS.items() if route != "ghosttykit_release"
route: job for route, job in JOBS.items()
if route not in {"ghosttykit_release", "submodule_forward_only"}
}


Expand All @@ -57,6 +59,9 @@ def route_decision(paths, event="pull_request", macos="false"):
)
outputs = dict(line.split("=", 1) for line in result.stdout.splitlines())
groups = tuple(json.loads(outputs.pop("linux_guard_test_groups")))
# The submodule guard is an unconditional reusable-workflow job rather
# than a routed input. Include its synthetic result in this gate payload.
outputs["submodule_forward_only"] = "true" if outputs and all(value == "true" for value in outputs.values()) else "false"
return outputs, groups


Expand Down Expand Up @@ -177,6 +182,7 @@ def test_testflight_change_routes_only_observing_test_groups(self):
"linux_guard_tests": "true", "linux_guard_history": "false",
"linux_guard_cli": "false", "linux_guard_source": "false",
"ghosttykit_release": "false",
"submodule_forward_only": "false",
})
self.assertEqual(
groups,
Expand Down Expand Up @@ -364,6 +370,7 @@ def test_reusable_workflow_policy_edits_skip_unrelated_guard_jobs(self):
"linux_guard_cli": "false",
"linux_guard_source": "false",
"ghosttykit_release": "true",
"submodule_forward_only": "false",
})
self.assertEqual(groups, GROUPS)

Expand All @@ -381,6 +388,7 @@ def test_reusable_workflow_policy_edits_skip_unrelated_guard_jobs(self):
"linux_guard_cli": "false",
"linux_guard_source": "false",
"ghosttykit_release": "false",
"submodule_forward_only": "false",
})
self.assertEqual(groups, ("preflight", "ci", "quality-determinism"))

Expand All @@ -397,6 +405,7 @@ def test_native_edit_keeps_source_contracts_without_history_or_cli_guards(self):
"linux_guard_tests": "true", "linux_guard_history": "false",
"linux_guard_cli": "false", "linux_guard_source": "true",
"ghosttykit_release": "true",
"submodule_forward_only": "false",
})

def test_cloud_skill_and_its_known_test_keep_only_the_owning_guard(self):
Expand Down Expand Up @@ -436,6 +445,7 @@ def test_cloud_skill_and_its_known_test_keep_only_the_owning_guard(self):
"linux_guard_tests": "true", "linux_guard_history": "false",
"linux_guard_cli": "false", "linux_guard_source": "true",
"ghosttykit_release": "true",
"submodule_forward_only": "false",
})

def test_owned_mac_control_plane_runs_only_its_own_guard_lane(self):
Expand Down Expand Up @@ -526,6 +536,12 @@ def test_missing_empty_or_uncertain_diff_and_non_pr_events_run_all(self):
self.assertEqual(route(["README.md"], macos=""), dict.fromkeys(JOBS, "true"))

def test_gate_rejects_selected_guard_skip_failure_or_cancellation(self):
for outcome in ("skipped", "failure", "cancelled"):
with self.subTest(job=JOBS["submodule_forward_only"], outcome=outcome):
result = run_guard_status(results={JOBS["submodule_forward_only"]: outcome})
self.assertNotEqual(result.returncode, 0)
self.assertIn(f"{JOBS['submodule_forward_only']}: {outcome}", result.stderr)

for route_name, job in REUSABLE_GUARDS.items():
for outcome in ("skipped", "failure", "cancelled"):
with self.subTest(job=job, outcome=outcome):
Expand Down
Loading
Loading