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
12 changes: 4 additions & 8 deletions .github/workflows/LinuxInstall.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
name: Linux_AppImage

on:
# Packaging runs on every pull request as well, so a change that breaks the MSI or
# the AppImage fails here instead of surviving until someone dispatches a run by
# hand. Narrow this with a `paths:` filter if the runner cost becomes a problem.
pull_request:

# Keep manual packaging qualification available for exact source SHAs.
# Dispatch-only. PRs targeting `dev` run ci.yml; MSI/AppImage qualification
# stays on the release-candidate path with an exact source SHA.
workflow_dispatch:
inputs:
source_sha:
Expand Down Expand Up @@ -40,13 +36,13 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
path: loop
ref: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
ref: ${{ inputs.source_sha }}
fetch-depth: 0

- name: Verify exact source SHA
working-directory: loop
env:
EXPECTED_SOURCE_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }}
run: |
if ! [[ "$EXPECTED_SOURCE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "::error::source_sha must be a full 40-character Git SHA"
Expand Down
21 changes: 12 additions & 9 deletions .github/workflows/WindowsInstall.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
name: Windows_MSI

on:
# Packaging runs on every pull request as well, so a change that breaks the MSI or
# the AppImage fails here instead of surviving until someone dispatches a run by
# hand. Narrow this with a `paths:` filter if the runner cost becomes a problem.
pull_request:

# Keep manual packaging qualification available for exact source SHAs.
# Dispatch-only. PRs targeting `dev` run ci.yml; MSI/AppImage qualification
# stays on the release-candidate path with an exact source SHA.
workflow_dispatch:
inputs:
source_sha:
Expand All @@ -21,7 +17,9 @@ permissions:

jobs:
build_windows:
runs-on: windows-2022
# Blacksmith is reserved for this ~50-minute packaging gate; other Windows CI
# stays on GitHub-hosted windows-2022 to conserve Blacksmith minutes.
runs-on: blacksmith-4vcpu-windows-2025
env:
VCPKG_INSTALLED_DIR: ${{ github.workspace }}\vcpkg_installed
VCPKG_ROOT: ${{ github.workspace }}\vcpkg
Expand All @@ -35,14 +33,19 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
path: loop
ref: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
ref: ${{ inputs.source_sha }}
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12"

- name: Verify exact source SHA
working-directory: loop
shell: pwsh
env:
EXPECTED_SOURCE_SHA: ${{ inputs.source_sha || github.event.pull_request.head.sha }}
EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }}
run: |
if ($env:EXPECTED_SOURCE_SHA -notmatch "^[0-9a-fA-F]{40}$") {
throw "source_sha must be a full 40-character Git SHA"
Expand Down
6 changes: 6 additions & 0 deletions changes/blacksmith-migration-bcd7474.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Blacksmith CI migration

Category: internal
Audience: developers
Breaking-Change: no
Summary: Run Windows_MSI on Blacksmith to shorten the ~50-minute packaging gate while keeping every other workflow on GitHub-hosted runners, preserving workflow_dispatch-only exact-SHA MSI/AppImage qualification, and keeping full linux/windows CI on the stable release-candidate path instead of every push to `dev`.
6 changes: 5 additions & 1 deletion docs/BRANCH_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ the protected branches by the corresponding GitHub branch rules.
The Release Gate workflow listens for `pull_request` targeting `stable` and for
`merge_group` so an optional merge queue cannot wait on a check that never
runs. It has no path filters. Integration PRs targeting `dev` or `unstable` run
`ci.yml`. Merges into `unstable` must pass `agent-fast / build`.
`ci.yml`. Merges into `unstable` must pass `agent-fast / build`. Windows_MSI
and Linux_AppImage are `workflow_dispatch`-only exact-SHA qualification; they
do not run on `push` or `pull_request`.

The declarations below are intentionally machine-readable by
`scripts/ci/check_branch_policy.py`. That check runs in CI, so a workflow
Expand All @@ -39,6 +41,8 @@ branch protection when a token can read it.
- Release gate pull_request branches: `stable`
- Integration workflow: `.github/workflows/ci.yml`
- Integration pull_request branches: `dev`, `unstable`
- Packaging workflows: `.github/workflows/LinuxInstall.yml`, `.github/workflows/WindowsInstall.yml`
- Packaging events: `workflow_dispatch`

`master` is not part of the Loop branch policy. It is retained only in older
historical documents or upstream references; new workflow triggers must not
Expand Down
59 changes: 59 additions & 0 deletions scripts/ci/check_branch_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
DOCUMENTED_INTEGRATION_PR_BRANCHES = re.compile(
r"^[-*]\s+Integration pull_request branches:\s*(.+)$", re.MULTILINE
)
DOCUMENTED_PACKAGING_WORKFLOWS = re.compile(
r"^[-*]\s+Packaging workflows:\s*(.+)$", re.MULTILINE
)
DOCUMENTED_PACKAGING_EVENTS = re.compile(
r"^[-*]\s+Packaging events:\s*(.+)$", re.MULTILINE
)


@dataclass(frozen=True)
Expand All @@ -56,6 +62,8 @@ class DocumentedPolicy:
release_gate_pull_request_branches: tuple[str, ...]
integration_workflow: str
integration_pull_request_branches: tuple[str, ...]
packaging_workflows: tuple[str, ...]
packaging_events: tuple[str, ...]


def _branch_names(value: str) -> tuple[str, ...]:
Expand Down Expand Up @@ -115,6 +123,14 @@ def required(pattern: re.Pattern[str], label: str) -> str:
)
if not integration_pr:
raise ValueError("policy declares no integration pull_request branches")
packaging_workflows = _branch_names(
required(DOCUMENTED_PACKAGING_WORKFLOWS, "Packaging workflows:")
)
if not packaging_workflows:
raise ValueError("policy declares no packaging workflows")
packaging_events = _branch_names(required(DOCUMENTED_PACKAGING_EVENTS, "Packaging events:"))
if not packaging_events:
raise ValueError("policy declares no packaging events")
return DocumentedPolicy(
ci_branches=ci_branches,
protected_branches=protected,
Expand All @@ -127,6 +143,8 @@ def required(pattern: re.Pattern[str], label: str) -> str:
release_gate_pull_request_branches=release_pr,
integration_workflow=integration_workflow,
integration_pull_request_branches=integration_pr,
packaging_workflows=packaging_workflows,
packaging_events=packaging_events,
)


