Skip to content
Merged
Show file tree
Hide file tree
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
4 changes: 2 additions & 2 deletions .github/python-versions.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# The Python version tiers (#1660). Every workflow matrix and every
# single-interpreter job's version is read from here through
# util/python_versions.py, and tests/project/test_python_versions_1660.py pins
# the classifiers and docs to it. A tier change is a one-line edit below plus
# the matching edit to ruleset 23497679's Compat contexts.
# the classifiers and docs to it. cron-python-versions.yml recomputes it weekly
# and proposes each change, and the ruleset 23497679 edit it needs, as a PR.
#
# Tiers: supported (blocking), ktlo (end-of-life, non-blocking) and pre-release
# (non-blocking; none listed today). `runner` is the image a version needs when
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/coverage-comment.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Coverage Comment

# Posts the coverage the `test` job's Python 3.15 leg measured in
# Posts the coverage the `test` job's `latest` leg measured in
# unit-tests.yml as a pull-request comment. It runs no tests. The comment cannot
# be posted from unit-tests.yml itself: that file is also a reusable workflow,
# and a called workflow can only narrow the token its caller passes, never widen
Expand Down Expand Up @@ -81,8 +81,13 @@ jobs:
if tests not in ('success', 'failure', 'cancelled', 'skipped'):
sys.exit(f'invalid test outcome in artifact: {tests!r}')
total = number(data['total'])
# The leg's version, from the artifact; one written before #1660 has none.
python = data.get('python')
if python is not None and not (isinstance(python, str) and re.fullmatch(r'3\.\d+', python)):
sys.exit(f'invalid Python version in artifact: {python!r}')
leg = f'Python {python}' if python else 'the coverage leg'
status = '' if tests == 'success' else f' -- **tests {tests}**, so this is partial'
print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python 3.15, "
print(f"**Coverage: {total:.2f}%**{status} (unit tier, {leg}, "
f"`{os.environ['HEAD_SHA'][:9]}`, Unit Tests run {os.environ['RUN_CONCLUSION']})")
print()
print('| Package | Statements | Missed | Branches | Partial | Cover |')
Expand Down
78 changes: 63 additions & 15 deletions .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ concurrency:
# artefact -- the tag it checks is the one it is about to create -- but ``tag``,
# ``pypi`` and ``conda`` now each read their own evidence instead: ``tag`` checks
# for the ``conda-<version>+0`` tag it creates, ``pypi`` checks PyPI's file count
# for the version against the 7 expected, and ``conda`` both checks Anaconda's
# for the version against the count expected, and ``conda`` both checks Anaconda's
# aggregate count as a job-level cost saver and, per leg, whether that leg's own
# platform/Python distribution is already there -- the correctness fix, since
# retrying an upload Anaconda already has fails outright rather than skipping
Expand Down Expand Up @@ -220,8 +220,9 @@ jobs:
fetch-depth: 0

# #1660: .github/python-versions.toml, read with the runner's own python3:
# `latest` for this job, the ``pypi`` and ``conda`` matrices, and the tags
# the two completeness checks below count, all from this run's commit.
# `latest` for this job and the ``pypi`` and ``conda`` matrices, from this
# run's commit, and the tags the two completeness checks below count until
# the version is tagged.
- name: Read the Python versions
id: python-versions
run: python3 util/python_versions.py --github-output
Expand Down Expand Up @@ -262,11 +263,58 @@ jobs:
with:
tag: "conda-${{ steps.get_version.outputs.PCAPKIT_VERSION }}+0"

# PyPI evidence: compare the file count for this version against the 7
# expected -- the 6 supported legs (``pypi``'s ``python-version`` axis,
# whose wheel tags ``PCAPKIT_PYPI_WHEELS`` lists) each producing a wheel,
# plus the sdist that the "No Source Distribution" step keeps only on the
# 3.15 leg, ``latest``. A release-level 200 only proves *some*
# #1660: the two checks below count what the version was released with.
# Once its ``v*`` tag exists, that is the tier file at the tag's commit,
# read by that commit's own util/python_versions.py, not this run's file:
# a version added to the supported tier since was never built for it, and
# counting it would read a finished release as incomplete, run the gate and
# the publishers, and fail #1634's tag check on every run until the next
# release. A tag older than the file counts the tiers the file was added
# with, which #1677 copied unchanged from the workflows of the time.
- name: Read the released Python versions
id: released
env:
PCAPKIT_TAG: v${{ steps.get_version.outputs.PCAPKIT_VERSION }}
PCAPKIT_TAG_EXISTS: ${{ steps.check_tag.outputs.exists }}
PCAPKIT_PYPI_WHEELS: ${{ steps.python-versions.outputs.pypi-wheels }}
PCAPKIT_CONDA_BUILDS: ${{ steps.python-versions.outputs.conda-builds }}
run: |
set -euo pipefail

