From a8da648a1fcf9f7573c571c2bec2f2ae9fe4f835 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:58:18 +0000 Subject: [PATCH] ci: release on a seven-day cadence Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/auto-merge-release.yml | 92 ++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/auto-merge-release.yml diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml new file mode 100644 index 0000000..1eca900 --- /dev/null +++ b/.github/workflows/auto-merge-release.yml @@ -0,0 +1,92 @@ +name: auto-merge-release + +# Releases demand on a cadence rather than on every merge: once a day this looks +# for release-plz's open release PR and merges it, but only if the last release +# is at least seven days old and something user-visible has landed since. +# +# Merging that PR is what triggers release-plz.yml's release job, so this is the +# whole release trigger — nothing else merges it. + +on: + schedule: + - cron: "0 10 * * *" + workflow_dispatch: + +permissions: {} + +concurrency: + group: auto-merge-release + cancel-in-progress: false + +jobs: + merge: + if: github.repository == 'jdx/demand' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Merge the release PR + env: + GH_TOKEN: ${{ secrets.RELEASE_PLZ_TOKEN }} + run: | + set -euo pipefail + + if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then + latest_tag=$(git describe --tags --match 'v[0-9]*' --abbrev=0 || true) + if [ -n "$latest_tag" ]; then + range="${latest_tag}..HEAD" + tag_time=$(git for-each-ref --format='%(creatordate:unix)' "refs/tags/$latest_tag") + now=$(date +%s) + min_age=$((7 * 24 * 60 * 60)) + age=$((now - tag_time)) + if [ "$age" -lt "$min_age" ]; then + echo "Latest release ${latest_tag} is less than 7 days old; not auto-merging release PRs" + exit 0 + fi + else + # Nothing to measure a cadence against yet — let the first + # release through rather than deadlocking on a missing tag. + echo "No v[0-9]* release tag found; allowing bootstrap release" + range="" + fi + # Docs, CI and chore commits do not move a version anyone installs. + if [ -n "$range" ]; then + commit_subjects=$(git log --format=%s "$range") + if ! grep -Eq '^(fix|feat)(\([^)]+\))?!?: ' <<<"$commit_subjects"; then + echo "No pending fix/feat commits since the latest release; not auto-merging release PRs" + exit 0 + fi + fi + else + echo "Manual dispatch; skipping release cadence guards" + fi + + # release-plz opens its PR from `release-plz-`. Match on that + # prefix and on the PR coming from this repository, so a fork branch + # of the same name can never be what gets merged. release-plz titles + # the PR `chore(): release v` when one crate is + # released and `chore: release` when several are, so accept both. + PR_NUMBER=$(gh pr list -R jdx/demand --base main --state open --limit 100 --json number,headRefName,headRepositoryOwner,title,updatedAt \ + --jq '[.[] | select(.headRefName | startswith("release-plz-")) | select(.headRepositoryOwner.login == "jdx") | select(.title | test("^chore(\\([^)]+\\))?: release( |$)"))] | sort_by(.updatedAt) | reverse | .[0].number // empty') + if [ -z "$PR_NUMBER" ]; then + echo "No open release-plz release PR to main" + exit 0 + fi + + # An empty body means release-plz was still writing the PR when this + # ran. The changelog would be wrong; wait for tomorrow instead. + BODY=$(gh pr view "$PR_NUMBER" -R jdx/demand --json body --jq '.body // ""') + TRIMMED_BODY=$(echo "$BODY" | tr -d '[:space:]') + if [ -z "$TRIMMED_BODY" ]; then + echo "Skipping merge: PR #$PR_NUMBER has an empty body; will retry on the next scheduled run" + exit 0 + fi + + echo "PR #$PR_NUMBER is ready for the daily release window" + gh pr merge "$PR_NUMBER" -R jdx/demand --squash --auto