Expand Down Expand Up @@ -331,6 +349,45 @@ def validate_integration_workflow(path: Path, text: str, policy: DocumentedPolic
violations.append(
f"{path}: {job_name} job must run for workflow_dispatch"
)
elif "refs/heads/stable" not in block:
violations.append(
f"{path}: {job_name} job must stay on stable push or workflow_dispatch"
)
return violations


def validate_packaging_workflows(root: Path, policy: DocumentedPolicy) -> list[str]:
"""Keep MSI/AppImage qualification on exact-SHA workflow_dispatch only."""
violations: list[str] = []
if policy.packaging_events != ("workflow_dispatch",):
violations.append(
"docs/BRANCH_POLICY.md: packaging events must be workflow_dispatch only, "
f"got {list(policy.packaging_events)}"
)
expected = {
".github/workflows/LinuxInstall.yml",
".github/workflows/WindowsInstall.yml",
}
if set(policy.packaging_workflows) != expected:
violations.append(
"docs/BRANCH_POLICY.md: packaging workflows must be "
f"{sorted(expected)}, got {list(policy.packaging_workflows)}"
)
for rel in policy.packaging_workflows:
path = root / rel
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
violations.append(f"{path}: {exc}")
continue
events = parse_on_events(text)
if "workflow_dispatch" not in events:
violations.append(f"{path}: packaging must listen for workflow_dispatch")
for event in events:
if event != "workflow_dispatch":
violations.append(f"{path}: packaging must not trigger on `{event}`")
if "github.event.pull_request" in text:
violations.append(f"{path}: packaging checkout must use inputs.source_sha only")
return violations


Expand Down Expand Up @@ -489,6 +546,8 @@ def validate_repository(
else:
violations.extend(validate_release_gate_workflow(release_gate, release_text, policy))

violations.extend(validate_packaging_workflows(root, policy))

codeql = root / ".github/workflows/codeql.yml"
try:
codeql_text = codeql.read_text(encoding="utf-8")
Expand Down
67 changes: 67 additions & 0 deletions scripts/ci/test_check_branch_policy.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
import tempfile
import unittest
from pathlib import Path

Expand All @@ -10,6 +11,7 @@
parse_workflow_branch_triggers,
validate_integration_workflow,
validate_live_protection,
validate_packaging_workflows,
validate_release_gate_workflow,
validate_repository,
validate_workflow_branches,
Expand Down Expand Up @@ -41,6 +43,14 @@ def test_documented_policy_declares_ci_branches_and_required_check(self):
self.assertEqual(policy.release_gate_pull_request_branches, ("stable",))
self.assertEqual(policy.integration_workflow, ".github/workflows/ci.yml")
self.assertEqual(policy.integration_pull_request_branches, ("dev", "unstable"))
self.assertEqual(
policy.packaging_workflows,
(
".github/workflows/LinuxInstall.yml",
".github/workflows/WindowsInstall.yml",
),
)
self.assertEqual(policy.packaging_events, ("workflow_dispatch",))

def test_current_ci_workflow_matches_policy(self):
policy = parse_documented_policy_full(
Expand Down Expand Up @@ -182,6 +192,63 @@ def test_rejects_manual_dispatch_without_full_platform_jobs(self):
self.assertTrue(any("linux job must run for workflow_dispatch" in item for item in violations))
self.assertTrue(any("windows job must run for workflow_dispatch" in item for item in violations))

def test_rejects_full_platform_jobs_on_every_push(self):
policy = parse_documented_policy_full(
(ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8")
)
stale = """on:
push:
branches: [dev, unstable, stable]
pull_request:
branches: [dev, unstable]
workflow_dispatch:

jobs:
agent-fast:
uses: ./.github/workflows/reusable-linux.yml
linux:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
uses: ./.github/workflows/reusable-linux.yml
windows:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
uses: ./.github/workflows/reusable-windows.yml
"""
violations = validate_integration_workflow(Path("ci.yml"), stale, policy)
self.assertTrue(
any("linux job must stay on stable push or workflow_dispatch" in item for item in violations)
)
self.assertTrue(
any("windows job must stay on stable push or workflow_dispatch" in item for item in violations)
)

def test_current_packaging_workflows_are_dispatch_only(self):
policy = parse_documented_policy_full(
(ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8")
)
self.assertEqual(validate_packaging_workflows(ROOT, policy), [])

def test_rejects_packaging_pull_request_trigger(self):
policy = parse_documented_policy_full(
(ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8")
)
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
linux = root / ".github/workflows/LinuxInstall.yml"
windows = root / ".github/workflows/WindowsInstall.yml"
linux.parent.mkdir(parents=True)
linux.write_text(
"on:\n pull_request:\n workflow_dispatch:\n inputs:\n source_sha:\n",
encoding="utf-8",
)
windows.write_text(
"on:\n workflow_dispatch:\n inputs:\n source_sha:\n",
encoding="utf-8",
)
violations = validate_packaging_workflows(root, policy)
self.assertTrue(
any("must not trigger on `pull_request`" in item for item in violations)
)

def test_live_protection_rejects_ci_ok_and_unbound_app(self):
policy = parse_documented_policy_full(
(ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8")
Expand Down
16 changes: 15 additions & 1 deletion scripts/ci/test_workflow_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def test_package_workflows_require_and_record_exact_source_sha(self):
# raises the oldest distro Loop runs on, so change it as a decision, not to make
# this assertion pass.
self.assertIn("runs-on: ubuntu-22.04", linux)
self.assertIn("runs-on: blacksmith-4vcpu-windows-2025", windows)
self.assertIn("VCPKG_DEFAULT_BINARY_CACHE", linux)
self.assertIn("VCPKG_BINARY_SOURCES=clear;files", linux)
self.assertIn("./vcpkg-binary-cache", linux)
Expand Down Expand Up @@ -111,7 +112,11 @@ def test_package_workflows_require_and_record_exact_source_sha(self):
self.assertIn("source_sha:", workflow)
self.assertRegex(workflow, r"source_sha:\n\s+description:.*\n\s+required:\s+true")
self.assertIn("inputs.source_sha", workflow)
self.assertIn("pull_request:", workflow)
self.assertIn("ref: ${{ inputs.source_sha }}", workflow)
self.assertIn("workflow_dispatch:", workflow)
self.assertNotIn("pull_request:", workflow)
self.assertNotIn("github.event.pull_request", workflow)
self.assertNotRegex(workflow, r"(?m)^ push:")
self.assertIn("Verify exact source SHA", workflow)
self.assertIn("LOOP_SOURCE_SHA", workflow)
self.assertIn("inspect_package_dependencies.py", workflow)
Expand All @@ -121,6 +126,15 @@ def test_package_workflows_require_and_record_exact_source_sha(self):
self.assertIn("loop-package-boundary-linux-evidence", linux)
self.assertIn("loop-package-boundary-windows-evidence", windows)

def test_blacksmith_is_reserved_for_windows_msi_only(self):
workflows_dir = ROOT / ".github/workflows"
blacksmith_workflows = []
for path in sorted(workflows_dir.glob("*.yml")):
text = path.read_text(encoding="utf-8")
if "blacksmith" in text:
blacksmith_workflows.append(path.name)
self.assertEqual(blacksmith_workflows, ["WindowsInstall.yml"])

def test_windows_release_msi_is_x64_and_uses_64_bit_program_files(self):
workflow = (ROOT / ".github/workflows/WindowsInstall.yml").read_text(encoding="utf-8")
self.assertIn('Platform=x64', workflow)
Expand Down
Loading