wheels="$PCAPKIT_PYPI_WHEELS"
builds="$PCAPKIT_CONDA_BUILDS"
tiers=.github/python-versions.toml
if [ "$PCAPKIT_TAG_EXISTS" = true ]; then
# The checkout fetched every tag, but not one pushed since.
git rev-parse -q --verify "refs/tags/${PCAPKIT_TAG}^{commit}" > /dev/null \
|| git fetch -q --no-tags origin "+refs/tags/${PCAPKIT_TAG}:refs/tags/${PCAPKIT_TAG}"
from="$(git rev-parse "refs/tags/${PCAPKIT_TAG}^{commit}")"
if ! git cat-file -e "${from}:${tiers}" 2> /dev/null; then
from="$(git log --diff-filter=A --format=%H -- "$tiers" | tail -n 1)"
if [ -z "$from" ]; then
echo "::error title=No tier file in the history::${tiers} was never added to this checkout's history."
exit 1
fi
echo "::notice title=Released before the tier file::${PCAPKIT_TAG} predates ${tiers}; counting the tiers ${from} added it with."
fi
if [ "$from" != "$(git rev-parse HEAD)" ]; then
released="$(mktemp -d)"
git show "${from}:${tiers}" > "${released}/python-versions.toml"
git show "${from}:util/python_versions.py" > "${released}/python_versions.py"
wheels="$(python3 "${released}/python_versions.py" --file "${released}/python-versions.toml" pypi-wheels)"
builds="$(python3 "${released}/python_versions.py" --file "${released}/python-versions.toml" conda-builds)"
fi
fi

echo "${PCAPKIT_TAG}: counting the wheels ${wheels} and the conda builds ${builds}"
echo "pypi-wheels=${wheels}" >> "$GITHUB_OUTPUT"
echo "conda-builds=${builds}" >> "$GITHUB_OUTPUT"

# PyPI evidence: compare the file count for this version against the one
# expected -- a wheel from each supported leg (``pypi``'s ``python-version``
# axis, whose wheel tags ``PCAPKIT_PYPI_WHEELS`` lists from the tiers read
# above), plus the sdist that the "No Source Distribution" step keeps only
# on the ``latest`` leg. A release-level 200 only proves *some*
# file landed, not all of them, so presence alone -- ``PCAPKIT_TAG_EXISTS``'s
# mistake, repeated -- is not enough. Only the supported tier's wheels are
# counted (#1633): a KTLO leg's wheel neither completes the release nor,
Expand All @@ -292,7 +340,7 @@ jobs:
id: check_pypi
env:
PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }}
PCAPKIT_PYPI_WHEELS: ${{ steps.python-versions.outputs.pypi-wheels }}
PCAPKIT_PYPI_WHEELS: ${{ steps.released.outputs.pypi-wheels }}
run: |
set -euo pipefail

Expand Down Expand Up @@ -342,19 +390,19 @@ jobs:
echo "http $status, found $count of $expected expected files for ${PCAPKIT_VERSION} on PyPI"
echo "PCAPKIT_PYPI_COMPLETE=$complete" >> "$GITHUB_OUTPUT"

# Anaconda evidence, same shape as PyPI's above but for the 10-leg
# ``conda`` matrix (2 OS x 5 Python versions). Unlike PyPI's, this endpoint
# reported ``cf-cache-status: DYNAMIC`` on every request checked while
# writing this, i.e. not cached -- so there is no staleness direction to
# Anaconda evidence, same shape as PyPI's above but for the ``conda``
# matrix (2 OS x the supported tier read above). Unlike PyPI's, this
# endpoint reported ``cf-cache-status: DYNAMIC`` on every request checked
# while writing this, i.e. not cached -- so there is no staleness direction to
# reason about here. This is the job-level cost saver mirroring ``pypi``'s;
# ``conda``'s own per-leg checks (see that job) are the actual correctness
# fix and still run underneath it, since a release with only 9 of 10 legs
# fix and still run underneath it, since a release one leg short of
# complete must not have the missing leg mistaken for "nothing to do".
- name: Check Anaconda publish completeness
id: check_conda
env:
PCAPKIT_VERSION: ${{ steps.get_version.outputs.PCAPKIT_VERSION }}
PCAPKIT_CONDA_BUILDS: ${{ steps.python-versions.outputs.conda-builds }}
PCAPKIT_CONDA_BUILDS: ${{ steps.released.outputs.conda-builds }}
run: |
set -euo pipefail

