diff --git a/.gitea/scripts/detect-changes.py b/.gitea/scripts/detect-changes.py new file mode 100644 index 000000000..f436b03c3 --- /dev/null +++ b/.gitea/scripts/detect-changes.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Shared path-filter helper for Gitea Actions workflows. + +Computes changed files against the PR base SHA or push-before SHA and writes +boolean outputs to GITHUB_OUTPUT. If the diff base is missing or untrusted, the +helper fails open by setting every output in the selected profile to true. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from pathlib import Path + + +PROFILES: dict[str, dict[str, str]] = { + "ci": { + "platform": r"^workspace-server/", + "canvas": r"^canvas/", + "python": r"^workspace/", + "scripts": r"^tests/e2e/|^scripts/|^infra/scripts/", + }, + "handlers-postgres": { + "handlers": ( + r"^workspace-server/internal/handlers/" + r"|^workspace-server/internal/wsauth/" + r"|^workspace-server/migrations/" + r"|^\.gitea/workflows/handlers-postgres-integration\.yml$" + ), + }, + "e2e-api": { + "api": r"^workspace-server/|^tests/e2e/|^\.gitea/workflows/e2e-api\.yml$", + }, +} + + +def classify(profile: str, paths: list[str]) -> dict[str, bool]: + patterns = PROFILES[profile] + return { + name: any(re.search(pattern, path) for path in paths) + for name, pattern in patterns.items() + } + + +def all_true(profile: str) -> dict[str, bool]: + return {name: True for name in PROFILES[profile]} + + +def resolve_base(event_name: str, pr_base_sha: str, push_before: str) -> str: + if event_name == "pull_request" and pr_base_sha: + return pr_base_sha + return push_before + + +def is_zero_sha(value: str) -> bool: + return not value or bool(re.fullmatch(r"0+", value)) + + +def run_git(args: list[str], *, timeout: int = 30) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + ) + + +def base_exists(base: str) -> bool: + return run_git(["cat-file", "-e", base]).returncode == 0 + + +def fetch_base(base: str, base_ref: str) -> None: + # Gitea may reject fetching an arbitrary unadvertised SHA from a shallow + # PR checkout. Fetch the advertised base branch first, then fall back to + # the SHA for hosts that allow it. + if base_ref: + run_git(["fetch", "--depth=1", "origin", base_ref]) + if not base_exists(base): + run_git(["fetch", "--depth=1", "origin", base]) + + +def deepen_base_ref(base_ref: str) -> None: + if base_ref: + run_git(["fetch", "--deepen=200", "origin", base_ref], timeout=60) + + +def merge_base(base: str) -> str | None: + proc = run_git(["merge-base", base, "HEAD"]) + if proc.returncode != 0: + return None + value = proc.stdout.strip() + return value or None + + +def changed_paths(base: str, *, use_merge_base: bool) -> list[str] | None: + compare_base = base + if use_merge_base: + compare_base = merge_base(base) or "" + if not compare_base: + return None + + proc = run_git(["diff", "--name-only", compare_base, "HEAD"]) + if proc.returncode != 0: + return None + return [line for line in proc.stdout.splitlines() if line] + + +def write_outputs(values: dict[str, bool], output_path: str | None) -> None: + lines = [f"{name}={'true' if value else 'false'}" for name, value in values.items()] + if output_path: + with Path(output_path).open("a", encoding="utf-8") as fh: + for line in lines: + fh.write(line + "\n") + else: + for line in lines: + print(line) + + +def detect( + profile: str, + event_name: str, + pr_base_sha: str, + push_before: str, + base_ref: str = "", +) -> dict[str, bool]: + base = resolve_base(event_name, pr_base_sha, push_before) + if is_zero_sha(base): + return all_true(profile) + + if not base_exists(base): + fetch_base(base, base_ref) + if not base_exists(base): + return all_true(profile) + + use_merge_base = event_name == "pull_request" + if use_merge_base and base_ref and merge_base(base) is None: + deepen_base_ref(base_ref) + + paths = changed_paths(base, use_merge_base=use_merge_base) + if paths is None: + return all_true(profile) + return classify(profile, paths) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", required=True, choices=sorted(PROFILES)) + parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", "")) + parser.add_argument("--pr-base-sha", default="") + parser.add_argument("--base-ref", default="") + parser.add_argument("--push-before", default=os.environ.get("GITHUB_EVENT_BEFORE", "")) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + values = detect( + args.profile, + args.event_name, + args.pr_base_sha, + args.push_before, + args.base_ref, + ) + write_outputs(values, os.environ.get("GITHUB_OUTPUT")) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 669cba5d0..fb9bf2a76 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -86,46 +86,17 @@ jobs: with: fetch-depth: 0 - id: check + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PUSH_BEFORE: ${{ github.event.before }} run: | - # For PR events: diff against the base branch (not HEAD~1 of the branch, - # which may be unrelated after force-pushes). When a push updates a PR, - # both pull_request and push events fire — prefer the PR base so that - # the diff is always computed against the actual merge base, not the - # previous SHA on the branch which may be on a different history line. - BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}" - # GITHUB_BASE_REF is set for PR events (the base branch name). - # For pull_request events we use the stored base.sha; for push events - # (or when base.sha is unavailable) fall back to github.event.before. - if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then - BASE="${{ github.event.pull_request.base.sha }}" - fi - # Fallback: if BASE is empty or all zeros (new branch), run everything - if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then - echo "platform=true" >> "$GITHUB_OUTPUT" - echo "canvas=true" >> "$GITHUB_OUTPUT" - echo "python=true" >> "$GITHUB_OUTPUT" - echo "scripts=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - # Workflow-only edits are covered by the workflow lint family - # and by this workflow's always-present required jobs. Do not fan - # those edits out into Go/Canvas/Python/shellcheck work; the - # downstream jobs still emit their required contexts via no-op - # steps when their surface flag is false. - # - # If the diff itself cannot be trusted, fail open by running every - # surface instead of silently under-testing the PR. - if ! DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null); then - echo "platform=true" >> "$GITHUB_OUTPUT" - echo "canvas=true" >> "$GITHUB_OUTPUT" - echo "python=true" >> "$GITHUB_OUTPUT" - echo "scripts=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/' && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/' && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "python=$(echo "$DIFF" | grep -qE '^workspace/' && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/' && echo true || echo false)" >> "$GITHUB_OUTPUT" + python3 .gitea/scripts/detect-changes.py \ + --profile ci \ + --event-name "${{ github.event_name }}" \ + --pr-base-sha "$PR_BASE_SHA" \ + --base-ref "$PR_BASE_REF" \ + --push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}" # Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run # + per-step gating shape preserves the GitHub-side required-check name @@ -133,6 +104,7 @@ jobs: # the name match works on PRs that don't touch workspace-server/). platform-build: name: Platform (Go) + needs: changes runs-on: ubuntu-latest # mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job. # Phase 4 (#656) originally flipped this to continue-on-error: false based on @@ -153,29 +125,29 @@ jobs: run: working-directory: workspace-server steps: - - if: false + - if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform != 'true' }} working-directory: . - run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." - - if: always() + run: echo "No workspace-server/** changes on this PR — Platform (Go) gate satisfied without running Go build/test/lint." + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: 'stable' - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} run: go mod download - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} run: go build ./cmd/server # CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} run: go vet ./... - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} name: Install golangci-lint run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} name: Run golangci-lint run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./... - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} name: Diagnostic — per-package verbose 60s run: | set +e @@ -191,7 +163,7 @@ jobs: echo "::endgroup::" # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. continue-on-error: true - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} name: Run tests with race detection and coverage # Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the # full ./... suite with race detection + coverage. A 10m per-step timeout @@ -199,7 +171,7 @@ jobs: # instead of OOM-killing. The job-level timeout (15m) is a backstop. run: go test -race -timeout 10m -coverprofile=coverage.out ./... - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} name: Per-file coverage report # Advisory — lists every source file with its coverage so reviewers # can see at-a-glance where gaps are. Sorted ascending so the worst @@ -213,7 +185,7 @@ jobs: END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \ | sort -n - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.platform == 'true' }} name: Check coverage thresholds # Enforces two gates from #1823 Layer 1: # 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md). @@ -301,6 +273,7 @@ jobs: # siblings — verified empirically on PR #2314). canvas-build: name: Canvas (Next.js) + needs: changes runs-on: ubuntu-latest timeout-minutes: 20 # Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12. @@ -309,20 +282,20 @@ jobs: run: working-directory: canvas steps: - - if: false + - if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.canvas != 'true' }} working-directory: . - run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." - - if: always() + run: echo "No canvas/** changes on this PR — Canvas (Next.js) gate satisfied without running npm build/test." + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }} run: npm ci --include=optional --prefer-offline - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }} run: npm run build - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }} name: Run tests with coverage # Coverage instrumentation is configured in canvas/vitest.config.ts # (provider: v8, reporters: text + html + json-summary). Step 2 of @@ -331,7 +304,7 @@ jobs: # tracked in #1815) after the team sees what current coverage is. run: npx vitest run --coverage - name: Upload coverage summary as artifact - if: always() + if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.canvas == 'true' }} # Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses # the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT # implement, surfacing as `GHESNotSupportedError: @actions/artifact @@ -348,15 +321,16 @@ jobs: # Shellcheck (E2E scripts) — required check, always runs. shellcheck: name: Shellcheck (E2E scripts) + needs: changes runs-on: ubuntu-latest # Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12. continue-on-error: false steps: - - if: false - run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection." - - if: always() + - if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.scripts != 'true' }} + run: echo "No tests/e2e, scripts, or infra/scripts changes on this PR — Shellcheck gate satisfied without running script checks." + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }} uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }} name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh # shellcheck is pre-installed on ubuntu-latest runners (via apt). # infra/scripts/ is included because setup.sh + nuke.sh gate the @@ -367,16 +341,16 @@ jobs: find tests/e2e infra/scripts -type f -name '*.sh' -print0 \ | xargs -0 shellcheck --severity=warning - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }} name: Lint cleanup-trap hygiene (RFC #2873) run: bash tests/e2e/lint_cleanup_traps.sh - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }} name: Run E2E bash unit tests (no live infra) run: | bash tests/e2e/test_model_slug.sh - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }} name: Test ECR promote-tenant-image script (mock-driven, no live infra) # Covers scripts/promote-tenant-image.sh — the codified # :staging-latest → :latest ECR promote + tenant fleet redeploy @@ -386,7 +360,7 @@ jobs: run: | bash scripts/test-promote-tenant-image.sh - - if: always() + - if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.scripts == 'true' }} name: Shellcheck promote-tenant-image script # scripts/ is excluded from the bulk shellcheck pass above (legacy # SC3040/SC3043 cleanup pending). Run shellcheck explicitly on diff --git a/.gitea/workflows/e2e-api.yml b/.gitea/workflows/e2e-api.yml index 19e45ab65..55fde08cd 100644 --- a/.gitea/workflows/e2e-api.yml +++ b/.gitea/workflows/e2e-api.yml @@ -132,31 +132,13 @@ jobs: with: fetch-depth: 0 - id: decide - # Inline replacement for dorny/paths-filter — same pattern PR#372's - # ci.yml port used. Diffs against the PR base or push BEFORE SHA, - # then matches against the api-relevant path set. run: | - BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}" - if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then - BASE="${{ github.event.pull_request.base.sha }}" - fi - if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then - echo "api=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - if ! git cat-file -e "$BASE" 2>/dev/null; then - git fetch --depth=1 origin "$BASE" 2>/dev/null || true - fi - if ! git cat-file -e "$BASE" 2>/dev/null; then - echo "api=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - CHANGED=$(git diff --name-only "$BASE" HEAD) - if echo "$CHANGED" | grep -qE '^(workspace-server/|tests/e2e/|\.gitea/workflows/e2e-api\.yml$)'; then - echo "api=true" >> "$GITHUB_OUTPUT" - else - echo "api=false" >> "$GITHUB_OUTPUT" - fi + python3 .gitea/scripts/detect-changes.py \ + --profile e2e-api \ + --event-name "${{ github.event_name }}" \ + --pr-base-sha "${{ github.event.pull_request.base.sha }}" \ + --base-ref "${{ github.event.pull_request.base.ref }}" \ + --push-before "${GITHUB_EVENT_BEFORE:-${{ github.event.before }}}" # ONE job (no job-level `if:`) that always runs and reports under the # required-check name `E2E API Smoke Test`. Real work is gated per-step diff --git a/.gitea/workflows/handlers-postgres-integration.yml b/.gitea/workflows/handlers-postgres-integration.yml index cd0dbba29..8ebfa0342 100644 --- a/.gitea/workflows/handlers-postgres-integration.yml +++ b/.gitea/workflows/handlers-postgres-integration.yml @@ -101,36 +101,13 @@ jobs: # not present in the shallow checkout. fetch-depth: 2 - id: filter - # Inline replacement for dorny/paths-filter — see e2e-api.yml. run: | - # Gitea Actions evaluates github.event.before to empty string in shell - # scripts. Use GITHUB_EVENT_BEFORE shell env var instead (Gitea - # correctly populates it for push events). PR case uses template var. - BASE="" - if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then - BASE="${{ github.event.pull_request.base.sha }}" - elif [ -n "$GITHUB_EVENT_BEFORE" ]; then - BASE="$GITHUB_EVENT_BEFORE" - fi - if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then - echo "handlers=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - # timeout 30 guards against the case where BASE points to a ref that - # git can resolve but cat-file hangs (rare on corrupted objects). - if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then - git fetch --depth=1 origin "$BASE" 2>/dev/null || true - fi - if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then - echo "handlers=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - CHANGED=$(git diff --name-only "$BASE" HEAD) - if echo "$CHANGED" | grep -qE '^(workspace-server/internal/handlers/|workspace-server/internal/wsauth/|workspace-server/migrations/|\.gitea/workflows/handlers-postgres-integration\.yml$)'; then - echo "handlers=true" >> "$GITHUB_OUTPUT" - else - echo "handlers=false" >> "$GITHUB_OUTPUT" - fi + python3 .gitea/scripts/detect-changes.py \ + --profile handlers-postgres \ + --event-name "${{ github.event_name }}" \ + --pr-base-sha "${{ github.event.pull_request.base.sha }}" \ + --base-ref "${{ github.event.pull_request.base.ref }}" \ + --push-before "${GITHUB_EVENT_BEFORE:-}" # Single-job-with-per-step-if pattern: always runs to satisfy the # required-check name on branch protection; real work gates on the diff --git a/tests/test_detect_changes.py b/tests/test_detect_changes.py new file mode 100644 index 000000000..e501aa4e1 --- /dev/null +++ b/tests/test_detect_changes.py @@ -0,0 +1,193 @@ +"""Tests for `.gitea/scripts/detect-changes.py`.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "detect-changes.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("detect_changes", SCRIPT) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_ci_profile_classifies_surfaces(): + mod = load_module() + + assert mod.classify("ci", ["workspace-server/internal/handlers/a2a_proxy.go"]) == { + "platform": True, + "canvas": False, + "python": False, + "scripts": False, + } + assert mod.classify("ci", ["canvas/src/app/page.tsx"]) == { + "platform": False, + "canvas": True, + "python": False, + "scripts": False, + } + assert mod.classify("ci", ["tests/e2e/test_model_slug.sh"]) == { + "platform": False, + "canvas": False, + "python": False, + "scripts": True, + } + assert mod.classify("ci", [".gitea/workflows/ci.yml", "README.md"]) == { + "platform": False, + "canvas": False, + "python": False, + "scripts": False, + } + + +def test_handlers_postgres_profile_is_narrower_than_workspace_server(): + mod = load_module() + + assert mod.classify("handlers-postgres", ["workspace-server/internal/handlers/a2a_proxy.go"]) == { + "handlers": True, + } + assert mod.classify("handlers-postgres", ["workspace-server/internal/provisioner/provisioner.go"]) == { + "handlers": False, + } + + +def test_e2e_api_profile_covers_api_inputs(): + mod = load_module() + + assert mod.classify("e2e-api", ["workspace-server/internal/handlers/workspace.go"]) == { + "api": True, + } + assert mod.classify("e2e-api", ["tests/e2e/test_api.sh"]) == {"api": True} + assert mod.classify("e2e-api", ["canvas/src/app/page.tsx"]) == {"api": False} + + +def test_fail_open_all_true_for_missing_base(): + mod = load_module() + + assert mod.all_true("ci") == { + "platform": True, + "canvas": True, + "python": True, + "scripts": True, + } + + +def test_fetch_base_prefers_advertised_base_ref(monkeypatch): + mod = load_module() + calls: list[list[str]] = [] + exists_checks = 0 + + def fake_base_exists(base: str) -> bool: + nonlocal exists_checks + exists_checks += 1 + return exists_checks >= 1 + + def fake_run_git(args: list[str], *, timeout: int = 30): + calls.append(args) + + class Result: + returncode = 0 + stdout = "" + stderr = "" + + return Result() + + monkeypatch.setattr(mod, "base_exists", fake_base_exists) + monkeypatch.setattr(mod, "run_git", fake_run_git) + + mod.fetch_base("abc123", "main") + + assert calls == [["fetch", "--depth=1", "origin", "main"]] + + +def test_fetch_base_falls_back_to_sha_when_ref_fetch_does_not_materialize(monkeypatch): + mod = load_module() + calls: list[list[str]] = [] + + monkeypatch.setattr(mod, "base_exists", lambda _base: False) + + def fake_run_git(args: list[str], *, timeout: int = 30): + calls.append(args) + + class Result: + returncode = 0 + stdout = "" + stderr = "" + + return Result() + + monkeypatch.setattr(mod, "run_git", fake_run_git) + + mod.fetch_base("abc123", "main") + + assert calls == [ + ["fetch", "--depth=1", "origin", "main"], + ["fetch", "--depth=1", "origin", "abc123"], + ] + + +def test_changed_paths_uses_merge_base_for_pull_request(monkeypatch): + mod = load_module() + calls: list[list[str]] = [] + + def fake_run_git(args: list[str], *, timeout: int = 30): + calls.append(args) + + class Result: + returncode = 0 + stdout = "workspace/agent.py\n" + stderr = "" + + if args[0] == "merge-base": + Result.stdout = "merge123\n" + return Result() + + monkeypatch.setattr(mod, "run_git", fake_run_git) + + assert mod.changed_paths("base123", use_merge_base=True) == ["workspace/agent.py"] + assert calls == [ + ["merge-base", "base123", "HEAD"], + ["diff", "--name-only", "merge123", "HEAD"], + ] + + +def test_detect_deepens_base_ref_when_pr_merge_base_missing(monkeypatch): + mod = load_module() + calls: list[tuple[str, str | None]] = [] + merge_base_calls = 0 + + monkeypatch.setattr(mod, "base_exists", lambda _base: True) + + def fake_merge_base(base: str): + nonlocal merge_base_calls + merge_base_calls += 1 + if merge_base_calls == 1: + return None + return "merge123" + + def fake_deepen_base_ref(base_ref: str): + calls.append(("deepen", base_ref)) + + def fake_changed_paths(base: str, *, use_merge_base: bool): + calls.append(("changed", str(use_merge_base))) + return [".gitea/workflows/ci.yml"] + + monkeypatch.setattr(mod, "merge_base", fake_merge_base) + monkeypatch.setattr(mod, "deepen_base_ref", fake_deepen_base_ref) + monkeypatch.setattr(mod, "changed_paths", fake_changed_paths) + + assert mod.detect("ci", "pull_request", "base123", "", "main") == { + "platform": False, + "canvas": False, + "python": False, + "scripts": False, + } + assert calls == [("deepen", "main"), ("changed", "True")] diff --git a/tests/test_lint_workflow_yaml.py b/tests/test_lint_workflow_yaml.py index 18b670600..a8c4d0b2f 100644 --- a/tests/test_lint_workflow_yaml.py +++ b/tests/test_lint_workflow_yaml.py @@ -26,9 +26,11 @@ import re import subprocess import sys import textwrap +import importlib.util from pathlib import Path import pytest # noqa: F401 (declares the dep) +import yaml REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "lint-workflow-yaml.py" @@ -616,16 +618,24 @@ def test_rule10_docker_info_head_in_separate_step_without_pipefail_passes(tmp_pa CI_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci.yml" CI_SURFACES = ("platform", "canvas", "python", "scripts") +DETECT_CHANGES_SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "detect-changes.py" + + +def _load_detect_changes(): + spec = importlib.util.spec_from_file_location("detect_changes", DETECT_CHANGES_SCRIPT) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module def _ci_change_patterns() -> dict[str, re.Pattern[str]]: - text = CI_WORKFLOW.read_text(encoding="utf-8") - patterns: dict[str, re.Pattern[str]] = {} - for surface, pattern in re.findall( - r'echo "(platform|canvas|python|scripts)=.*?grep -qE \'([^\']+)\'', - text, - ): - patterns[surface] = re.compile(pattern) + detect_changes = _load_detect_changes() + patterns = { + surface: re.compile(pattern) + for surface, pattern in detect_changes.PROFILES["ci"].items() + } assert set(patterns) == set(CI_SURFACES) return patterns @@ -693,3 +703,58 @@ def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces(): "python": False, "scripts": False, } + + +def test_ci_platform_go_pr_steps_are_path_scoped(): + doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8")) + platform = doc["jobs"]["platform-build"] + assert platform.get("needs") == "changes" + + expensive_steps = [ + step + for step in platform["steps"] + if step.get("uses") + or step.get("run", "").startswith("go ") + or "golangci-lint" in step.get("run", "") + ] + assert expensive_steps + for step in expensive_steps: + expr = step.get("if", "") + assert "github.event_name != 'pull_request'" in expr + assert "needs.changes.outputs.platform == 'true'" in expr + + +def test_ci_canvas_nextjs_pr_steps_are_path_scoped(): + doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8")) + canvas = doc["jobs"]["canvas-build"] + assert canvas.get("needs") == "changes" + + expensive_steps = [ + step + for step in canvas["steps"] + if step.get("uses") + or step.get("run", "").startswith("npm ") + or step.get("run", "").startswith("npx ") + ] + assert expensive_steps + for step in expensive_steps: + expr = step.get("if", "") + assert "github.event_name != 'pull_request'" in expr + assert "needs.changes.outputs.canvas == 'true'" in expr + + +def test_ci_shellcheck_pr_steps_are_path_scoped(): + doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8")) + shellcheck = doc["jobs"]["shellcheck"] + assert shellcheck.get("needs") == "changes" + + expensive_steps = [ + step + for step in shellcheck["steps"] + if step.get("uses") or step.get("run", "").startswith(("bash ", "find ", "shellcheck ")) + ] + assert expensive_steps + for step in expensive_steps: + expr = step.get("if", "") + assert "github.event_name != 'pull_request'" in expr + assert "needs.changes.outputs.scripts == 'true'" in expr