diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index c45e668..6821bee 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,32 +1,194 @@ name: publish-pypi -# Trusted Publishing (OIDC) — sem token armazenado. -# Requer: publisher registrado no PyPI apontando para -# owner/repo: / workflow: publish-pypi.yml environment: (vazio) -# Para projetos ainda nao criados, use "pending publisher" — o primeiro -# publish confiavel cria o projeto. Disparo manual: gh workflow run publish-pypi. +# Secure PyPI release pipeline — build once, publish the same artifact. +# +# build — version gate + `python -m build` + integrity manifest + artifact +# upload (contents:read only) +# publish — downloads the SAME dist/ built above, re-verifies sha256, then +# publishes via PyPI Trusted Publishing (OIDC — no stored token) +# verify — installs the published version from PyPI in a clean venv and +# smoke-tests the console script +# +# Trusted Publishing (OIDC) — no stored token. The publisher registered on +# PyPI must point to: +# owner/repo: / workflow: publish-pypi.yml environment: release +# NOTE: the previous pipeline used an empty environment — update the PyPI +# publisher registration to `release` before the first run of this workflow. +# +# `environment: release` also enables repo protection — configure required +# reviewers/rules under Settings > Environments > release. +# +# GitHub artifact attestations (PEP 740) are intentionally disabled to keep +# this pipeline minimal; to enable later, add `attestations: write` to the +# publish job permissions and `attestations: true` to the pypi-publish inputs. on: workflow_dispatch: + inputs: + version: + description: "Version to publish — must match [project] version in pyproject.toml" + required: true + type: string permissions: contents: read jobs: - pypi: - # PyPI trusted publisher registered — OIDC, no token. - if: true + build: + name: Build release artifact runs-on: ubuntu-latest permissions: - id-token: write # OIDC token for PyPI trusted publishing - attestations: write # PEP 740 artifact attestations + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" + + - name: Version gate (input match, PyPI freshness, changelog) + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + python - <<'PY' + import json, os, re, sys, tomllib, urllib.error, urllib.request + + def fail(msg): + print(f"::error::version gate: {msg}") + sys.exit(1) + + with open("pyproject.toml", "rb") as fh: + project = tomllib.load(fh)["project"] + name = project["name"] + version = project["version"] + print(f"pyproject.toml: {name} == {version}") + + expected = os.environ.get("EXPECTED_VERSION", "").strip() + if expected: + if version != expected: + fail(f"dispatch input {expected!r} != pyproject.toml version {version!r}") + print(f"dispatch input matches pyproject version: {version}") + else: + print("no version input supplied — input-match check skipped") + + pypi_name = re.sub(r"[-_.]+", "-", name).lower() + url = f"https://pypi.org/pypi/{pypi_name}/json" + releases = None + try: + with urllib.request.urlopen(url, timeout=15) as resp: + releases = json.load(resp).get("releases", {}) + except urllib.error.HTTPError as e: + if e.code == 404: + print(f"{pypi_name}: not on PyPI yet (HTTP 404) — first publish") + else: + fail(f"PyPI JSON returned HTTP {e.code}; refusing to publish unverified") + except Exception as e: + fail(f"PyPI check failed: {e}") + if releases is not None and version in releases: + fail(f"{pypi_name} {version} is already published on PyPI") + + if os.path.exists("CHANGELOG.md"): + lines = open("CHANGELOG.md", encoding="utf-8").read().splitlines() + tracks = any(re.match(r"##\s*\[?\d+\.\d+", l) for l in lines[:40]) + if not tracks: + print("CHANGELOG.md does not track versions — changelog gate skipped") + elif any(l.startswith("##") and version in l for l in lines): + print(f"CHANGELOG.md has an entry for {version}") + else: + fail(f"CHANGELOG.md tracks versions but has no entry for {version}") + else: + print("no CHANGELOG.md — changelog gate skipped") + + print(f"version gate OK: {name} {version}") + PY + - name: Build wheel + sdist - run: python -m pip install --quiet build && python -m build - - name: Publish to PyPI (trusted publishing + attestations) + run: python -m pip install --quiet build twine && python -m build + + - name: Twine check + run: python -m twine check dist/* + + - name: Inspect wheel contents + run: | + for whl in dist/*.whl; do + echo "== $whl" + python -m zipfile -l "$whl" + done + + - name: Record artifact checksums + run: (cd dist && sha256sum -- *) | tee dist-checksums.txt + + - name: Upload release artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-dist + path: | + dist/ + dist-checksums.txt + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + # configure protection rules/reviewers for the "release" environment + # in repo Settings > Environments + environment: release + permissions: + contents: read + id-token: write # OIDC for PyPI trusted publishing — scoped to this job only + steps: + - name: Download release artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-dist + + - name: Verify artifact integrity (same artifact that was built) + run: | + test -f dist-checksums.txt + cd dist && sha256sum --check ../dist-checksums.txt + + - name: Publish to PyPI (trusted publishing) uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + + verify: + name: Verify published package + needs: publish + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: - attestations: true + python-version: "3.12" + + - name: Wait for release to appear on PyPI + env: + PKG_NAME: platformforge + EXPECTED_VERSION: ${{ inputs.version }} + run: | + python - <<'PY' + import os, sys, time, urllib.request + name = os.environ["PKG_NAME"] + version = os.environ["EXPECTED_VERSION"] + url = f"https://pypi.org/pypi/{name}/{version}/json" + for attempt in range(1, 21): # up to ~5 min + try: + with urllib.request.urlopen(url, timeout=15) as resp: + if resp.status == 200: + print(f"{name} {version} live on PyPI (attempt {attempt})") + sys.exit(0) + except Exception as e: + print(f"attempt {attempt}/20: not live yet ({e})") + time.sleep(15) + sys.exit(f"{name} {version} not visible on PyPI after ~5 minutes") + PY + + - name: Install published package in clean venv + run: | + python -m venv /tmp/verify-venv + /tmp/verify-venv/bin/python -m pip install --no-cache-dir \ + --index-url https://pypi.org/simple \ + "platformforge==${{ inputs.version }}" + + - name: Smoke test console script + run: /tmp/verify-venv/bin/platformforge --version