Merge pull request #254 from declaresub/fix/abnfgen-surrogates #487
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |