From 87c18cddd10f8f6f22a0aa72f8c3febfe31acfb6 Mon Sep 17 00:00:00 2001 From: Market Data <112879596+MarketDataApp@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:50:03 -0400 Subject: [PATCH] ci: a documentation-only pull request should not run the suite The docs sync bot (marketdata-docs-sync) opens pull requests here that touch nothing but docs/. Every one of them runs the full suite to test markdown. A required status check can never be conditional: the list is static. And a matrix job that GitHub SKIPS does not expand its name, so it reports no per-combination context at all and the pull request waits forever for checks that never arrive. Measured on MarketDataApp/sdk-php#95. So `Tests passed` becomes the single context branch protection requires. A `changes` job always runs, diffs against the merge base, and the suite gates on it. The path list is inverted on purpose: it says what DOCUMENTATION looks like and runs the suite for everything else. Enumerating code paths instead would skip the suite the first time somebody adds a source directory nobody listed. `Tests passed` separates two classes of skip. A job that applies to every change may only skip when the gate asked for it, so a failed gate cannot produce a green over untested code. A job with a condition of its own -- the live suite does not run on an ordinary push, nor for dependabot, which cannot read the token -- may skip for its own reasons, which branch protection already tolerates. A FAILURE never passes in either class. --- .github/workflows/ci.yml | 133 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2de6a8..462605c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,75 @@ jobs: # manual dispatch exercise the full cross-OS matrix (ยง13 forward-compat). # The unit/build OS matrix is the ONLY thing trimmed on PRs; the coverage # gate and the integration job are unaffected. + changes: + # A documentation-only pull request does not need the suite below. But a + # required status check can never be conditional -- the list is static -- and + # a matrix job that GitHub SKIPS does not expand its name, so it reports no + # per-combination context at all and the pull request waits forever for + # checks that never arrive. Measured on MarketDataApp/sdk-php#95. + # + # So `Tests passed` at the bottom is the single context branch protection + # requires, and this job decides whether the suite does any work. + name: Decide whether the suite applies + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + code: ${{ steps.filter.outputs.code }} + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Detect whether anything but documentation changed + id: filter + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + # Only a pull request can be documentation-only here. A push, a release + # and a workflow_call all run the suite. + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "code=true" >> "$GITHUB_OUTPUT" + echo "Not a pull request, so running the suite." + exit 0 + fi + + # Every path out of here that is not a confident "documentation only" + # says true. A gate that cannot see both ends of the diff must never + # conclude that no code changed. + for sha in "$BASE" "$HEAD"; do + if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then + echo "code=true" >> "$GITHUB_OUTPUT" + echo "Cannot reach $sha, so running the suite rather than guessing." + exit 0 + fi + done + + # Three dots: the diff from the merge base, not from wherever the base + # branch has moved to since. + files=$(git diff --name-only "$BASE...$HEAD") + echo "changed files:" + printf '%s\n' "$files" | sed 's/^/ /' + + # Inverted deliberately. This lists what DOCUMENTATION looks like and + # runs the suite for everything else. Enumerating code paths instead + # would skip the suite the first time somebody adds a source directory + # nobody remembered to list. + if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '(^docs/|\.md$)'; then + echo "code=false" >> "$GITHUB_OUTPUT" + echo "Documentation only: the suite skips, and Tests passed says so." + else + echo "code=true" >> "$GITHUB_OUTPUT" + echo "Something other than documentation changed: running the suite." + fi + setup: + needs: changes + # `!= 'false'`, not `== 'true'`. A failed gate leaves this empty, + # and the safe reading of empty is RUN THE SUITE. + if: needs.changes.outputs.code != 'false' name: Select OS matrix runs-on: ubuntu-latest timeout-minutes: 5 @@ -241,10 +309,17 @@ jobs: # RequireApiToken() call in every live test's constructor both enforce it. Dependabot # runs cannot read repository secrets, so their PRs skip the JOB: that shows as # "skipped" in the checks list, not as a green run that tested nothing. + # + # This job does NOT depend on `setup`, so it carries the documentation gate + # itself. The existing clauses are parenthesised because the gate is ANDed + # onto all of them. `!= 'false'` and not `== 'true'`, so a failed gate still + # runs the suite rather than reporting a green that tested nothing. + needs: changes if: >- - (github.event_name == 'pull_request' && github.actor != 'dependabot[bot]') || + ((github.event_name == 'pull_request' && github.actor != 'dependabot[bot]') || github.event_name == 'release' || - (github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration_tests == 'true') + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration_tests == 'true')) && + needs.changes.outputs.code != 'false' runs-on: ubuntu-latest # Every step here talks to the live API. Without a cap, one hung request would hold # the runner until the 6-hour default expires. @@ -305,3 +380,57 @@ jobs: with: name: integration-test-results path: TestResults/ + + tests-passed: + # The single context branch protection requires. It always reports, so it is + # always a decision rather than a silence. + name: Tests passed + needs: [changes, setup, build, integration] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 2 + + steps: + - name: Require the suite to have passed, or to have been skipped for cause + env: + CODE: ${{ needs.changes.outputs.code }} + R0: ${{ needs.setup.result }} + R1: ${{ needs.build.result }} + R2: ${{ needs.integration.result }} + run: | + echo "gate said code=${CODE:-}" + echo "setup: ${R0}" + echo "build: ${R1}" + echo "integration: ${R2}" + + # Two classes of job, because they skip for different reasons. + # + # STRICT jobs apply to every change, so the only acceptable skip is the + # one this workflow's gate asked for. If the gate failed, CODE is empty, + # the job skipped for some other reason, and a green here would cover + # untested code. + for result in "${R0}" "${R1}"; do + case "$result" in + success) ;; + skipped) + if [ "$CODE" != "false" ]; then + echo "A required job was skipped and the gate did not ask for it." + exit 1 + fi + ;; + *) echo "A required job reported: $result"; exit 1 ;; + esac + done + + # LENIENT jobs carry conditions of their own that predate this gate: the + # live suite does not run on an ordinary push, and it does not run for + # dependabot, which cannot read the token. Branch protection already + # treats those skips as passing, so this must too. A FAILURE still + # fails. + for result in "${R2}"; do + case "$result" in + success|skipped) ;; + *) echo "A conditional job reported: $result"; exit 1 ;; + esac + done + echo "Every job passed, or skipped for a reason this gate accepts."