diff --git a/.github/workflows/release-testpypi.yml b/.github/workflows/release-testpypi.yml new file mode 100644 index 0000000..22c4add --- /dev/null +++ b/.github/workflows/release-testpypi.yml @@ -0,0 +1,131 @@ +# Dry-run release to TestPyPI. +# +# Trigger: manual only — Actions tab → "Release (TestPyPI)" → "Run workflow". +# Pick any branch from the UI. Provide a pre-release suffix +# (e.g. ".dev1", "a1", "rc1"); the workflow combines it with the +# base version from pyproject.toml on that branch. +# +# The final version is: + +# - pyproject.toml says: version = "0.1.2" suffix: ".dev1" → 0.1.2.dev1 +# - pyproject.toml says: version = "0.1.2.dev5" suffix: "rc1" → 0.1.2rc1 +# (any existing pre-release suffix on the base is stripped first) +# +# The patched version is written to pyproject.toml inside the runner +# only. Nothing is committed back to your branch. +# +# One-time setup on TestPyPI +# -------------------------- +# 1. test.pypi.org → Manage → Publishing → Add a pending publisher: +# Owner: +# Repo: +# Workflow: release-testpypi.yml +# Env: testpypi +# 2. GitHub repo → Settings → Environments → create `testpypi`. + +name: Release (TestPyPI) + +on: + workflow_dispatch: + inputs: + suffix: + description: 'Pre-release suffix to append (e.g. .dev1, a1, b2, rc1)' + required: true + type: string + default: '.dev1' + +jobs: + build: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Compute and patch version + id: version + env: + SUFFIX: ${{ inputs.suffix }} + run: | + python3 - <<'PY' + import os, re, sys + from pathlib import Path + + pp = Path("pyproject.toml") + text = pp.read_text() + m = re.search(r'^version = "([^"]+)"', text, re.M) + if not m: + sys.exit("::error::No version field in pyproject.toml") + + base = re.sub(r'(a|b|rc|\.dev)\d+$', '', m.group(1)) + + suffix = os.environ["SUFFIX"].strip() + # Friendly normalization: "dev1" → ".dev1" (PEP 440 needs the dot) + if re.match(r'^dev\d+$', suffix): + suffix = '.' + suffix + + final = base + suffix + if not re.fullmatch(r'\d+(\.\d+)*(a|b|rc|\.dev)\d+', final): + sys.exit( + f"::error::Final version '{final}' is not a valid PEP 440 " + "pre-release. Use suffixes like .dev1, a1, b1, rc1." + ) + + pp.write_text( + re.sub(r'^version = "[^"]+"', f'version = "{final}"', text, count=1, flags=re.M) + ) + + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"version={final}\n") + print(f"::notice::Publishing version {final} to TestPyPI") + PY + + - name: Install build tools + run: python -m pip install --upgrade pip build twine + + - name: Build frontend bundle + working-directory: frontend + run: | + npm ci + npm run build + + - name: Copy bundle into Python package + run: | + rm -rf backend/app/static + mkdir -p backend/app/static + cp -R frontend/dist/. backend/app/static/ + + - name: Build wheel + sdist + run: python -m build + + - name: Check artifacts + run: twine check dist/* + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7915c68..b76d95d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,43 +1,36 @@ -# Release workflow for forge-agent. +# Release to PyPI. # -# Triggers -# -------- -# * Push a tag like `v0.1.2` → build → publish to PyPI → GitHub Release -# * Manual "Run workflow" dispatch with target=testpypi → build → TestPyPI -# * Manual dispatch with target=pypi → build → PyPI (no tag/release) +# Trigger: GitHub Release published. +# Flow you'll follow manually: +# 1. Bump `version` in pyproject.toml, commit, push. +# 2. Create a GitHub Release (UI or `gh release create`) from the +# tag of your choice (e.g. v0.1.2). Set release notes. +# 3. Click "Publish release". +# 4. This workflow fires: builds the wheel, publishes to PyPI, +# uploads the wheel + sdist as assets on the release itself. # -# The tag version must match `project.version` in pyproject.toml; the job -# fails loudly otherwise so you can't ship mismatched metadata. +# The release tag must match `project.version` in pyproject.toml; the +# build fails loudly otherwise so mismatched metadata never ships. +# Pre-release versions (0.1.2.dev1, 0.1.2rc1, ...) are rejected here — +# use the Release (TestPyPI) workflow for those. # -# One-time setup on PyPI + TestPyPI (Trusted Publishers, no API tokens) -# -------------------------------------------------------------------- -# 1. pypi.org → Manage → Publishing → Add a pending publisher: -# - Owner: -# - Repo: -# - Workflow: release.yml -# - Env: pypi -# 2. test.pypi.org → same flow, environment name: testpypi -# 3. GitHub repo → Settings → Environments → create `pypi` and `testpypi`. -# (Optional: add required reviewers on `pypi` for manual approval.) +# One-time setup on PyPI +# ---------------------- +# 1. pypi.org → Manage → Publishing → Add a pending publisher: +# Owner: +# Repo: +# Workflow: release.yml +# Env: pypi +# 2. GitHub repo → Settings → Environments → create `pypi` +# (optionally require a manual approval here for prod releases). # -# After that, no secrets are needed — OIDC does the auth. +# After setup, no secrets are needed — OIDC handles the auth. -name: Release +name: Release (PyPI) on: - push: - tags: - - 'v*.*.*' - workflow_dispatch: - inputs: - target: - description: 'Where to publish' - required: true - default: 'testpypi' - type: choice - options: - - testpypi - - pypi + release: + types: [published] jobs: build: @@ -46,6 +39,8 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} - uses: actions/setup-node@v4 with: @@ -84,40 +79,33 @@ jobs: v=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") echo "version=$v" >> "$GITHUB_OUTPUT" - - name: Verify tag matches pyproject version - if: startsWith(github.ref, 'refs/tags/v') + - name: Verify release tag matches pyproject version run: | - tag="${GITHUB_REF#refs/tags/v}" + tag="${{ github.event.release.tag_name }}" + # Strip a leading "v" if present (v0.1.2 → 0.1.2) + tag="${tag#v}" pyv="${{ steps.version.outputs.version }}" if [ "$tag" != "$pyv" ]; then - echo "::error::Tag v$tag doesn't match pyproject.toml version $pyv" + echo "::error::Release tag ${{ github.event.release.tag_name }} doesn't match pyproject.toml version $pyv" exit 1 fi - - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ + - name: Reject pre-release versions (use TestPyPI workflow instead) + run: | + v="${{ steps.version.outputs.version }}" + if echo "$v" | grep -Eq '(a|b|rc|\.dev)[0-9]+$'; then + echo "::error::Real PyPI releases must be clean versions (0.1.2). Got pre-release: $v" + echo "::error::Use the Release (TestPyPI) workflow for dry-runs; drop the suffix before publishing." + exit 1 + fi - publish-testpypi: - needs: build - if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' - runs-on: ubuntu-latest - environment: testpypi - permissions: - id-token: write - steps: - - uses: actions/download-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - publish-pypi: + publish: needs: build - if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') runs-on: ubuntu-latest environment: pypi permissions: @@ -129,9 +117,8 @@ jobs: path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1 - github-release: - needs: publish-pypi - if: startsWith(github.ref, 'refs/tags/v') + attach-assets: + needs: publish runs-on: ubuntu-latest permissions: contents: write @@ -140,7 +127,7 @@ jobs: with: name: dist path: dist/ - - uses: softprops/action-gh-release@v2 - with: - files: dist/* - generate_release_notes: true + - name: Upload wheel + sdist to the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" dist/* --clobber diff --git a/README.md b/README.md index e3b4c41..437bce1 100644 --- a/README.md +++ b/README.md @@ -127,35 +127,64 @@ forge ## Release to PyPI (GitHub Actions) -Releases are automated via `.github/workflows/release.yml` using PyPI +Two separate workflows, each using PyPI [Trusted Publishers](https://docs.pypi.org/trusted-publishers/) (OIDC). No API tokens stored anywhere. +| Workflow | File | Trigger | Target | +|---|---|---|---| +| Release (PyPI) | `release.yml` | GitHub Release published | PyPI (wheel also attached to the Release) | +| Release (TestPyPI) | `release-testpypi.yml` | manual dispatch | TestPyPI (dry-run) | + **One-time setup:** 1. On → *Manage → Publishing → Add a pending publisher*: - Owner / Repository: your GitHub repo - Workflow file: `release.yml` - Environment: `pypi` -2. Same on with environment `testpypi`. +2. Same on : + - Workflow file: `release-testpypi.yml` + - Environment: `testpypi` 3. GitHub repo → *Settings → Environments* → create `pypi` and `testpypi` (optionally require a manual approval on `pypi`). -**Cut a release:** +**Cut a real release:** -```bash -# Bump version in pyproject.toml, then: -git commit -am "Release 0.1.2" -git tag v0.1.2 -git push origin main v0.1.2 +1. Bump `version` in `pyproject.toml`, commit, push. +2. On GitHub → *Releases → Draft a new release* (or `gh release create v0.1.2 --generate-notes`). +3. Click *Publish release*. + +The workflow fires on the `release: published` event. It builds the +wheel, publishes to PyPI, and uploads the wheel + sdist as assets on +the release. A tag/pyproject version mismatch fails the build; a +pre-release version (e.g. `0.1.2.dev1`) is also rejected here — those +go through the TestPyPI workflow instead. + +**Dry-run to TestPyPI:** + +GitHub repo → *Actions → Release (TestPyPI) → Run workflow*: + +1. **Use workflow from:** pick any branch. +2. **Pre-release suffix:** type `.dev1`, `a1`, `b2`, `rc1`, etc. + +The workflow combines the base version from `pyproject.toml` on that +branch with your suffix at runtime — no commit needed, no pyproject +edit, nothing polluting git history. + +``` +pyproject says: 0.1.2 + suffix .dev1 → uploads 0.1.2.dev1 +pyproject says: 0.1.2.dev5 + suffix rc1 → uploads 0.1.2rc1 + (any existing pre-release is stripped first) ``` -The workflow builds the wheel, publishes to PyPI, and creates a GitHub -Release with the artifacts attached. A tag/pyproject version mismatch -fails the build before anything ships. +Every dispatch needs a new suffix since TestPyPI versions are +immutable. When you're ready for a real release, make sure +`pyproject.toml` is on a clean version (e.g. `0.1.2`), publish a +GitHub Release with tag `v0.1.2` — the PyPI workflow takes it from +there and rejects any pre-release version symmetrically. -For a dry-run to TestPyPI, use *Actions → Release → Run workflow → -target: testpypi*. +`pipx install forge-agent` skips pre-releases by default, so end users +on real PyPI never pull a `.dev` wheel. ## Project layout