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
55 changes: 47 additions & 8 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
153 changes: 153 additions & 0 deletions scripts/classify_build_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""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/",
# build_frozen_engine.py freezes engine/__main__.py and its local imports
# into the sidecar that every native installer embeds.
"engine/",
"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",
}

# 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/",
"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.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())
76 changes: 76 additions & 0 deletions tests/test_build_change_classifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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",
[
["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/__main__.py",
"engine/dispatch.py",
"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"])