Skip to content
Merged
Show file tree
Hide file tree
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
131 changes: 131 additions & 0 deletions .github/workflows/release-testpypi.yml
Original file line number Diff line number Diff line change
@@ -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 base> + <your suffix>
# - 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: <your-gh-username-or-org>
# Repo: <your-repo-name>
# 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/
111 changes: 49 additions & 62 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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: <your-gh-username-or-org>
# - Repo: <your-repo-name>
# - 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: <your-gh-username-or-org>
# Repo: <your-repo-name>
# 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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
55 changes: 42 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://pypi.org> → *Manage → Publishing → Add a pending publisher*:
- Owner / Repository: your GitHub repo
- Workflow file: `release.yml`
- Environment: `pypi`
2. Same on <https://test.pypi.org> with environment `testpypi`.
2. Same on <https://test.pypi.org>:
- 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

Expand Down
Loading