Expand Down
177 changes: 177 additions & 0 deletions .github/workflows/cron-python-versions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
name: Python Versions

# #1660: keeps .github/python-versions.toml current. `detect` recomputes the
# tiers from python.org's release cycle, PyPy's releases, setup-python's builds
# and the hosted runner images (util/update_python_versions.py, whose docstring
# gives the rules), rewrites the file and the prose the audit tests pin to it,
# and runs that audit. When something changed, `pr` pushes the change to
# `bot/python-versions` and opens that branch's pull request, or updates the
# open one, for the owner to merge. Nothing here pushes `main` or edits ruleset
# 23497679: the pull request body lists the required contexts to change by hand.
#
# Only these two triggers. A push to `bot/python-versions` starts the pull
# request's own checks, which is why `pr` spends the release App's token rather
# than GITHUB_TOKEN, whose pushes start nothing; nothing it starts runs this
# workflow again.
on:
schedule:
- cron: '41 5 * * 1' # Mondays at 05:41 UTC
workflow_dispatch:

# Nothing by default; each job asks for what it needs.
permissions: {}

# One run at a time, so two runs cannot race for the branch. A queued run is
# kept, not cancelled: it recomputes from the sources and skips what the branch
# already proposes.
concurrency:
group: python-versions
cancel-in-progress: false

jobs:
# No secret and a read-only token. The computation reads the network and
# writes only this checkout, and the audit is the repository's own tests.
detect:
name: Recompute the Python version tiers
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: read
outputs:
changed: ${{ steps.update.outputs.changed }}
publish: ${{ steps.update.outputs.publish }}

steps:
- uses: actions/checkout@v7
with:
persist-credentials: false

# `latest` for this job, like every single-interpreter job (#1661).
- name: Read the Python versions
id: python-versions
run: python3 util/python_versions.py --github-output

- uses: actions/setup-python@v7
with:
python-version: ${{ steps.python-versions.outputs.latest }}

# PyYAML, from the `test` extra, for the workflow readers and the audit;
# neither imports pcapkit.
- name: Install PyYAML
run: python -m pip install PyYAML

# Fetches the four sources, rewrites the files, and writes the proposal to
# $RUNNER_TEMP/proposal. `publish` is false when nothing changed, or when
# `bot/python-versions` has an open pull request and a commit it holds
# ahead of main proposes these tiers, hand edits or merges of main on top
# or not (an anonymous, commits-only fetch), so an unmerged proposal is not
# pushed, nor its approval asked for, every week. When that branch holds
# commits it did not make and proposes other tiers, this fails as
# `publish` would refuse, before any approval. A branch with no open pull
# request was merged or closed, and is proposed afresh. The token reads
# that pull request's state.
- name: Recompute the tiers
id: update
env:
GH_TOKEN: ${{ github.token }}
run: >-
python util/update_python_versions.py update --write --out "$RUNNER_TEMP/proposal"
--proposal-branch bot/python-versions --github-output

# The three modules that pin the workflows and the prose to the file, on
# the rewritten tree. A failure here does not stop the proposal: a tier
# move can need hand edits the script names, and the body lists what failed.
- name: Run the audit tests
id: audit
continue-on-error: true
shell: bash
run: >-
python -m unittest -v tests.project.test_python_versions_1660
tests.project.test_python_matrix_tiers_1633 tests.project.test_ktlo_legs_stay_green_1672
2>&1 | tee "$RUNNER_TEMP/audit.log"

