From d4addfe26eeb0244f43e265f265eea477df517c4 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Wed, 21 Oct 2026 15:43:32 +0200 Subject: [PATCH] Cut a patch release on the 1st of each month A scheduled workflow tags the next patch version when main is green, something has merged since the last tag and no pull request labelled breaking was merged. It dispatches release.yml and publish.yml on the new tag, since a tag created with GITHUB_TOKEN doesn't start them. Scheduled runs are dry runs until the MONTHLY_RELEASE variable is true. Closes #532 --- .github/workflows/monthly-release.yml | 109 ++++++++++++++++++++++++++ .github/workflows/release.yml | 3 + CONTRIBUTING.md | 8 ++ 3 files changed, 120 insertions(+) create mode 100644 .github/workflows/monthly-release.yml diff --git a/.github/workflows/monthly-release.yml b/.github/workflows/monthly-release.yml new file mode 100644 index 00000000..6afee9d4 --- /dev/null +++ b/.github/workflows/monthly-release.yml @@ -0,0 +1,109 @@ +name: Monthly patch release + +# Cuts a patch release on the 1st of each month when main is green and +# something has merged since the last release. Scheduled runs only report +# what they would do until the repository variable MONTHLY_RELEASE is "true". + +on: + schedule: + - cron: "0 6 1 * *" + workflow_dispatch: + inputs: + dry_run: + description: Only report what would be released + type: boolean + default: true + +permissions: {} + +concurrency: + group: monthly-release + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + checks: read + pull-requests: read + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Decide whether to release + id: plan + env: + EVENT: ${{ github.event_name }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + ENABLED: ${{ vars.MONTHLY_RELEASE }} + run: | + set -euo pipefail + if [ "$EVENT" = workflow_dispatch ]; then + dry_run=$INPUT_DRY_RUN + elif [ "$ENABLED" = true ]; then + dry_run=false + else + dry_run=true + fi + echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT" + + sha=$(git rev-parse HEAD) + last=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' --exclude '*-*') + merged=$(git rev-list --count "$last..$sha") + if [ "$merged" -eq 0 ]; then + echo "Nothing merged since $last" + exit 0 + fi + + # Every check on main's HEAD must have finished green. This run + # shows up as a check on the same commit, so it is left out. + bad=$(gh api --paginate "repos/$REPO/commits/$sha/check-runs?per_page=100" \ + --jq ".check_runs[] + | select(.html_url | contains(\"/actions/runs/$GITHUB_RUN_ID/\") | not) + | select(.status != \"completed\" + or (.conclusion | IN(\"success\", \"skipped\", \"neutral\") | not)) + | .name") + if [ -n "$bad" ]; then + echo "::warning::main ($sha) is not green, skipping: $(echo "$bad" | sort -u | paste -sd, -)" + exit 0 + fi + + since=$(git log -1 --format=%cI "$last") + breaking=$(gh pr list -R "$REPO" --state merged --base main \ + --search "merged:>$since label:breaking" --json number --jq '.[].number') + if [ -n "$breaking" ]; then + echo "::warning::breaking changes merged since $last (#$(echo "$breaking" | paste -sd, - | sed 's/,/, #/g')), release a new minor version by hand" + exit 0 + fi + + IFS=. read -r major minor patch <<<"${last#v}" + tag="v$major.$minor.$((patch + 1))" + echo "$merged commits since $last, next release $tag at $sha" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + # A tag pushed with GITHUB_TOKEN doesn't start other workflows, so the + # release workflows are dispatched on the new tag instead. + - name: Tag and start the release workflows + if: steps.plan.outputs.tag != '' + env: + TAG: ${{ steps.plan.outputs.tag }} + SHA: ${{ steps.plan.outputs.sha }} + DRY_RUN: ${{ steps.plan.outputs.dry_run }} + run: | + set -euo pipefail + if [ "$DRY_RUN" = true ]; then + echo "Dry run: would tag $TAG at $SHA and start release.yml and publish.yml" + exit 0 + fi + gh api "repos/$REPO/git/refs" -f ref="refs/tags/$TAG" -f sha="$SHA" >/dev/null + gh workflow run release.yml -R "$REPO" --ref "$TAG" + gh workflow run publish.yml -R "$REPO" --ref "$TAG" + echo "Tagged $TAG at $SHA" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d321813..dddfef01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,8 @@ on: push: tags: - "v*" + # Started by monthly-release.yml on the tag it creates. + workflow_dispatch: permissions: contents: write @@ -11,6 +13,7 @@ permissions: jobs: release: + if: github.ref_type == 'tag' runs-on: ubuntu-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c697283..f6802514 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -212,6 +212,14 @@ Add PyPI registry support - Add tests for wheel and sdist downloads ``` +## Releases + +A patch release is cut on the 1st of each month by `.github/workflows/monthly-release.yml` when `main` is green and something has merged since the last tag. It is skipped if a pull request labelled `breaking` was merged since then: label pull requests that change behaviour users rely on, and release those as a new minor version by hand. + +Scheduled runs only report what they would release until the repository variable `MONTHLY_RELEASE` is set to `true`. The workflow can also be started by hand from the Actions tab, as a dry run by default. + +To release by hand, push a `v*` tag. + ## Questions? Open an issue on GitHub if you have questions or need help getting started.