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
22 changes: 22 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
changelog:
exclude:
labels:
- skip-changelog
categories:
- title: Breaking changes
labels:
- breaking-change
- title: Features
labels:
- enhancement
- feature
- title: Fixes
labels:
- bug
- fix
- title: Documentation
labels:
- documentation
- title: Other merged changes
labels:
- "*"
188 changes: 181 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,28 +1,171 @@
name: Release

on:
release:
types: [published]
workflow_dispatch:
inputs:
bump:
description: Semantic version component to increment
required: true
default: patch
type: choice
options:
- patch
- minor
- major

permissions:
contents: read

concurrency:
group: release
cancel-in-progress: false

jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
outputs:
published: ${{ steps.release.outputs.published }}
sha: ${{ steps.release.outputs.sha }}
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.sha }}
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Create and merge version bump
id: release
env:
BUMP: ${{ inputs.bump }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ "$GITHUB_REF_NAME" != "$DEFAULT_BRANCH" ]]; then
echo "Release must be dispatched from $DEFAULT_BRANCH, not $GITHUB_REF_NAME" >&2
exit 1
fi

current_version="$(python scripts/bump_version.py current)"
version="$(python scripts/bump_version.py "$BUMP")"
tag="v$version"
git fetch origin "$DEFAULT_BRANCH" --tags

if sha="$(git rev-list -n 1 "$tag" 2>/dev/null)" && [[ -n "$sha" ]]; then
echo "Reusing existing tag $tag at $sha"
else
git show "origin/$DEFAULT_BRANCH:pyproject.toml" > "$RUNNER_TEMP/main-pyproject.toml"
main_version="$(python scripts/bump_version.py current --pyproject "$RUNNER_TEMP/main-pyproject.toml")"
if [[ "$main_version" == "$version" ]]; then
sha="$(git rev-parse "origin/$DEFAULT_BRANCH")"
echo "Reusing version bump already merged at $sha"
elif [[ "$main_version" != "$current_version" ]]; then
echo "main moved from version $current_version to $main_version; dispatch again" >&2
exit 1
else
branch="release/$tag-$GITHUB_RUN_ID"
git switch --create "$branch" "origin/$DEFAULT_BRANCH"
written_version="$(python scripts/bump_version.py "$BUMP" --write)"
[[ "$written_version" == "$version" ]]
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add pyproject.toml
git commit -m "Release $tag"
head_sha="$(git rev-parse HEAD)"
git push --set-upstream origin "$branch"

gh label create skip-changelog \
--description "Exclude this PR from generated release notes" \
--color "ededed" \
--force
pr_url="$(gh pr create \
--base "$DEFAULT_BRANCH" \
--head "$branch" \
--title "Release $tag" \
--body "Automated **$BUMP** version bump from $current_version to $version.")"
pr_number="${pr_url##*/}"
gh pr edit "$pr_number" --add-label skip-changelog
mergeable=UNKNOWN
for _attempt in {1..30}; do
mergeable="$(gh pr view "$pr_number" --json mergeable --jq ".mergeable")"
[[ "$mergeable" == "MERGEABLE" ]] && break
if [[ "$mergeable" == "CONFLICTING" ]]; then
echo "Release PR $pr_number conflicts with $DEFAULT_BRANCH" >&2
exit 1
fi
sleep 2
done
if [[ "$mergeable" != "MERGEABLE" ]]; then
echo "Release PR $pr_number did not become mergeable" >&2
exit 1
fi
gh pr merge "$pr_number" \
--squash \
--delete-branch \
--match-head-commit "$head_sha" \
--subject "Release $tag" \
--body "Automated semantic version bump."
sha="$(gh pr view "$pr_number" --json mergeCommit --jq ".mergeCommit.oid")"
fi
fi

pypi_status="$(curl \
--silent \
--output /dev/null \
--write-out '%{http_code}' \
--retry 3 \
--retry-all-errors \
"https://pypi.org/pypi/pineforge-data/$version/json")"
case "$pypi_status" in
200) published=true ;;
404) published=false ;;
*)
echo "Unexpected PyPI status $pypi_status while checking $version" >&2
exit 1
;;
esac

{
echo "published=$published"
echo "sha=$sha"
echo "tag=$tag"
echo "version=$version"
} >> "$GITHUB_OUTPUT"
printf "Prepared %s at %s (already on PyPI: %s).\n" \
"$tag" "$sha" "$published" >> "$GITHUB_STEP_SUMMARY"

