Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 175 additions & 13 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -1,32 +1,194 @@
name: publish-pypi

# Trusted Publishing (OIDC) — sem token armazenado.
# Requer: publisher registrado no PyPI apontando para
# owner/repo: <org>/<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: <org>/<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
Loading