Skip to content

Prepare Release

Prepare Release #3

name: Prepare Release
on:
workflow_dispatch:
inputs:
module:
description: 'Module to release (must match a key in .github/modules.json)'
required: true
type: string
version:
description: 'Release version (e.g., 1.3.0 or 1.3.0-beta.1)'
required: true
type: string
permissions:
contents: write
pull-requests: write
concurrency:
group: prepare-release-${{ inputs.module }}
cancel-in-progress: false
jobs:
prepare:
name: "Prepare ${{ inputs.module }} ${{ inputs.version }}"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Validate inputs
env:
MODULE: ${{ inputs.module }}
VERSION: ${{ inputs.version }}
run: |
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Invalid version format: $VERSION"
exit 1
fi
jq -e --arg m "$MODULE" '.[$m]' .github/modules.json > /dev/null || {
echo "::error::Unknown module '$MODULE'. Valid modules: $(jq -r 'keys | join(", ")' .github/modules.json)"
exit 1
}
- name: Resolve module config
id: config
env:
MODULE: ${{ inputs.module }}
VERSION: ${{ inputs.version }}
run: |
MODULE_CONFIG=$(jq -e --arg m "$MODULE" '.[$m]' .github/modules.json)
TAG_PREFIX=$(echo "$MODULE_CONFIG" | jq -r '.tag_prefix')
{
echo "tag=${TAG_PREFIX}${VERSION}"
echo "tag_prefix=${TAG_PREFIX}"
echo "pyproject_toml=$(echo "$MODULE_CONFIG" | jq -r '.pyproject_toml')"
# Newline-separated list of additional Python source files holding the version literal.
echo "version_files<<EOF"
echo "$MODULE_CONFIG" | jq -r '.version_files[]?'
echo "EOF"
echo "changelog=$(echo "$MODULE_CONFIG" | jq -r '.changelog')"
echo "readme=$(echo "$MODULE_CONFIG" | jq -r '.readme')"
echo "package_name=$(echo "$MODULE_CONFIG" | jq -r '.package_name')"
echo "branch=release/${MODULE}/${VERSION}"
} >> "$GITHUB_OUTPUT"
- name: Validate version not already released
env:
TAG: ${{ steps.config.outputs.tag }}
run: |
if git tag -l "$TAG" | grep -q .; then
echo "::error::Tag $TAG already exists"
exit 1
fi
- name: Clean up existing release branch and PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ steps.config.outputs.branch }}
run: |
EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true)
if [[ -n "$EXISTING_PR" ]]; then
echo "Closing existing PR #$EXISTING_PR and deleting branch"
gh pr close "$EXISTING_PR" --delete-branch
elif git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo "Deleting orphaned branch $BRANCH"
git push origin --delete "$BRANCH"
fi
- name: Create release branch
env:
BRANCH: ${{ steps.config.outputs.branch }}
run: git checkout -b "$BRANCH"
- name: Bump version
env:
VERSION: ${{ inputs.version }}
PYPROJECT_TOML: ${{ steps.config.outputs.pyproject_toml }}
VERSION_FILES: ${{ steps.config.outputs.version_files }}
run: |
set -euo pipefail
# If pyproject.toml uses dynamic version, the canonical source lives
# in the listed Python file(s) (e.g. `__version__ = "X.Y.Z"`). Otherwise
# the literal `version = "X.Y.Z"` line in pyproject.toml is the source.
IS_DYNAMIC=$(python3 - <<PY
import sys
try:
import tomllib # py311+
except ModuleNotFoundError:
import tomli as tomllib
with open("${PYPROJECT_TOML}", "rb") as f:
data = tomllib.load(f)
project = data.get("project", {})
dynamic = project.get("dynamic", []) or []
print("yes" if "version" in dynamic else "no")
PY
)
if [ "$IS_DYNAMIC" = "yes" ]; then
echo "pyproject ${PYPROJECT_TOML} declares version dynamically; updating version_files."
if [ -z "${VERSION_FILES//[[:space:]]/}" ]; then
echo "::error::Module has dynamic version but no version_files entry in modules.json"
exit 1
fi
while IFS= read -r vf; do
[ -z "$vf" ] && continue
if [ ! -f "$vf" ]; then
echo "::error::version_files entry not found: $vf"
exit 1
fi
# Match either `__version__ = "X"` or `__version__ = 'X'`.
if ! grep -E "^__version__\s*=\s*['\"][^'\"]+['\"]" "$vf" >/dev/null; then
echo "::error::No __version__ literal found in $vf"
exit 1
fi
OLD=$(grep -E "^__version__\s*=\s*['\"][^'\"]+['\"]" "$vf" | head -1 | sed -E "s/.*['\"]([^'\"]+)['\"].*/\1/")
echo "Bumping ${vf}: ${OLD} -> ${VERSION}"
sed -i.bak -E "s|^__version__[[:space:]]*=[[:space:]]*['\"][^'\"]+['\"]|__version__ = \"${VERSION}\"|" "$vf"
rm -f "${vf}.bak"
done <<< "$VERSION_FILES"
else
if ! grep -E "^version[[:space:]]*=[[:space:]]*\"[^\"]+\"" "$PYPROJECT_TOML" >/dev/null; then
echo "::error::No literal version line found in $PYPROJECT_TOML"
exit 1
fi
OLD=$(grep -E "^version[[:space:]]*=[[:space:]]*\"[^\"]+\"" "$PYPROJECT_TOML" | head -1 | sed -E "s/.*\"([^\"]+)\".*/\1/")
echo "Bumping ${PYPROJECT_TOML}: ${OLD} -> ${VERSION}"
sed -i.bak -E "s|^version[[:space:]]*=[[:space:]]*\"[^\"]+\"|version = \"${VERSION}\"|" "$PYPROJECT_TOML"
rm -f "${PYPROJECT_TOML}.bak"
fi
- name: Update README version header
env:
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
TAG: ${{ steps.config.outputs.tag }}
README: ${{ steps.config.outputs.readme }}
run: |
DATE=$(date +"%B %d, %Y")
# The `1,/pat/` address range bounds substitution to the first match
# so older changelog entries inside the README aren't trampled.
sed -i -E \
"1,/^##### _.*_ - \[.*\]\(.*\)\$/ s|^##### _.*_ - \[.*\]\(.*\)\$|##### _${DATE}_ - [${TAG}](${REPO_URL}/releases/tag/${TAG})|" \
"$README"
- name: Generate changelog
env:
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
MODULE: ${{ inputs.module }}
TAG: ${{ steps.config.outputs.tag }}
CHANGELOG_FILE: ${{ steps.config.outputs.changelog }}
run: |
CHANGELOG=$(.github/scripts/generate-changelog.sh \
"$MODULE" "$TAG" "$REPO_URL" HEAD)
if [ -f "$CHANGELOG_FILE" ]; then
{
printf '# Changelog\n\n%s\n' "$CHANGELOG"
sed '1{/^# Changelog$/d;}' "$CHANGELOG_FILE"
} > CHANGELOG.new.md
mv CHANGELOG.new.md "$CHANGELOG_FILE"
else
mkdir -p "$(dirname "$CHANGELOG_FILE")"
printf '# Changelog\n\n%s\n' "$CHANGELOG" > "$CHANGELOG_FILE"
fi
- name: Commit and push
env:
MODULE: ${{ inputs.module }}
VERSION: ${{ inputs.version }}
BRANCH: ${{ steps.config.outputs.branch }}
PYPROJECT_TOML: ${{ steps.config.outputs.pyproject_toml }}
VERSION_FILES: ${{ steps.config.outputs.version_files }}
CHANGELOG_FILE: ${{ steps.config.outputs.changelog }}
README: ${{ steps.config.outputs.readme }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add "$PYPROJECT_TOML" "$CHANGELOG_FILE" "$README"
while IFS= read -r FILE; do
[ -z "$FILE" ] && continue
git add "$FILE"
done <<< "$VERSION_FILES"
git commit -m "release: prepare ${MODULE} ${VERSION}"
git push origin "$BRANCH"
- name: Create pull request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MODULE: ${{ inputs.module }}
VERSION: ${{ inputs.version }}
TAG: ${{ steps.config.outputs.tag }}
TAG_PREFIX: ${{ steps.config.outputs.tag_prefix }}
PYPROJECT_TOML: ${{ steps.config.outputs.pyproject_toml }}
CHANGELOG_FILE: ${{ steps.config.outputs.changelog }}
README: ${{ steps.config.outputs.readme }}
BRANCH: ${{ steps.config.outputs.branch }}
run: |
gh pr create \
--title "release: prepare ${MODULE} ${VERSION}" \
--body "$(cat <<EOF
## Release ${MODULE} ${VERSION}
This PR prepares the ${MODULE} module for release.
### Changes
- Bumps version to \`${VERSION}\` (in \`${PYPROJECT_TOML}\` and/or its referenced version file)
- Updates \`${CHANGELOG_FILE}\` with a new section since the last \`${TAG_PREFIX}*\` tag
- Updates \`${README}\` version header
### After merging
1. Push tag \`${TAG}\` from the merge commit on \`master\` to trigger the publish workflow:
\`\`\`
git checkout master && git pull
git tag ${TAG}
git push origin ${TAG}
\`\`\`
2. The publish workflow creates a draft GitHub release and publishes to PyPI via Trusted Publishing. Review and publish the GitHub release after the workflow finishes.
EOF
)" \
--base master \
--head "$BRANCH"