mrrc 0.9.1

A Rust library for reading, writing, and manipulating MARC bibliographic records in ISO 2709 binary format
Documentation
name: Python Release to PyPI

on:
  push:
    tags:
      - 'v*'
  # Dry run: builds and tests every artifact (wheels, sdist) without
  # publishing to PyPI — the PyPI publish and GitHub Release steps are
  # gated on tag refs. Selecting publish-target=testpypi additionally
  # rehearses the publish against TestPyPI (requires a TestPyPI trusted
  # publisher configured for this repository and workflow).
  workflow_dispatch:
    inputs:
      publish-target:
        description: "none = build/test only; testpypi = rehearsal publish to TestPyPI"
        type: choice
        options:
          - none
          - testpypi
        default: none

permissions:
  contents: read

env:
  # Same setting as the PR wheel gate and check.sh: build under the
  # abi3 forward-compatibility flag so a CPython newer than the pinned
  # pyo3 supports fails here the same way it would fail there.
  PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1"

jobs:
  build-release-wheels:
    name: Build release wheels on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-2025]
        python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
      fail-fast: false
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}

      - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          manylinux: auto
          # macOS builds universal2 (arm64 + x86_64 in one wheel) so Intel
          # Macs are covered; GitHub no longer offers Intel macOS runners,
          # and the arm64 slice still gets natively import-tested by the
          # test-release-wheels job.
          target: ${{ matrix.os == 'macos-latest' && 'universal2-apple-darwin' || '' }}
          args: --release --out dist -i python${{ matrix.python-version }}

      - name: Upload wheels
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-wheels-${{ matrix.os }}-${{ matrix.python-version }}
          path: dist

  build-cross-linux-wheels:
    name: Build cross-compiled ${{ matrix.target }} wheel (Python ${{ matrix.python-version }})
    runs-on: ubuntu-latest
    timeout-minutes: 45
    strategy:
      matrix:
        target: [aarch64-unknown-linux-gnu, i686-unknown-linux-gnu]
        python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
      fail-fast: false
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}

      - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          target: ${{ matrix.target }}
          manylinux: auto
          args: --release --out dist -i python${{ matrix.python-version }}

      - name: Upload wheels
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-wheels-${{ matrix.target }}-${{ matrix.python-version }}
          path: dist

  build-sdist:
    name: Build sdist
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

      - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
        with:
          command: sdist
          args: --out dist

      - name: Upload sdist
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-sdist
          path: dist

  test-sdist:
    name: Test install from sdist
    needs: build-sdist
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.12"

      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: release-sdist
          path: dist

      - name: Build from sdist and smoke-test
        run: |
          # Compiles the extension from source — proves the sdist is the
          # working fallback for platforms outside the wheel matrix.
          pip install dist/mrrc-*.tar.gz
          # Run from outside the repo root: python -c puts the CWD on
          # sys.path, and the source ./mrrc/ directory (no compiled
          # extension) would shadow the installed package.
          cd "$RUNNER_TEMP"
          python -c "
          import mrrc
          data = open('$GITHUB_WORKSPACE/tests/data/simple_book.mrc','rb').read()
          records = list(mrrc.MARCReader(data))
          assert len(records) == 1 and records[0].title == 'The Great Gatsby'
          print('sdist install OK from', mrrc.__file__)
          "

  validate-cross-wheels:
    name: Validate ${{ matrix.target }} wheel (Python ${{ matrix.python-version }})
    needs: build-cross-linux-wheels
    runs-on: ubuntu-latest
    timeout-minutes: 10
    strategy:
      matrix:
        target: [aarch64-unknown-linux-gnu, i686-unknown-linux-gnu]
        python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
      fail-fast: false
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: release-wheels-${{ matrix.target }}-${{ matrix.python-version }}
          path: dist

      - name: Validate wheel exists with correct platform tag
        run: |
          if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then
            EXPECTED_TAG="aarch64"
          elif [ "${{ matrix.target }}" = "i686-unknown-linux-gnu" ]; then
            EXPECTED_TAG="i686"
          fi
          WHEEL=$(ls dist/*.whl 2>/dev/null | head -1)
          if [ -z "$WHEEL" ]; then
            echo "ERROR: No .whl file found in dist/"
            ls -la dist/
            exit 1
          fi
          echo "Found wheel: $WHEEL"
          if echo "$WHEEL" | grep -q "$EXPECTED_TAG"; then
            echo "OK: Wheel filename contains expected tag '$EXPECTED_TAG'"
          else
            echo "ERROR: Wheel filename does not contain expected tag '$EXPECTED_TAG'"
            exit 1
          fi

  # The filename check above covers all 10 cross-compiled wheels; this job
  # additionally installs and imports one representative wheel per target
  # inside a matching container, so a wheel that builds but cannot load
  # (bad linkage, wrong interpreter ABI) fails before publish.
  smoke-test-cross-wheels:
    name: Import-smoke ${{ matrix.target }} wheel (Python 3.12)
    needs: build-cross-linux-wheels
    runs-on: ubuntu-latest
    # QEMU emulation makes the aarch64 leg slow; the budget covers it.
    timeout-minutes: 30
    strategy:
      matrix:
        target: [aarch64-unknown-linux-gnu, i686-unknown-linux-gnu]
      fail-fast: false
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: release-wheels-${{ matrix.target }}-3.12
          path: dist

      - name: Set up QEMU (aarch64 emulation)
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0

      - name: Write import-smoke script
        run: |
          # Executed with python -I so neither the CWD nor the script
          # directory lands on sys.path; otherwise the source ./mrrc/
          # directory (no compiled extension) shadows the installed wheel.
          cat > smoke.py <<'EOF'
          import mrrc
          data = open("tests/data/simple_book.mrc", "rb").read()
          records = list(mrrc.MARCReader(data))
          assert len(records) == 1
          assert records[0].title == "The Great Gatsby"
          print("wheel import OK:", mrrc.__file__)
          EOF

      - name: Import-smoke aarch64 wheel under QEMU
        if: matrix.target == 'aarch64-unknown-linux-gnu'
        run: |
          docker run --rm --platform linux/arm64 -v "$PWD":/work -w /work \
            python:3.12-slim \
            sh -c 'pip install --no-cache-dir dist/*.whl && python -I smoke.py'

      - name: Import-smoke i686 wheel in 32-bit container
        if: matrix.target == 'i686-unknown-linux-gnu'
        run: |
          # i686 binaries run natively on the x86_64 runner kernel; linux32
          # sets the uname personality so pip accepts the i686 wheel tag.
          docker run --rm --platform linux/386 -v "$PWD":/work -w /work \
            quay.io/pypa/manylinux2014_i686 \
            linux32 sh -c '/opt/python/cp312-cp312/bin/pip install --no-cache-dir dist/*.whl && /opt/python/cp312-cp312/bin/python -I smoke.py'

  test-release-wheels:
    name: Test release wheels on ${{ matrix.os }}
    needs: build-release-wheels
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-2025]
        python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
      fail-fast: false
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install uv
        uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0

      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: release-wheels-${{ matrix.os }}-${{ matrix.python-version }}
          path: dist

      - name: Install wheel and test dependencies
        shell: bash
        run: |
          # Install the wheel together with the [dev] extra (which extends
          # [test] via self-reference) so test runtime deps + type checkers
          # stay in sync with pyproject.toml as a single source of truth.
          WHEEL=$(ls dist/mrrc-*.whl | head -1)
          uv pip install --system "${WHEEL}[dev]" pytest-rerunfailures

      - name: Run pytest (core tests, excludes benchmarks)
        shell: bash
        env:
          # Per-test reruns absorb intermittent macOS runner flakiness
          # with per-test signal instead of rerunning the whole suite.
          RERUN_FLAGS: ${{ runner.os == 'macOS' && '--reruns 2 --reruns-delay 5' || '' }}
        run: pytest tests/python/ -m "not benchmark" -v $RERUN_FLAGS

      - name: Run type checking
        run: |
          mypy tests/python/ --strict || true
          pyright tests/python/ || true

  publish-pypi:
    name: Publish to PyPI
    needs: [test-release-wheels, validate-cross-wheels, smoke-test-cross-wheels, test-sdist]
    runs-on: ubuntu-latest
    timeout-minutes: 15
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          # Collects release-wheels-* and release-sdist.
          pattern: release-*
          path: dist
          merge-multiple: true

      - name: List artifacts
        run: ls -l dist/

      - name: Publish to PyPI
        # Skipped on workflow_dispatch runs; only tag pushes publish.
        # skip-existing makes re-runs after a partial failure upload only
        # the missing files instead of dying on "file already exists".
        if: startsWith(github.ref, 'refs/tags/')
        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
        with:
          skip-existing: true

      - name: Publish to TestPyPI (rehearsal)
        if: github.event_name == 'workflow_dispatch' && inputs.publish-target == 'testpypi'
        uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
        with:
          repository-url: https://test.pypi.org/legacy/
          skip-existing: true

  # Separate job so a GitHub Release failure can be re-run without
  # re-entering the PyPI publish path, and vice versa.
  github-release:
    name: Create GitHub Release
    needs: publish-pypi
    if: startsWith(github.ref, 'refs/tags/')
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
        with:
          sparse-checkout: CHANGELOG.md
          sparse-checkout-cone-mode: false

      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          # Collects release-wheels-* and release-sdist.
          pattern: release-*
          path: dist
          merge-multiple: true

      - name: Extract release notes from CHANGELOG
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          # Extract the section for this version (between its header and the next ## header)
          NOTES=$(awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md)
          # Write to file to avoid shell quoting issues
          echo "$NOTES" > release_notes.md

      - name: Create GitHub Release
        uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
        with:
          files: dist/*
          body_path: release_notes.md
          draft: false
          prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') }}