name: Python on: # Push only, as in the Inspector: with pull_request as well, every commit on a # PR branch ran each workflow twice (once per event). PR checks still report # from the push run of the head commit. push: # A push made with the workflow token starts no workflow, so the version PRs # that automation opens (Version Packages, Prepare Python Release) get no # push run. Their workflows dispatch this one on the PR's branch instead # (#5110): `workflow_dispatch` is the one event that token can start, and its # run's checks attach to the branch's head commit. The rollup a PR reads # leaves dispatched checks out, so the `dispatch-status` jobs below report # the run as a commit status instead. workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), # except on main, where every merge keeps its own CI run concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} # Every job here only reads the repository (checkout, caches, artifacts), so # the token is scoped in the file rather than inherited from the repository's # default workflow permission, which could be widened in the UI with no commit # (#5091). permissions: contents: read jobs: detect-packages: timeout-minutes: 5 runs-on: ubuntu-latest outputs: packages: ${{ steps.find-packages.outputs.packages }} steps: - uses: actions/checkout@v6 - name: Find Python packages id: find-packages working-directory: src run: | PACKAGES=$(find . -name pyproject.toml -exec dirname {} \; | sed 's/^\.\///' | jq -R -s -c 'split("\n")[:-1]') echo "packages=$PACKAGES" >> $GITHUB_OUTPUT script-tests: timeout-minutes: 5 name: Test the Python gate script runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v7 with: node-version: 24 - name: Run node --test run: node --test scripts/validate-py.test.mjs scripts/coverage-py.test.mjs scripts/lib/py-coverage.test.mjs test: timeout-minutes: 15 needs: [detect-packages] strategy: matrix: package: ${{ fromJson(needs.detect-packages.outputs.packages) }} name: Test ${{ matrix.package }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version-file: "src/${{ matrix.package }}/.python-version" - uses: actions/setup-node@v7 with: node-version: 24 # The same per-server chain `npm run validate:py` runs locally: locked # sync, ruff check, ruff format --check, pyright, pytest, uv build. - name: Validate run: node scripts/validate-py.mjs ${{ matrix.package }} # The per-file coverage gate (#4855), in parallel with `test`: every file of # each server at >= 90% on lines and on branches. The same script # `npm run coverage:py` runs locally, one server per leg. fail-fast is off so # one server's shortfall does not cancel the other legs' verdicts. coverage: timeout-minutes: 15 needs: [detect-packages] strategy: fail-fast: false matrix: package: ${{ fromJson(needs.detect-packages.outputs.packages) }} name: Coverage ${{ matrix.package }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version-file: "src/${{ matrix.package }}/.python-version" - uses: actions/setup-node@v7 with: node-version: 24 - name: Coverage run: npm run coverage:py -- ${{ matrix.package }} # The test suites on Windows (#1149), beside the Ubuntu `test` legs. Only # pytest: ruff, pyright and the build do not depend on the OS. test-windows: timeout-minutes: 20 needs: [detect-packages] strategy: fail-fast: false matrix: package: ${{ fromJson(needs.detect-packages.outputs.packages) }} name: Test ${{ matrix.package }} (Windows) runs-on: windows-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version-file: "src/${{ matrix.package }}/.python-version" - name: Install dependencies working-directory: src/${{ matrix.package }} run: uv sync --locked --all-extras --dev # fetch's tests pin the Readability.js path, which needs Node and npm. # readabilipy >= 0.3.0 ships only its JavaScript's package.json, and the # server installs the dependencies on first use, resolving npm through # PATH so Windows' npm.cmd is found (#5313). Nothing is pre-installed # here, so this leg proves that install works on Windows. - uses: actions/setup-node@v7 if: matrix.package == 'fetch' with: node-version: 24 - name: Run pytest working-directory: src/${{ matrix.package }} run: uv run --frozen pytest build: timeout-minutes: 10 needs: [detect-packages, test] strategy: matrix: package: ${{ fromJson(needs.detect-packages.outputs.packages) }} name: Build ${{ matrix.package }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install uv uses: astral-sh/setup-uv@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version-file: "src/${{ matrix.package }}/.python-version" - name: Install dependencies working-directory: src/${{ matrix.package }} run: uv sync --locked --all-extras --dev - name: Run pyright working-directory: src/${{ matrix.package }} run: uv run --frozen pyright - name: Build package working-directory: src/${{ matrix.package }} run: uv build - name: Upload artifacts uses: actions/upload-artifact@v7 with: name: dist-${{ matrix.package }} path: src/${{ matrix.package }}/dist/ # A dispatched run's checks attach to the branch's head commit but are left # out of its status rollup, which is what a pull request reads (verified on # #5110: 29 successful dispatched check runs, a rollup of null). So on a # dispatch, which is how a version PR opened by automation gets CI, these two # jobs mirror the run onto the commit as a status, which the rollup does # include: pending at the start, then the result of every job above. A push # run reports through its own checks and skips both. dispatch-status-pending: name: Report the dispatched run as pending if: github.event_name == 'workflow_dispatch' timeout-minutes: 5 runs-on: ubuntu-latest permissions: statuses: write # To look for a newer dispatched run of this workflow on the commit. actions: read steps: - name: Set the commit status to pending env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} run: | # A re-run keeps its run ID, so a newer run means this one is # superseded and the status is no longer its to set (#5223). NEWER=$(gh run list --workflow python.yml --event workflow_dispatch \ --commit "$GITHUB_SHA" --json databaseId \ --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 if [ "${NEWER:-0}" -gt 0 ]; then echo "Superseded by a newer dispatched run, which reports instead" exit 0 fi gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ -f state=pending -f context="Python (dispatched)" \ -f description="Dispatched run in progress" \ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" # `always()`, so a cancelled run cannot leave the status pending. A run that # a newer dispatch on the same commit superseded posts nothing, whatever its # jobs' results, and the newer run reports instead; any other cancellation # is reported as an error. A job skipped by its own `if:` counts as passed. dispatch-status: name: Report the dispatched run's result if: github.event_name == 'workflow_dispatch' && always() needs: [ dispatch-status-pending, detect-packages, script-tests, test, coverage, test-windows, build, ] timeout-minutes: 5 runs-on: ubuntu-latest permissions: statuses: write # To look for a newer dispatched run of this workflow on the commit. actions: read steps: - name: Set the commit status to the run's result env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} NEEDS: ${{ toJSON(needs) }} run: | # A run superseded by a newer dispatch on the same commit posts # nothing, whatever its jobs' results: the newer run owns the status # (#5178). A failed lookup counts as no newer run, so this run still # reports: a stale status is better than one left pending. NEWER=$(gh run list --workflow python.yml --event workflow_dispatch \ --commit "$GITHUB_SHA" --json databaseId \ --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 if [ "${NEWER:-0}" -gt 0 ]; then echo "Superseded by a newer dispatched run, which reports instead" exit 0 fi if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then STATE=success DESCRIPTION="Every job passed" elif jq -e 'any(.[]; .result == "failure")' <<<"$NEEDS" >/dev/null; then STATE=failure DESCRIPTION="A job failed; open the run for details" else STATE=error DESCRIPTION="The run was cancelled; dispatch it again" fi gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ -f state="$STATE" -f context="Python (dispatched)" \ -f description="$DESCRIPTION" \ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"