Skip to content
Merged
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
133 changes: 131 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:-<nothing: the gate itself failed>}"
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."