diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index 24030d38..9e0f90f5 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -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: @@ -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" diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index b8c5c2e6..48a2a5f0 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -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: @@ -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 @@ -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" diff --git a/changes/blacksmith-migration-bcd7474.md b/changes/blacksmith-migration-bcd7474.md new file mode 100644 index 00000000..9ff51189 --- /dev/null +++ b/changes/blacksmith-migration-bcd7474.md @@ -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`. diff --git a/docs/BRANCH_POLICY.md b/docs/BRANCH_POLICY.md index 969aa113..91b71ec8 100644 --- a/docs/BRANCH_POLICY.md +++ b/docs/BRANCH_POLICY.md @@ -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 @@ -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 diff --git a/scripts/ci/check_branch_policy.py b/scripts/ci/check_branch_policy.py index 9ace72d8..577d2c1f 100644 --- a/scripts/ci/check_branch_policy.py +++ b/scripts/ci/check_branch_policy.py @@ -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) @@ -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, ...]: @@ -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, @@ -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, ) @@ -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 @@ -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") diff --git a/scripts/ci/test_check_branch_policy.py b/scripts/ci/test_check_branch_policy.py index e531b4bc..1707881a 100644 --- a/scripts/ci/test_check_branch_policy.py +++ b/scripts/ci/test_check_branch_policy.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import tempfile import unittest from pathlib import Path @@ -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, @@ -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( @@ -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") diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index 4b6dbbf6..caca118b 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -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) @@ -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) @@ -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)