name: Publish to PyPI

# Triggered by GitHub Releases. The release event is a deliberate
# "publish now" gesture — pushing a tag alone won't publish, which
# avoids accidental releases.
#
# Auth uses PyPI Trusted Publishing (OIDC). No long-lived token is
# stored in GitHub Secrets — PyPI verifies the workflow's identity
# via the id-token claim and issues a short-lived upload credential.
#
# One-time PyPI setup (already-existing project):
#   1. https://pypi.org/manage/project/pyrxd/settings/publishing/
#   2. Add a trusted publisher with:
#        - Owner:           MudwoodLabs
#        - Repository:      pyrxd
#        - Workflow:        publish.yml
#        - Environment:     pypi
#   3. Optional but recommended: configure the `pypi` environment in
#      GitHub repo settings with required reviewers, so a second pair
#      of eyes approves each publish.

on:
  release:
    types: [published]

permissions:
  contents: read

jobs:
  build:
    name: Build sdist + wheel
    runs-on: ubuntu-latest
    permissions:
      # contents: write attaches the CycloneDX SBOM to the GitHub Release.
      # The release event is a trusted "publish now" gesture, so granting it
      # to this build job is acceptable.
      contents: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97  # v7.0.0
        with:
          python-version: "3.12"
      - name: Install Poetry
        # Hash-pinned via ci/poetry-pin.txt (shared with ci.yml). Closes the
        # OpenSSF Scorecard / CodeQL PinnedDependenciesID alert.
        run: pip install -r ci/poetry-pin.txt --require-hashes
      - name: Verify version matches release tag
        # Reject the publish if pyproject.toml's version doesn't match
        # the tag the release was created from. This catches the
        # "forgot to bump pyproject.toml" mistake before any artifact
        # reaches PyPI.
        run: |
          tag="${GITHUB_REF_NAME#v}"
          pkg_version=$(poetry version --short)
          if [ "$tag" != "$pkg_version" ]; then
            echo "::error::Release tag v$tag does not match pyproject.toml version $pkg_version"
            exit 1
          fi
          echo "Version OK: $pkg_version"
      - name: Build distributions
        run: poetry build
      - name: Generate CycloneDX SBOM
        # Built from a fresh venv containing only the just-built wheel,
        # rather than parsed from pyproject.toml directly: cyclonedx-py's
        # `poetry` backbone (and `environment --pyproject`) treats ANY
        # `[tool.poetry]` table as legacy Poetry format and reads
        # name/version/description straight out of it — with no PEP 621
        # `[project]` fallback — so it unconditionally calls
        # `poetry['name']` (cyclonedx_py/_internal/utils/pyproject.py).
        # pyrxd's `[tool.poetry]` only holds `packages`/`include`
        # (name/version/etc. live under `[project]`, Poetry's PEP 621
        # mode), so that lookup raised `KeyError: 'name'` on every release
        # since the PEP 621 migration — silently, since this step is
        # continue-on-error, and never actually produced an SBOM (zero SBOM
        # assets exist on any past release). Fix: `environment` mode
        # introspects the ACTUALLY INSTALLED distribution instead of
        # parsing pyproject.toml for dependency discovery (so poetry.lock,
        # intentionally gitignored, isn't needed here either), and its
        # root-component metadata is read from a `[tool.poetry]`-stripped
        # copy of pyproject.toml so cyclonedx-py's PEP 621 branch (which
        # only triggers when `[tool.poetry]` is entirely absent from the
        # file it's given) is the one that actually runs.
        # Written OUTSIDE dist/ so PyPI never sees it (gh-action-pypi-publish
        # uploads everything in dist/).
        # continue-on-error: the SBOM is supplementary — never block the PyPI
        # publish on it.
        continue-on-error: true
        run: |
          set -euo pipefail
          pkg_version=$(poetry version --short)
          pip install -r ci/cyclonedx-pin.txt --require-hashes

          strip_script=$(mktemp)
          cat > "$strip_script" <<'PY'
          import sys
          with open('pyproject.toml') as f:
              lines = f.readlines()
          out, skip = [], False
          for line in lines:
              stripped = line.strip()
              if stripped.startswith('[') and stripped.endswith(']'):
                  skip = stripped.startswith('[tool.poetry')
              if not skip:
                  out.append(line)
          with open(sys.argv[1], 'w') as f:
              f.writelines(out)
          PY
          pep621_pyproject=$(mktemp)
          python3 "$strip_script" "$pep621_pyproject"
          rm -f "$strip_script"

          python3 -m venv /tmp/sbom-venv
          /tmp/sbom-venv/bin/pip install --quiet dist/*.whl

          mkdir -p sbom
          cyclonedx-py environment /tmp/sbom-venv \
            --pyproject "$pep621_pyproject" \
            --output-format JSON \
            --output-file "sbom/pyrxd-${pkg_version}.cdx.json"
          rm -f "$pep621_pyproject"
      - name: Attach SBOM to the GitHub Release
        continue-on-error: true
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh release upload "${GITHUB_REF_NAME}" sbom/pyrxd-*.cdx.json --clobber
      - name: Upload distributions as workflow artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a  # v7.0.1
        with:
          name: dist
          path: dist/
          if-no-files-found: error

  publish:
    name: Publish to PyPI
    needs: build
    runs-on: ubuntu-latest
    # The `pypi` environment is the second-factor: configure required
    # reviewers under repo Settings → Environments → pypi to gate
    # publishes on a manual approval click.
    environment:
      name: pypi
      url: https://pypi.org/p/pyrxd
    permissions:
      # id-token: write is required for PyPI Trusted Publishing OIDC.
      # contents: read inherited from the workflow default would be
      # dropped without re-declaring at the job level when overriding
      # any permission, so re-declare both explicitly.
      id-token: write
      contents: read
    steps:
      - name: Download built distributions
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c  # v8.0.1
        with:
          name: dist
          path: dist/
      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33  # v1.14.2
        # Publishes to PyPI from ./dist using Trusted Publishing OIDC. With
        # id-token: write present, this action defaults `attestations: true`,
        # so each artifact ships a PEP 740 / Sigstore digital attestation
        # (verifiable on the PyPI project page and via `pip download
        # --require-hashes`). Verify they appear after the first publish at
        # https://pypi.org/project/pyrxd/#files (the "Provenance" badge).
        with:
          print-hash: true