build:
if: needs.prepare.outputs.published != 'true'
needs: prepare
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.release.tag_name }}
ref: ${{ needs.prepare.outputs.sha }}
persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: pip
- run: python -m pip install --upgrade pip
- run: python -m pip install '.[release]'
- name: Verify release tag
run: python scripts/verify_release.py "${{ github.event.release.tag_name }}"
- run: python -m pip install -e '.[dev,ccxt,release]'
- name: Verify release tag and source
run: python scripts/verify_release.py "${{ needs.prepare.outputs.tag }}"
- name: Run static and unit checks
run: |
ruff check .
mypy src
pytest
- name: Run Docker integration checks
run: PINEFORGE_DOCKER_TEST=1 pytest tests/test_docker_integration.py
- name: Build wheel and source distribution
run: python -m build
- name: Check distribution metadata
Expand All @@ -39,8 +182,39 @@ jobs:
path: dist/
if-no-files-found: error

github-release:
if: needs.prepare.outputs.published != 'true'
needs:
- prepare
- build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Create GitHub Release with generated notes
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SHA: ${{ needs.prepare.outputs.sha }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
if gh release view "$TAG" >/dev/null 2>&1; then
echo "GitHub Release $TAG already exists"
else
gh release create "$TAG" \
--target "$SHA" \
--title "pineforge-data $TAG" \
--generate-notes \
--latest
fi

publish:
needs: build
if: needs.prepare.outputs.published != 'true'
needs:
- prepare
- build
- github-release
runs-on: ubuntu-latest
environment:
name: pypi
Expand Down
53 changes: 39 additions & 14 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ the `main` branch to `https://pineforge-4pass.github.io/pineforge-data/`.

## Release checklist

1. Update `project.version` in `pyproject.toml` using a valid Python package
version.
2. Update user-facing documentation and merge the release changes to `main`.
3. Run the complete local checks:
1. Merge every intended code and documentation change to `main`.
2. Run the complete local checks when preparing substantial release changes:

```bash
python -m pip install -e '.[dev,ccxt,database,server,docs,release]'
Expand All @@ -43,20 +41,47 @@ the `main` branch to `https://pineforge-4pass.github.io/pineforge-data/`.
python -m twine check dist/*
```

4. Create a GitHub Release whose tag is exactly `v<project.version>`, such as
`v0.1.0`.
5. Review the generated wheel and source-distribution artifact, then approve
the `pypi` environment deployment.
6. Confirm the version and project links on PyPI and install it into a clean
3. Open the repository's **Actions** tab, select **Release**, and choose
**Run workflow** from `main`.
4. Select the semantic version component:

| Choice | Example |
|---|---|
| `patch` | `1.2.3` → `1.2.4` |
| `minor` | `1.2.3` → `1.3.0` |
| `major` | `1.2.3` → `2.0.0` |

5. The workflow creates and squash-merges a version-only PR, validates the
merged source, and creates the matching `v<version>` GitHub Release.
6. Review the generated wheel, source distribution, and release notes, then
approve the protected `pypi` environment deployment.
7. Confirm the version and project links on PyPI and install it into a clean
environment.

## Workflow safeguards

The release workflow runs only for a published GitHub Release. Before upload,
it verifies that the tag exactly matches `v` plus `project.version`, builds the
wheel and source archive once, validates their long-description metadata, and
installs the wheel in a clean virtual environment. The publish job receives
only those validated artifacts and has only `id-token: write` permission.
The release workflow is manually dispatched and serialized so two version
bumps cannot race. It refuses dispatches from branches other than `main`. Since
the organization requires changes to enter `main` through a pull request, the
workflow creates a dedicated release PR instead of pushing directly.

The generated notes include every merged PR since the previous GitHub Release.
Closed-but-unmerged PRs are intentionally excluded because their changes were
not shipped. The mechanical version-bump PR carries `skip-changelog` and is
also excluded. Labels group breaking changes, features, fixes, documentation,
and all other merged work.

Before upload, the workflow verifies that the prospective tag exactly matches
`v` plus `project.version`, runs static checks, unit tests, and Docker
integration tests, builds the wheel and source archive once, validates their
long-description metadata, and installs the wheel in a clean virtual
environment. Only then does it create the GitHub Release and request approval
for PyPI. The publish job receives the validated artifacts and has only
`id-token: write` permission.

Reruns reuse an already merged bump or tag. If the version is already present
on PyPI, build and publication jobs are skipped instead of attempting a
duplicate immutable upload.

PyPI versions are immutable. If a release is wrong, increment the package
version and publish a correction; do not attempt to replace an existing file.
Loading