From ec00775da592e0b87f5f7eee13e7794bce56c275 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 01:58:44 -0400 Subject: [PATCH 1/2] ci: skip duplicate desktop artifact matrices --- .github/workflows/build.yml | 55 +++++++-- scripts/classify_build_changes.py | 157 ++++++++++++++++++++++++++ tests/test_build_change_classifier.py | 74 ++++++++++++ 3 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 scripts/classify_build_changes.py create mode 100644 tests/test_build_change_classifier.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b3654f..2b4bd77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,8 +15,42 @@ concurrency: cancel-in-progress: true jobs: + artifact-scope: + name: Select artifact scope + runs-on: ubuntu-22.04 + outputs: + run_sidecar: ${{ steps.scope.outputs.run_sidecar }} + run_native: ${{ steps.scope.outputs.run_native }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + + - name: Classify changed files before expensive builds + id: scope + env: + BEFORE_SHA: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} + shell: bash + run: | + set -euo pipefail + changed_paths="${RUNNER_TEMP}/openadapt-desktop-changed-paths.txt" + : > "${changed_paths}" + if [ "${EVENT_NAME}" = "push" ]; then + before="${BEFORE_SHA}" + if [ -z "${before}" ] || [ "${before}" = "0000000000000000000000000000000000000000" ]; then + before="$(git rev-parse HEAD^)" + fi + git diff --name-only "${before}" HEAD > "${changed_paths}" + fi + python scripts/classify_build_changes.py \ + --event "${EVENT_NAME}" \ + --paths-file "${changed_paths}" \ + --github-output "${GITHUB_OUTPUT}" + frontend: name: Frontend behavior and build + needs: artifact-scope runs-on: ubuntu-22.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -34,6 +68,7 @@ jobs: python-distribution: name: Python distribution + needs: artifact-scope runs-on: ubuntu-22.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -62,12 +97,14 @@ jobs: python-sidecar: name: Python sidecar (${{ matrix.os }}) + needs: artifact-scope + if: needs.artifact-scope.outputs.run_sidecar == 'true' strategy: fail-fast: false matrix: - # PRs prove the frozen-engine contract once on Linux. The exact main - # commit rebuilds it on every supported OS before release, avoiding - # two expensive duplicate platform builds on every review push. + # PRs prove the frozen-engine contract once on Linux. Main repeats the + # complete matrix for dependency, toolchain, packaging, and native + # changes. Immutable native release tags rebuild every supported OS. os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-22.04"]' || '["ubuntu-22.04", "macos-14", "windows-latest"]') }} runs-on: ${{ matrix.os }} steps: @@ -103,11 +140,13 @@ jobs: native-installers: name: Native installer (${{ matrix.label }}) - # Installer packaging, installation, launch, uninstall, and checksums are - # exact-main/release evidence. PRs already exercise the frontend, Python - # distribution, and representative frozen sidecar without paying for four - # complete installer builds on each incremental push. - if: github.event_name != 'pull_request' + needs: artifact-scope + # Main runs this evidence for dependency, toolchain, packaging, and native + # changes. Application-only merges already pass the PR checks and the + # exact-main frontend, distribution, and cross-platform test jobs. Every + # immutable desktop-v* release tag rebuilds and tests all installers in the + # separate Native Installer Release workflow. + if: needs.artifact-scope.outputs.run_native == 'true' strategy: fail-fast: false matrix: diff --git a/scripts/classify_build_changes.py b/scripts/classify_build_changes.py new file mode 100644 index 0000000..11ed44a --- /dev/null +++ b/scripts/classify_build_changes.py @@ -0,0 +1,157 @@ +"""Classify whether a Desktop change needs full native artifact builds. + +Pull requests keep the representative Linux frozen-sidecar build. Release +candidates and explicit dispatches keep the complete platform matrix. A push +to protected ``main`` repeats the expensive matrix only when a dependency, +toolchain, packaging, or native-runtime input changed. The immutable native +release workflow always rebuilds every installer from its release tag. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +FULL_ARTIFACT_FILES = { + ".github/workflows/build.yml", + ".github/workflows/native-freshness.yml", + ".github/workflows/native-release.yml", + ".github/workflows/release.yml", + ".python-version", + "LICENSE", + "package-lock.json", + "package.json", + "pyproject.toml", + "rust-toolchain.toml", + "uv.lock", +} + +FULL_ARTIFACT_PREFIXES = ( + ".github/actions/", + "src-tauri/", +) + +FULL_ARTIFACT_SCRIPTS = { + "scripts/build_frozen_engine.py", + "scripts/check_release_consistency.py", + "scripts/classify_build_changes.py", + "scripts/frozen_notices.py", + "scripts/native_release.py", + "scripts/native_signing.py", + "scripts/package_ffmpeg_runtime.py", + "scripts/smoke_test_frozen_flow.py", + "scripts/smoke_test_native_installer.py", + "scripts/verify_build_artifact.py", +} + +FULL_ARTIFACT_ENGINE_FILES = { + "engine/vision-runtime-manifest.json", +} + +# These paths get the normal PR checks and the exact-main frontend, Python +# distribution, and cross-platform test jobs. They do not change the native +# packaging contract. Everything not named here fails toward the full matrix. +CHEAP_MAIN_PREFIXES = ( + "docs/", + "engine/", + "src/", + "tests/", +) + +CHEAP_MAIN_FILES = { + ".gitignore", + "CHANGELOG.md", + "CONTRIBUTING.md", + "README.md", + "SECURITY.md", +} + + +@dataclass(frozen=True) +class BuildScope: + run_sidecar: bool + run_native: bool + reason: str + + +def _normalize(path: str) -> str: + value = path.strip().replace("\\", "/") + if not value: + return "" + normalized = PurePosixPath(value).as_posix() + if normalized.startswith("../") or normalized == ".." or normalized.startswith("/"): + raise ValueError(f"changed path must be repository-relative: {path!r}") + return normalized.removeprefix("./") + + +def _requires_full_artifacts(path: str) -> bool: + if path in FULL_ARTIFACT_FILES: + return True + if path in FULL_ARTIFACT_SCRIPTS: + return True + if path in FULL_ARTIFACT_ENGINE_FILES: + return True + if path.startswith(FULL_ARTIFACT_PREFIXES): + return True + if path.startswith("THIRD_PARTY_NOTICES"): + return True + if path in CHEAP_MAIN_FILES or path.startswith(CHEAP_MAIN_PREFIXES): + return False + return True + + +def classify_build_scope(event: str, changed_paths: Iterable[str]) -> BuildScope: + """Return the artifact scope for one GitHub event.""" + + if event == "workflow_dispatch": + return BuildScope(True, True, "explicit release-candidate build") + if event == "pull_request": + return BuildScope(True, False, "representative pull-request sidecar") + if event != "push": + return BuildScope(True, True, f"unknown event {event!r}; fail toward full evidence") + + paths = tuple(path for raw in changed_paths if (path := _normalize(raw))) + if not paths: + return BuildScope(True, True, "no changed paths; fail toward full evidence") + + full_paths = tuple(path for path in paths if _requires_full_artifacts(path)) + if full_paths: + sample = ", ".join(full_paths[:3]) + if len(full_paths) > 3: + sample += f", and {len(full_paths) - 3} more" + return BuildScope(True, True, f"artifact-sensitive change: {sample}") + + return BuildScope(False, False, "application-only change; release tag rebuilds all artifacts") + + +def _write_github_output(path: Path, scope: BuildScope) -> None: + reason = scope.reason.replace("\n", " ").replace("\r", " ") + with path.open("a", encoding="utf-8") as output: + output.write(f"run_sidecar={str(scope.run_sidecar).lower()}\n") + output.write(f"run_native={str(scope.run_native).lower()}\n") + output.write(f"reason={reason}\n") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--event", required=True) + parser.add_argument("--paths-file", type=Path) + parser.add_argument("--github-output", type=Path) + args = parser.parse_args() + + paths: list[str] = [] + if args.paths_file: + paths = args.paths_file.read_text(encoding="utf-8").splitlines() + scope = classify_build_scope(args.event, paths) + if args.github_output: + _write_github_output(args.github_output, scope) + print(f"sidecar={str(scope.run_sidecar).lower()}") + print(f"native={str(scope.run_native).lower()}") + print(f"reason={scope.reason}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_build_change_classifier.py b/tests/test_build_change_classifier.py new file mode 100644 index 0000000..6ed2508 --- /dev/null +++ b/tests/test_build_change_classifier.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import pytest + +from scripts.classify_build_changes import BuildScope, classify_build_scope + + +def test_pull_request_keeps_one_representative_sidecar() -> None: + assert classify_build_scope("pull_request", ["src/App.tsx"]) == BuildScope( + run_sidecar=True, + run_native=False, + reason="representative pull-request sidecar", + ) + + +def test_explicit_release_candidate_keeps_the_complete_matrix() -> None: + assert classify_build_scope("workflow_dispatch", []) == BuildScope( + run_sidecar=True, + run_native=True, + reason="explicit release-candidate build", + ) + + +@pytest.mark.parametrize( + "paths", + [ + ["engine/dispatch.py", "src/screens/RecordReview.tsx"], + ["README.md", "docs/qualification.md"], + ["tests/test_engine/test_dispatch.py"], + ], +) +def test_protected_main_uses_cheap_jobs_for_application_only_changes(paths: list[str]) -> None: + scope = classify_build_scope("push", paths) + assert scope.run_sidecar is False + assert scope.run_native is False + + +@pytest.mark.parametrize( + "path", + [ + "pyproject.toml", + "uv.lock", + "package-lock.json", + "src-tauri/src/main.rs", + "src-tauri/Cargo.lock", + "scripts/build_frozen_engine.py", + "scripts/verify_build_artifact.py", + ".github/workflows/build.yml", + "engine/vision-runtime-manifest.json", + "THIRD_PARTY_NOTICES.md", + ], +) +def test_artifact_inputs_keep_the_complete_main_matrix(path: str) -> None: + scope = classify_build_scope("push", [path]) + assert scope.run_sidecar is True + assert scope.run_native is True + assert path in scope.reason + + +def test_unknown_path_fails_toward_the_complete_matrix() -> None: + scope = classify_build_scope("push", ["new-build-input.conf"]) + assert scope.run_sidecar is True + assert scope.run_native is True + + +def test_empty_push_fails_toward_the_complete_matrix() -> None: + scope = classify_build_scope("push", []) + assert scope.run_sidecar is True + assert scope.run_native is True + + +def test_absolute_or_parent_path_is_refused() -> None: + with pytest.raises(ValueError, match="repository-relative"): + classify_build_scope("push", ["../outside"]) From 088eaff99b3e352a7415e83766ee20387ef3794e Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 04:07:27 -0400 Subject: [PATCH 2/2] ci: rebuild artifacts for engine changes --- scripts/classify_build_changes.py | 10 +++------- tests/test_build_change_classifier.py | 4 +++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/classify_build_changes.py b/scripts/classify_build_changes.py index 11ed44a..11e4de7 100644 --- a/scripts/classify_build_changes.py +++ b/scripts/classify_build_changes.py @@ -30,6 +30,9 @@ FULL_ARTIFACT_PREFIXES = ( ".github/actions/", + # build_frozen_engine.py freezes engine/__main__.py and its local imports + # into the sidecar that every native installer embeds. + "engine/", "src-tauri/", ) @@ -46,16 +49,11 @@ "scripts/verify_build_artifact.py", } -FULL_ARTIFACT_ENGINE_FILES = { - "engine/vision-runtime-manifest.json", -} - # These paths get the normal PR checks and the exact-main frontend, Python # distribution, and cross-platform test jobs. They do not change the native # packaging contract. Everything not named here fails toward the full matrix. CHEAP_MAIN_PREFIXES = ( "docs/", - "engine/", "src/", "tests/", ) @@ -91,8 +89,6 @@ def _requires_full_artifacts(path: str) -> bool: return True if path in FULL_ARTIFACT_SCRIPTS: return True - if path in FULL_ARTIFACT_ENGINE_FILES: - return True if path.startswith(FULL_ARTIFACT_PREFIXES): return True if path.startswith("THIRD_PARTY_NOTICES"): diff --git a/tests/test_build_change_classifier.py b/tests/test_build_change_classifier.py index 6ed2508..5b9185a 100644 --- a/tests/test_build_change_classifier.py +++ b/tests/test_build_change_classifier.py @@ -24,7 +24,7 @@ def test_explicit_release_candidate_keeps_the_complete_matrix() -> None: @pytest.mark.parametrize( "paths", [ - ["engine/dispatch.py", "src/screens/RecordReview.tsx"], + ["src/screens/RecordReview.tsx"], ["README.md", "docs/qualification.md"], ["tests/test_engine/test_dispatch.py"], ], @@ -46,6 +46,8 @@ def test_protected_main_uses_cheap_jobs_for_application_only_changes(paths: list "scripts/build_frozen_engine.py", "scripts/verify_build_artifact.py", ".github/workflows/build.yml", + "engine/__main__.py", + "engine/dispatch.py", "engine/vision-runtime-manifest.json", "THIRD_PARTY_NOTICES.md", ],