- name: Write the pull request body
if: ${{ steps.update.outputs.changed == 'true' }}
env:
AUDIT_OUTCOME: ${{ steps.audit.outcome }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: >-
python util/update_python_versions.py body "$RUNNER_TEMP/proposal"
--audit-outcome "$AUDIT_OUTCOME" --audit-log "$RUNNER_TEMP/audit.log" --run-url "$RUN_URL"

# Kept for as long as an environment approval can wait. The proposal holds
# files/.github/..., which upload-artifact leaves out unless told otherwise.
- name: Upload the proposal
if: ${{ steps.update.outputs.publish == 'true' }}
uses: actions/upload-artifact@v7
with:
name: python-versions-proposal
path: ${{ runner.temp }}/proposal
include-hidden-files: true
retention-days: 30

- name: Say why no pull request opens
if: ${{ steps.update.outputs.publish == 'true' && vars.RELEASE_APP_CLIENT_ID == '' }}
run: >-
echo "::warning title=No pull request without the release App::vars.RELEASE_APP_CLIENT_ID is unset,
so the pr job skips. Set up the App as docs/source/contributing/releasing.rst describes."

# With nothing changed the tree is main's, so a failing audit is main's own
# failure: the run says so rather than passing green over it.
- name: Fail on a failing audit of an unchanged tree
if: ${{ steps.update.outputs.changed != 'true' && steps.audit.outcome == 'failure' }}
run: |
echo "::error title=The audit fails on main::the tiers need no change, but the audit fails; see above."
exit 1

# The release App's private key is a secret of the `github-release`
# environment, which needs the owner's approval, so he is asked only on a run
# that has something to propose. The token is contents and pull requests
# write and nothing else: no `workflows`, so it cannot push a workflow file,
# and `update` writes none. `publish` force-pushes `bot/python-versions` alone,
# and while its pull request is open refuses it if it holds commits it did not
# make; with none open it recreates the branch from main.
pr:
name: Open or update the pull request
needs: detect
if: >-
${{ needs.detect.outputs.publish == 'true' && vars.RELEASE_APP_CLIENT_ID != '' &&
github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: github-release
# GITHUB_TOKEN only downloads `detect`'s artifact; the App's token does the rest.
permissions:
actions: read

steps:
- name: Mint the release App's token
id: app
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write

# The commit `detect` read, with full history for the branch's merge base.
- uses: actions/checkout@v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
token: ${{ steps.app.outputs.token }}

- name: Download the proposal
env:
GH_TOKEN: ${{ github.token }}
run: >-
gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name python-versions-proposal
--dir "$RUNNER_TEMP/proposal"

- name: Push the proposal and open or update its pull request
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
APP_SLUG: ${{ steps.app.outputs.app-slug }}
APP_BOT_ID: ${{ vars.RELEASE_APP_BOT_ID }}
run: python3 util/update_python_versions.py publish "$RUNNER_TEMP/proposal"
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ jobs:
- uses: actions/checkout@v7

# One interpreter, not a matrix. Unit Tests and Python Compatibility sweep
# 3.11-3.15, plus the non-blocking KTLO legs 3.7-3.10 (#1633), because
# the supported tier, plus the non-blocking KTLO legs (#1633), because
# they check that the package *runs* everywhere; these tools read the
# source, and running them several times over would produce several
# copies of the same findings for several times the runner cost.
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ jobs:
c['num_partial_branches'], round(pct(c), 2)] for name, c in sorted(packages.items())]
total = round(data['totals']['percent_covered'], 2)
tests = os.environ['TESTS_OUTCOME']
json.dump({'total': total, 'tests': tests, 'packages': rows},
json.dump({'total': total, 'tests': tests, 'packages': rows, 'python': os.environ['PYTHON']},
open('coverage-comment/coverage-summary.json', 'w'))
status = '' if tests == 'success' else f' -- **tests {tests}**, so this is partial'
print(f"**Coverage: {total:.2f}%**{status} (unit tier, Python {os.environ['PYTHON']}, `{os.environ['SHA'][:9]}`)")
Expand Down Expand Up @@ -1748,8 +1748,8 @@ jobs:
- name: Run full test suite
run: python -m pytest -q -n auto --dist load

# Ruleset 23497679's required_status_checks requires six contexts: this
# job's `Required checks passed`, and five for `Compat Python 3.11`-`3.15`
# Ruleset 23497679's required_status_checks requires these contexts: this
# job's `Required checks passed`, and a `Compat Python` one per supported version,
# from the `compatibility` job in `.github/workflows/python-compatibility.yml`.
# This job aggregates the gating jobs of *this* file, the ones its `needs:`
# lists, because rulesets match check names literally and support no
Expand Down
Loading
Loading