diff --git a/.github/workflows/ci-minimal.yml b/.github/workflows/ci-minimal.yml index b8060426..01c5db6e 100644 --- a/.github/workflows/ci-minimal.yml +++ b/.github/workflows/ci-minimal.yml @@ -92,6 +92,40 @@ jobs: checkout_ref: ${{ github.event.pull_request.head.sha }} fbuild-artifact: fbuild-bin-linux-debug toolchain-cache: ${{ matrix.toolchain_cache }} + reuse_decision: + name: Verified reuse decision (shadow) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + permissions: + contents: read + actions: read + pull-requests: read + steps: + - uses: actions/checkout@v6 + continue-on-error: true + timeout-minutes: 1 + with: + repository: zackees/ci.yml + ref: b93b8c4a20ce3cd476c75a278ad01a31bde2438b + path: .ci-lint + persist-credentials: false + - uses: astral-sh/setup-uv@v3 + continue-on-error: true + timeout-minutes: 1 + - name: Reuse decision (GEN-021, shadow) + continue-on-error: true + timeout-minutes: 2 + working-directory: .ci-lint + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + uv run --no-project python -m ci_lint reuse-check + --workflow ci-minimal.yml --mode shadow + --required-job "linux / Check (ubuntu-latest)" + --required-job "linux / Python facade tests (ubuntu-latest)" + --required-job "CI selected coverage" test: if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-test') && !contains(github.event.pull_request.labels.*.name, 'ci-full') uses: ./.github/workflows/ci-test.yml diff --git a/ci/render_workflows.py b/ci/render_workflows.py index d3a3c12f..ea02748b 100644 --- a/ci/render_workflows.py +++ b/ci/render_workflows.py @@ -306,6 +306,68 @@ def render_pr_boards() -> str: ) +# zackees/ci.yml commit that provides `ci-lint reuse-check` (stdlib-only). +CI_LINT_SHA = "b93b8c4a20ce3cd476c75a278ad01a31bde2438b" +# Job names green on both the default PR tier and main pushes (zackees/ci.yml#162). +REUSE_REQUIRED_JOBS = ( + "linux / Check (ubuntu-latest)", + "linux / Python facade tests (ubuntu-latest)", + "CI selected coverage", +) +REUSE_JOB_TIMEOUT = 5 +# Per-step minutes: checkout, setup-uv, reuse-check. Sum stays below the job cap. +REUSE_STEP_TIMEOUTS = (1, 1, 2) + + +def render_reuse_decision() -> str: + """GEN-021 verified-reuse decision on main pushes, SHADOW mode only. + + Records in the job summary whether this push's tree is identical to a PR + head whose ci-minimal run passed every REUSE_REQUIRED_JOBS job + (zackees/ci.yml#162). `--mode shadow` always reports reuse=false, nothing + `needs:` this job, and the job and every step are continue-on-error, so it + can neither skip a job nor fail the run. REUSE_STEP_TIMEOUTS sum below + REUSE_JOB_TIMEOUT, so a stall + ends as a (tolerated) step timeout rather than a job cancellation. + """ + required = "".join(f" --required-job \"{job}\"\n" for job in REUSE_REQUIRED_JOBS) + checkout_t, uv_t, check_t = REUSE_STEP_TIMEOUTS + return ( + " reuse_decision:\n" + " name: Verified reuse decision (shadow)\n" + " if: github.event_name == 'push' && github.ref == 'refs/heads/main'\n" + " runs-on: ubuntu-latest\n" + f" timeout-minutes: {REUSE_JOB_TIMEOUT}\n" + " continue-on-error: true\n" + " permissions:\n" + " contents: read\n" + " actions: read\n" + " pull-requests: read\n" + " steps:\n" + " - uses: actions/checkout@v6\n" + " continue-on-error: true\n" + f" timeout-minutes: {checkout_t}\n" + " with:\n" + " repository: zackees/ci.yml\n" + f" ref: {CI_LINT_SHA}\n" + " path: .ci-lint\n" + " persist-credentials: false\n" + " - uses: astral-sh/setup-uv@v3\n" + " continue-on-error: true\n" + f" timeout-minutes: {uv_t}\n" + " - name: Reuse decision (GEN-021, shadow)\n" + " continue-on-error: true\n" + f" timeout-minutes: {check_t}\n" + " working-directory: .ci-lint\n" + " env:\n" + " GITHUB_TOKEN: ${{ github.token }}\n" + " run: >-\n" + " uv run --no-project python -m ci_lint reuse-check\n" + " --workflow ci-minimal.yml --mode shadow\n" + + required + ) + + def render_ci(boards: list[dict], tier: str, families: dict) -> str: full = tier == "full" minimal = tier == "minimal" @@ -516,7 +578,7 @@ def render_ci(boards: list[dict], tier: str, families: dict) -> str: + " done\n" + " echo 'complete=true' >> \"$GITHUB_OUTPUT\"\n" ) - + (render_pr_boards() if minimal else "") + + (render_pr_boards() + render_reuse_decision() if minimal else "") + (" test:\n" " if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-test') && !contains(github.event.pull_request.labels.*.name, 'ci-full')\n" " uses: ./.github/workflows/ci-test.yml\n" diff --git a/ci/test_fractional_workflows.py b/ci/test_fractional_workflows.py index fba041b0..30dd2125 100644 --- a/ci/test_fractional_workflows.py +++ b/ci/test_fractional_workflows.py @@ -169,9 +169,20 @@ def test_ordinary_minimal_and_opt_in_test_are_distinct(self): self.assertIn("push", minimal[True]) self.assertIn("pull_request", minimal[True]) self.assertEqual( - {"linux", "board_plan", "fbuild_bin", "pr_boards", "test", "full", "selected-coverage"}, + {"linux", "board_plan", "fbuild_bin", "pr_boards", "reuse_decision", "test", "full", "selected-coverage"}, set(minimal["jobs"]), ) + # GEN-021 shadow decision (zackees/ci.yml#162): main pushes only, never + # consumed by another job, and unable to fail the run. + reuse = minimal["jobs"]["reuse_decision"] + self.assertIn("github.event_name == 'push'", reuse["if"]) + self.assertTrue(reuse.get("continue-on-error")) + self.assertTrue(all(step.get("continue-on-error") for step in reuse["steps"])) + # A stall must end as a tolerated step timeout, never a job cancellation. + step_budget = sum(step["timeout-minutes"] for step in reuse["steps"]) + self.assertLess(step_budget, reuse["timeout-minutes"]) + self.assertIn("--mode shadow", reuse["steps"][-1]["run"]) + self.assertFalse(any("reuse_decision" in str(job.get("needs", "")) for job in minimal["jobs"].values())) # Path-selected boards run only on the default PR tier. plan_if = minimal["jobs"]["board_plan"]["if"] self.assertIn("pull_request", plan_if)