Prepare Release #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |