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