Skip to content

Merge pull request #254 from declaresub/fix/abnfgen-surrogates #487

Merge pull request #254 from declaresub/fix/abnfgen-surrogates

Merge pull request #254 from declaresub/fix/abnfgen-surrogates #487

Workflow file for this run

# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: abnf-tox
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
# Minimal default token permissions. No job in this workflow needs
# anything beyond reading the source tree to run tests.
permissions:
contents: read
# Cancel stale runs when a new commit lands on the same branch or
# PR. Different branches / PRs still run in parallel.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Python backend / Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- name: Run tox
run: |
pip install pytest
pip install pytest-cov
pip install tox
tox -e py
rust-backend:
# Companion of the `build` job above. The Python-backend tests
# exercise `abnf._parser_python`; this job builds the compiled
# `abnf-rust` extension and runs the same suite against the
# dispatch shim's Rust path. Both backends must stay parity-
# equivalent, so any regression on either side fails CI.
#
# Runs on all three platforms we ship wheels for. Linux covers
# the supported Python range; Windows and macOS add one version
# each, because what they contribute is a different toolchain
# (MSVC link, Mach-O) rather than a different Python minor — a
# full OS x version matrix would be 15 jobs to learn almost
# nothing extra. 3.10 and 3.14 pick up the ends of the range at
# no cost. Runner labels are pinned to match release.yml, which
# avoids `macos-latest` because that label can shift under us.
name: Rust backend / ${{ matrix.label }}
runs-on: ${{ matrix.os }}
defaults:
run:
# Windows runners default to pwsh; bash is present on all
# three images, so every step below stays identical.
shell: bash
strategy:
fail-fast: false
matrix:
# Linux labels are bare version numbers so these job names
# stay byte-identical to the required status checks named in
# the branch ruleset. Renaming them silently drops the
# requirement.
include:
- { label: 'Python 3.10', os: ubuntu-latest, python-version: '3.10' }
- { label: 'Python 3.11', os: ubuntu-latest, python-version: '3.11' }
- { label: 'Python 3.12', os: ubuntu-latest, python-version: '3.12' }
- { label: 'Python 3.13', os: ubuntu-latest, python-version: '3.13' }
- { label: 'Python 3.14', os: ubuntu-latest, python-version: '3.14' }
- { label: 'Windows / Python 3.10', os: windows-latest, python-version: '3.10' }
- { label: 'macOS / Python 3.14', os: macos-15, python-version: '3.14' }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ matrix.python-version }}
- name: Cache Cargo registry and target/
# Cache hit rate is high (keyed by Cargo.lock) so subsequent
# runs build only what changed. Unlike the release workflow
# — where we deliberately disable build caches to seal source —
# tests can use a cache: a poisoned cache would surface as a
# test failure rather than as a tampered release artifact.
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: packages/abnf-rust -> target
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
python -m pip install pytest pytest-cov
- name: Install abnf (editable)
run: python -m pip install -e .
- name: Build and install abnf-rust
# `pip install` reads `packages/abnf-rust/pyproject.toml`,
# which declares maturin as the PEP 517 build backend; maturin
# builds the wheel in an isolated environment and pip installs
# it. No need to call maturin directly.
run: python -m pip install ./packages/abnf-rust
- name: Confirm the Rust backend is active
run: |
python -c "import abnf.parser; assert abnf.parser._BACKEND == 'rust', abnf.parser._BACKEND; print('backend:', abnf.parser._BACKEND)"
- name: Run tests
run: pytest --ignore=tests/fuzz --ignore=tests/benchmarks
# ----------------------------------------------------------------
# Release-dependency resolvability
# ----------------------------------------------------------------
#
# The release workflow resolves the runtime dependency closure to
# build the SBOM, and that resolution happens *before* anything is
# published. So a dependency bound naming a version that is not on
# PyPI yet -- the one being released, say -- makes the release
# unbuildable, and nothing in the ordinary test matrix would notice:
# this is the only place the resolution is exercised outside a tag
# build. Run the same command here, where it costs seconds and
# fails a PR instead of a release.
lockfile:
name: Release deps resolve
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install uv
run: |
python3 -m pip install --user 'uv==0.11.8'
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: uv.lock agrees with pyproject.toml
run: uv lock --check
- name: Runtime dependency closure resolves
# The exact command the release workflow runs for the SBOM.
# Written to a real path, not /dev/null: uv writes a temp file
# beside the target and renames it, which /dev cannot host.
run: |
uv export --no-dev --extra rust --no-hashes --no-emit-project \
--format requirements-txt -o "$RUNNER_TEMP/requirements.txt"
# ----------------------------------------------------------------
# Changelog entry
# ----------------------------------------------------------------
#
# A change to the library that ships without a changelog entry is
# invisible to users, and the way entries go missing is rarely a
# decision: `## Unreleased` exists only *between* releases, so an
# edit anchored on that heading silently does nothing in the window
# right after one is cut. Checking here catches the omission
# whatever caused it.
#
# Scoped to the library itself. CI, docs and tooling changes do not
# need an entry, and requiring one for them is how a check like this
# becomes noise that people learn to route around.
changelog:
name: Changelog entry
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Needed to diff against the merge base.
fetch-depth: 0
- name: A library change carries a changelog entry
env:
BASE_REF: ${{ github.base_ref }}
run: |
python3 - <<'PY'
import os, pathlib, subprocess, sys
WATCHED = ("src/", "packages/abnf-rust/rust/")
CHANGELOG = "CHANGELOG.md"
def git(*args):
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True
).stdout
base = f"origin/{os.environ['BASE_REF']}"
merge_base = git("merge-base", base, "HEAD").strip()
changed = git("diff", "--name-only", merge_base, "HEAD").split()
code = [p for p in changed if p.startswith(WATCHED)]
if not code:
print("No library change; a changelog entry is not required.")
sys.exit(0)
# The entry has to land in the Unreleased section, not in an
# already-released one.
text = pathlib.Path(CHANGELOG).read_text(encoding="utf-8").splitlines()
try:
start = text.index("## Unreleased")
except ValueError:
print(
f"::error::{len(code)} library file(s) changed, but {CHANGELOG} has "
"no '## Unreleased' section. Add one above the most recent release."
)
sys.exit(1)
end = next(
(i for i, l in enumerate(text[start + 1 :], start + 1) if l.startswith("## ")),
len(text),
)
unreleased = set(text[start + 1 : end])
added = [
l[1:]
for l in git("diff", "-U0", merge_base, "HEAD", "--", CHANGELOG).splitlines()
if l.startswith("+") and not l.startswith("+++")
]
if not any(l in unreleased for l in added):
print(
f"::error::{len(code)} library file(s) changed, but this branch adds "
f"nothing to the '## Unreleased' section of {CHANGELOG}. "
"Note that heading only exists between releases -- if a release was "
"just cut, add the section back rather than editing a released one."
)
for p in code[:10]:
print(f" changed: {p}")
sys.exit(1)
print(f"{len(code)} library file(s) changed, and Unreleased gained an entry.")
PY