Files
plezy/scripts/check_build_workflow.py
T
edde746 0c58b2dc55 fix(ci): follow the guard roster and Flutter pin to their current homes
Two workflow guards had drifted from the code they describe, so
`scripts/ci_guard_checks.sh` failed on a clean tree.

The Flutter release-tag pin moved out of build.yml into the shared
setup-flutter-git composite action, but the checker read that action
from a fixed repository path while its test mutated a workflow fixture.
The mutation could not reach the checker, so the rejection test asserted
against an unmodified run. The checker now resolves the action beside
the workflow it is given, and the test materialises a `.github` tree so
the pin is genuinely exercised.

The script-test roster likewise moved into ci_guard_checks.sh, which
discovers `scripts/test_*.py` by glob; the dispatch guard still expected
each one to be named explicitly in ci_checks.sh and ci.yml. It now reads
that glob and checks both aggregates delegate to the shared roster.
2026-07-26 23:05:17 +02:00

360 lines
12 KiB
Python

#!/usr/bin/env python3
"""Guard the architecture matrices and release contract in build.yml."""
from pathlib import Path
import re
import sys
from workflow_yaml import iter_uses_references, job_block
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_WORKFLOW = ROOT / ".github/workflows/build.yml"
FLUTTER_VERSION = "3.44.0"
FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
if len(sys.argv) > 2:
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]")
WORKFLOW = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_WORKFLOW
# The shared bootstrap both windows-arm jobs call, and the pins it must keep.
# Resolved beside the workflow rather than from ROOT so that checking a fixture
# tree exercises this rule instead of silently re-reading the real action.
SETUP_FLUTTER_GIT = WORKFLOW.parents[1] / "actions/setup-flutter-git/action.yml"
text = WORKFLOW.read_text(encoding="utf-8")
errors: list[str] = []
def require(condition: bool, message: str) -> None:
if not condition:
errors.append(message)
def job(name: str) -> str:
block = job_block(text, name)
require(bool(block), f"missing {name} job")
return block
def named_step(block: str, name: str) -> str:
match = re.search(
rf"(?ms)^ - name: {re.escape(name)}\n.*?(?=^ - |\Z)",
block,
)
require(match is not None, f"missing '{name}' step")
return match.group(0) if match else ""
def validate_windows_signing(block: str) -> None:
install = named_step(block, "Install dependencies")
signing = named_step(block, "Sign installer for WinSparkle (EdDSA)")
require(
block.find(" - name: Install dependencies")
< block.find(" - name: Sign installer for WinSparkle (EdDSA)"),
"locked root dependencies must be installed before Windows signing",
)
require(
"flutter pub get --enforce-lockfile --no-example" in install,
"Windows signing must use the enforced root dependency lock",
)
require(
"dart run auto_updater:sign_update plezy-windows-installer.exe $keyPath"
in signing,
"Windows signing must execute the locked auto_updater package",
)
require(
"$env:RUNNER_TEMP" in signing,
"Windows signing key must live under RUNNER_TEMP",
)
require(
"try {" in signing and "} finally {" in signing,
"Windows signing key cleanup must run from a finally block",
)
require(
"Remove-Item -Path $keyPath -Force -ErrorAction SilentlyContinue"
in signing,
"Windows signing must remove its temporary key",
)
lowered = signing.lower()
for forbidden in (
"raw.githubusercontent.com",
"invoke-webrequest",
"git clone",
"_signer",
"pubspec.yaml",
"dart pub get",
):
require(
forbidden not in lowered,
f"Windows signing step contains mutable or ad-hoc input: {forbidden}",
)
def require_explicit_shells(name: str, block: str, shell: str) -> None:
steps = re.findall(r"(?ms)^ - .*?(?=^ - |\Z)", block)
run_steps = [step for step in steps if re.search(r"(?m)^ run:", step)]
require(bool(run_steps), f"{name} must contain run steps")
for step in run_steps:
step_name = re.search(r"(?m)^ - name: (.+)$", step)
label = step_name.group(1) if step_name else "unnamed step"
require(
f" shell: {shell}\n" in step,
f"{name} step '{label}' must explicitly use {shell}",
)
for legacy_job in (
"build-windows-x64",
"build-windows-arm64",
"build-linux-x64",
"build-linux-arm64",
):
require(f" {legacy_job}:\n" not in text, f"legacy job {legacy_job} must stay removed")
windows = job("build-windows")
require("runs-on: ${{ matrix.runner }}" in windows, "Windows must use its matrix runner")
require("fail-fast: false" in windows, "Windows matrix must not cancel its other architecture")
require(
re.search(
r"(?ms) - arch: x64\n"
r" runner: windows-latest\n"
r" flutter_setup: action\n"
r" native_cache_path: build/windows/x64/_deps\n",
windows,
)
is not None,
"Windows x64 matrix configuration changed",
)
require(
re.search(
r"(?ms) - arch: arm64\n"
r" runner: windows-11-arm\n"
r" flutter_setup: git\n"
r" native_cache_path: \|\n"
r" build/windows/arm64/_deps\n"
r" build/windows/arm64/mpv-dev-arm64\n",
windows,
)
is not None,
"Windows arm64 matrix configuration changed",
)
for expected in (
"if: matrix.flutter_setup == 'action'",
"if: matrix.flutter_setup == 'git'",
"uses: ./.github/actions/setup-flutter-git",
"flutter pub get --enforce-lockfile --no-example",
"--dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }}",
"--split-debug-info=debug-info/windows-${{ matrix.arch }}",
"name: windows-${{ matrix.arch }}-build",
"path: build/windows/${{ matrix.arch }}/runner/Release/",
):
require(expected in windows, f"Windows matrix missing: {expected}")
require(
"if: matrix.arch == 'arm64' && steps.windows-native-cache.outputs.cache-hit != 'true'"
in windows,
"7-Zip installation must remain ARM-only and cache-aware",
)
require(
re.search(
r"(?ms)^ permissions:\n contents: read\n strategy:", windows
)
is not None,
"Windows build permissions must remain contents: read",
)
require_explicit_shells("build-windows", windows, "pwsh")
setup_flutter_git = (
SETUP_FLUTTER_GIT.read_text(encoding="utf-8") if SETUP_FLUTTER_GIT.is_file() else ""
)
require(bool(setup_flutter_git), "missing .github/actions/setup-flutter-git/action.yml")
for expected in (
f'$version = "{FLUTTER_VERSION}"',
f'$expectedCommit = "{FLUTTER_COMMIT}"',
# Fetch the release tag rather than the bare commit: the commit is only
# reachable through the tag, and the tag is what makes the SDK report its
# own version. Both halves are then verified, so a moved tag fails the job.
'git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"',
'git -C $root checkout --detach "refs/tags/$version"',
"$actualCommit = git -C $root rev-parse HEAD",
"$actualCommit -ne $expectedCommit",
r'$versionOutput = & "$root\bin\flutter.bat" --version --machine',
"$reportedVersion -ne $version",
):
require(
expected in setup_flutter_git,
f"shared Flutter bootstrap must keep its verified pin: {expected}",
)
linux = job("build-linux")
require("runs-on: ${{ matrix.runner }}" in linux, "Linux must use its matrix runner")
require("fail-fast: false" in linux, "Linux matrix must not cancel its other architecture")
require(
re.search(
r"(?ms) - arch: x64\n"
r" runner: ubuntu-latest\n"
r" flutter_channel: stable\n"
r" pkg_config_arch: x86_64-linux-gnu\n",
linux,
)
is not None,
"Linux x64 matrix configuration changed",
)
require(
re.search(
r"(?ms) - arch: arm64\n"
r" runner: ubuntu-24.04-arm\n"
r" flutter_channel: master\n"
r" pkg_config_arch: aarch64-linux-gnu\n",
linux,
)
is not None,
"Linux arm64 matrix configuration changed",
)
for expected in (
"channel: ${{ matrix.flutter_channel }}",
"flutter-version: ${{ env.FLUTTER_VERSION }}",
"flutter pub get --enforce-lockfile --no-example",
"lib/${{ matrix.pkg_config_arch }}/pkgconfig",
"--dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }}",
"--split-debug-info=debug-info/linux-${{ matrix.arch }}",
"BUILD_DIR=\"$BUNDLE_DIR\"",
"ARCH_SUFFIX=${{ matrix.arch }}",
"name: linux-${{ matrix.arch }}",
):
require(expected in linux, f"Linux matrix missing: {expected}")
require(
re.search(
r"(?ms)^ permissions:\n"
r" id-token: write\n"
r" attestations: write\n"
r" contents: read\n"
r" strategy:",
linux,
)
is not None,
"Linux build attestation permissions changed",
)
require_explicit_shells("build-linux", linux, "bash")
libmpv_cache = named_step(linux, "Cache libmpv build")
require(
"hashFiles('linux/packaging/build-libmpv.sh', 'linux/packaging/native-inputs.json')"
in libmpv_cache,
"libmpv cache identity must include its build script and native input manifest",
)
package_windows = job("package-windows")
validate_windows_signing(package_windows)
require("needs: build-windows" in package_windows, "Windows packaging must fan in the matrix")
for artifact in (
"windows-x64-build",
"windows-arm64-build",
"windows-x64-portable",
"windows-arm64-portable",
"windows-installer",
):
require(f"name: {artifact}" in package_windows, f"Windows packaging lost {artifact}")
release = job("create-release")
require(
"needs: [validate-trusted-ref, build-android, build-ios, build-macos, build-windows, package-windows, build-linux]"
in release,
"release dependencies must include the trust gate, both architecture matrices, and Windows packaging",
)
for artifact in (
"android-apk",
"ios-ipa",
"macos-dmg",
"windows-x64-portable",
"windows-arm64-portable",
"windows-installer",
"linux-x64",
"linux-arm64",
):
require(f"name: {artifact}" in release, f"release download lost {artifact}")
release_if = re.search(r"(?m)^ if: (.+)$", release)
require(release_if is not None, "release job must have an explicit condition")
release_condition = release_if.group(1) if release_if else ""
for build_input in (
"build_android",
"build_ios",
"build_macos",
"build_windows",
"build_linux",
):
require(
f"&& inputs.{build_input}" in release_condition,
f"release publication must require {build_input}",
)
require("draft: true" in release, "build output must remain a draft release")
require("tag_name:" not in release, "build output must not bind a release tag")
require(
"generate_release_notes:" not in release,
"untagged draft releases must not request generated release notes",
)
require(
"Refuse to overwrite a published release" not in release,
"untagged draft creation must not inspect or block on published releases",
)
trusted_ref = job("validate-trusted-ref")
require("permissions: {}" in trusted_ref, "trusted-ref validation must have no token permissions")
require(
'"$GITHUB_REF" != "refs/heads/main"' in trusted_ref,
"trusted-ref validation must reject non-main refs",
)
for protected_job in (
"build-android",
"build-ios",
"build-macos",
"build-windows",
"build-linux",
):
require(
"needs: validate-trusted-ref" in job(protected_job),
f"{protected_job} must depend on trusted-ref validation",
)
require(
text.count(FLUTTER_VERSION) == 1 and f'FLUTTER_VERSION: "{FLUTTER_VERSION}"' in text,
"the Flutter SDK version must be written once, as the workflow FLUTTER_VERSION env",
)
require(
"TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1" in text,
"build caches must use a dedicated trusted namespace",
)
require("restore-keys:" not in text, "privileged build caches must not use prefix fallback")
cache_keys = re.findall(r"(?m)^ key: (.+)$", text)
require(bool(cache_keys), "build workflow must define cache keys")
for cache_key in cache_keys:
require(
"TRUSTED_BUILD_CACHE_VERSION" in cache_key,
f"cache key is outside the trusted build namespace: {cache_key}",
)
require(
text.count("cache-key:") == text.count("cache: true"),
"every Flutter SDK cache must define its trusted cache key",
)
# check_workflow_action_pins.py owns the SHA-pin rule for every workflow, this
# one included; build.yml only adds the credential invariant on top, because it
# is workflow_dispatch-only and so escapes the pull-request rule in
# check_workflow_security.py.
remote_actions = [
reference.rpartition("@")[0]
for _, reference in iter_uses_references(text)
if not reference.startswith("./")
]
require(bool(remote_actions), "build workflow must use pinned actions")
require(
text.count("persist-credentials: false") == remote_actions.count("actions/checkout"),
"every build checkout must discard GitHub credentials",
)
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
sys.exit(1)
print("build workflow architecture matrix checks passed")