Skip to content

Auto-release

Auto-release #1356

Workflow file for this run

name: Auto-release
# Triggers after CI passes on main — bumps patch version and publishes
# Only releases when meaningful source/test/template files changed.
#
# Safety note (zizmor dangerous-triggers): this workflow is intentionally
# `workflow_run`-driven. It runs in the context of the canonical repo, only
# fires on the `main` branch (fork PRs are filtered out by the
# `branches: [main]` selector), and never checks out untrusted PR head code
# — it only reads commit metadata via `gh api` and tags releases from main.
# No fork-controlled inputs reach a `run:` script.
on: # zizmor: ignore[dangerous-triggers]
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
concurrency:
group: auto-release-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
permissions: {}
jobs:
# Sibling alert job — fires when the triggering CI run did NOT succeed
# (cancelled / failure / timed_out / action_required). Without this,
# auto-release silently skipped the v2.0.0 publish because the gate
# job below short-circuits on anything other than 'success' (#1273).
# 'skipped' is intentional and stays quiet.
alert-on-stale-release-trigger:
name: Alert on stale release trigger
runs-on: ubuntu-latest
timeout-minutes: 5
# `cancelled` excluded: usually means superseded by concurrency
# or operator-cancelled, not a real failure worth alerting.
if: >-
contains(fromJSON('["failure","timed_out","action_required"]'),
github.event.workflow_run.conclusion)
permissions:
contents: read
issues: write
steps:
- name: Harden runner (audit mode)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Log the skipped trigger
env:
STATUS: ${{ github.event.workflow_run.conclusion }}
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
echo "::error::release skipped — CI conclusion was ${STATUS} on ${SHA}"
- name: Telegram alert
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
STATUS: ${{ github.event.workflow_run.conclusion }}
BRANCH: ${{ github.event.workflow_run.head_branch }}
SHA: ${{ github.event.workflow_run.head_sha }}
HTML_URL: ${{ github.event.workflow_run.html_url }}
REPO: ${{ github.repository }}
run: |
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
echo "Telegram not configured; skipping."
exit 0
fi
SHORT_SHA="${SHA:0:7}"
TEXT="📦 auto-release skipped on ${BRANCH}
CI conclusion: ${STATUS}
Commit: ${SHORT_SHA}
${HTML_URL}"
KEYBOARD='{"inline_keyboard":[[{"text":"🔄 View Run","url":"'"${HTML_URL}"'"}]]}'
curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "{\"chat_id\":\"${TELEGRAM_CHAT_ID}\",\"text\":\"${TEXT}\",\"reply_markup\":${KEYBOARD}}" \
|| echo "Telegram notification failed"
- name: Open or update tracking issue
env:
GH_TOKEN: ${{ github.token }}
STATUS: ${{ github.event.workflow_run.conclusion }}
SHA: ${{ github.event.workflow_run.head_sha }}
HTML_URL: ${{ github.event.workflow_run.html_url }}
REPO: ${{ github.repository }}
run: |
SHORT_SHA="${SHA:0:7}"
TITLE="auto-release skipped on \`${SHORT_SHA}\` (conclusion: ${STATUS})"
BODY=$(printf 'CI conclusion: **%s**\n\nCommit: %s\nRun: %s\n\nThis issue was opened automatically by `auto-release.yml :: alert-on-stale-release-trigger`. See #1273 for the root cause.\n' \
"${STATUS}" "${SHA}" "${HTML_URL}")
# Skip the alert when the failing commit is no longer at main HEAD.
# Rapid-merge series produce a cascade of cancelled CI runs as each
# push supersedes the previous; opening one issue per supersede is
# pure noise, since main has already moved past the failure and the
# next CI run will (in)validate the release pipeline on its own.
MAIN_SHA=$(gh api "repos/${REPO}/commits/main" --jq .sha)
if [ "${SHA}" != "${MAIN_SHA}" ]; then
echo "::notice::commit ${SHORT_SHA} is no longer main HEAD (${MAIN_SHA:0:7}); skipping issue creation. Closing any prior open issue for this SHA."
STALE=$(gh issue list --repo "${REPO}" --state open \
--search "in:title auto-release skipped ${SHORT_SHA}" \
--json number,title \
--jq ".[] | select(.title == \"${TITLE}\") | .number" \
| head -1)
if [ -n "${STALE}" ]; then
gh issue close "${STALE}" --repo "${REPO}" \
--comment "Auto-closing — commit ${SHORT_SHA} is no longer main HEAD; main has moved to \`${MAIN_SHA:0:7}\`. The cancelled / failed CI was superseded by a later push, and the alert is no longer actionable."
fi
exit 0
fi
# Idempotent layer 1: find an existing open issue with the same
# title (same SHA + conclusion) and just append a comment
# instead of opening duplicates.
EXISTING=$(gh issue list --repo "${REPO}" --state open \
--search "in:title auto-release skipped ${SHORT_SHA}" \
--json number,title \
--jq ".[] | select(.title == \"${TITLE}\") | .number" \
| head -1)
if [ -n "${EXISTING}" ]; then
gh issue comment "${EXISTING}" --repo "${REPO}" \
--body "Repeat trigger - same commit, same conclusion. Run: ${HTML_URL}"
exit 0
fi
# Idempotent layer 2 (24h cross-SHA dedup): if any open
# auto-release-skipped issue exists from the last 24 hours
# (regardless of SHA), comment on the most recent one and exit
# rather than opening yet another duplicate. Recursive lint
# drift hotfix series produce a cascade of different SHAs in
# quick succession; one tracking issue per 24h window is
# enough signal. The sweep-stale-alerts-on-success job below
# closes the survivors once main goes green.
CUTOFF=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \
|| python3 -c 'import datetime; print((datetime.datetime.utcnow() - datetime.timedelta(hours=24)).strftime("%Y-%m-%dT%H:%M:%SZ"))')
RECENT=$(gh issue list --repo "${REPO}" --state open \
--search "in:title \"auto-release skipped\"" \
--json number,title,createdAt \
--jq "[.[] | select(.createdAt >= \"${CUTOFF}\")] | sort_by(.createdAt) | reverse | .[0].number // empty")
if [ -n "${RECENT}" ]; then
gh issue comment "${RECENT}" --repo "${REPO}" \
--body "Additional auto-release skip on \`${SHORT_SHA}\` (conclusion: ${STATUS}) within the 24h dedup window. Run: ${HTML_URL}"
exit 0
fi
gh issue create --repo "${REPO}" \
--title "${TITLE}" \
--body "${BODY}" \
--label "ci,release-drift" || \
gh issue create --repo "${REPO}" \
--title "${TITLE}" \
--body "${BODY}"
# Sweep job - closes any open auto-release-skipped tracking issues
# opened by the alert job once a CI run on main concludes successfully.
# Without this, the alert issues accumulate forever even after main
# has been repaired by the next hotfix, leaving the operator with a
# noisy issue list that no longer reflects reality.
sweep-stale-alerts-on-success:
name: Close auto-release-skipped issues on green main
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event.workflow_run.conclusion == 'success'
permissions:
contents: read
issues: write
steps:
- name: Harden runner (audit mode)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Close stale auto-release-skipped issues
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
NEW_SHA: ${{ github.event.workflow_run.head_sha }}
HTML_URL: ${{ github.event.workflow_run.html_url }}
run: |
SHORT_NEW="${NEW_SHA:0:7}"
# Match issues opened by the alert job above. Author scope is
# restricted to the github-actions bot so we never close a
# human-filed issue that happens to share the title prefix.
NUMBERS=$(gh issue list --repo "${REPO}" --state open \
--search 'in:title "auto-release skipped" author:app/github-actions' \
--limit 100 \
--json number \
--jq '.[].number')
if [ -z "${NUMBERS}" ]; then
echo "No open auto-release-skipped alert issues to close."
exit 0
fi
echo "Closing stale alert issues superseded by ${SHORT_NEW}:"
echo "${NUMBERS}"
for N in ${NUMBERS}; do
gh issue close "${N}" --repo "${REPO}" --reason completed \
--comment "Superseded by successful release run on \`${SHORT_NEW}\`. Run: ${HTML_URL}" \
|| echo "::warning::failed to close #${N}"
done
gate:
name: Release gate
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 5
if: github.event.workflow_run.conclusion == 'success'
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
steps:
- name: Harden runner (audit mode)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Check for meaningful changes
id: decide
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
# Skip bot commits (prevents release loop)
AUTHOR=$(gh api "repos/${REPO}/commits/${HEAD_SHA}" \
--jq '.author.login // ""')
if [ "$AUTHOR" = "bernstein-orchestrator[bot]" ]; then
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "Skipping: bot commit"
exit 0
fi
# Find latest release tag
PREV_TAG=$(gh api "repos/${REPO}/releases/latest" \
--jq '.tag_name' 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "No previous release — proceeding"
exit 0
fi
# Count changed files that matter for a release:
# src/ — Python source code
# tests/ — test suite
# templates/ — role/prompt templates
# scripts/ — build/test scripts
# packaging/ — npm/docker packaging
# infra/ — infrastructure configs
# sdk/ — SDK code
# services/ — service code
# Dockerfile* — container builds
# requirements*— dependency pins
# pyproject.toml — version, deps, config
CHANGED=$(gh api "repos/${REPO}/compare/${PREV_TAG}...${HEAD_SHA}" \
--jq '[.files[].filename | select(
test("^src/") or
test("^tests/") or
test("^templates/") or
test("^scripts/") or
test("^packaging/") or
test("^infra/") or
test("^sdk/") or
test("^services/") or
test("^Dockerfile") or
test("^requirements") or
test("^pyproject\\.toml$")
)] | length')
echo "Meaningful files changed since $PREV_TAG: ${CHANGED:-0}"
if [ "${CHANGED:-0}" = "0" ]; then
echo "should_release=false" >> "$GITHUB_OUTPUT"
echo "No source/test/template changes — skipping release"
else
echo "should_release=true" >> "$GITHUB_OUTPUT"
fi
release:
name: Tag and create GitHub Release
needs: gate
if: needs.gate.outputs.should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
# Distribution is split across two workflows that both fire from this job:
# - `publish.yml` — runs on tag push; owns PyPI, npm, GitHub Release
# - `publish-docker.yml` — runs on `release: published`; owns GHCR image
# The tag push emitted by the `Tag` step below triggers `publish.yml`,
# which then creates the GitHub Release that triggers `publish-docker.yml`.
# This job's responsibility is now narrowed to:
# 1. Decide whether to tag (gate already passed),
# 2. Push the tag,
# 3. Author rich, conventional-commit-grouped release notes for the
# tag. `publish.yml::github-release` is idempotent and tolerates
# a pre-existing release, so the notes authored here win.
permissions:
contents: write
steps:
- name: Harden runner (audit mode)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
# persist-credentials kept: this job pushes the release tag back to git.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 # zizmor: ignore[artipacked]
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
# Read current version
- name: Get version
id: ver
run: |
VERSION=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "current=$VERSION" >> "$GITHUB_OUTPUT"
# Skip if this version is already tagged
- name: Check tag
id: check
env:
CURRENT_VERSION: ${{ steps.ver.outputs.current }}
run: |
if git tag -l "v${CURRENT_VERSION}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
# Tag-already-exists path: no-op success, not auto-bump.
#
# Previous logic tried to open an auto-merge bump PR via `gh pr create`.
# The `sipyourdrink-ltd` org blocks GitHub Actions from creating pull
# requests (`GraphQL: GitHub Actions is not permitted to create or
# approve pull requests`), so every CI run on main after a release tag
# has shipped failed at this step — painting the pypi deployment red
# weekly even when nothing was wrong with the code.
#
# New behaviour: when the current pyproject.toml version is already
# tagged, exit cleanly with a notice. The operator can ship the next
# release by manually bumping `pyproject.toml` in any PR (the bump
# commit itself triggers a fresh CI → auto-release run that finds an
# untagged version and proceeds to tag and hand off to publish.yml).
- name: No-op when current version is already tagged
if: steps.check.outputs.exists == 'true'
id: bump
env:
CURRENT_VERSION: ${{ steps.ver.outputs.current }}
run: |
IFS='.' read -r MAJOR MINOR PATCH <<< "${CURRENT_VERSION}"
NEXT="${MAJOR}.${MINOR}.$((PATCH + 1))"
echo "::notice::v${CURRENT_VERSION} is already tagged. Skipping auto-release. To ship the next patch, manually bump pyproject.toml to v${NEXT} (or higher) in any PR — auto-release will pick it up on merge."
echo "version=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT"
echo "deferred=true" >> "$GITHUB_OUTPUT"
# Final version (bumped or current). When the bump was deferred to a
# PR, every subsequent step short-circuits via the ``deferred`` output
# below — the actual tag will happen on the next workflow run after
# the PR's CI passes and auto-merge fires.
- name: Set version
id: final
env:
BUMP_DEFERRED: ${{ steps.bump.outputs.deferred }}
BUMP_VERSION: ${{ steps.bump.outputs.version }}
CHECK_EXISTS: ${{ steps.check.outputs.exists }}
CURRENT_VERSION: ${{ steps.ver.outputs.current }}
run: |
if [ "${BUMP_DEFERRED}" = "true" ]; then
echo "::notice::Auto-release deferred — bump PR for v${BUMP_VERSION} will trigger the publish run on merge."
echo "deferred=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "deferred=false" >> "$GITHUB_OUTPUT"
if [ "${CHECK_EXISTS}" = "true" ]; then
echo "version=${BUMP_VERSION}" >> "$GITHUB_OUTPUT"
else
echo "version=${CURRENT_VERSION}" >> "$GITHUB_OUTPUT"
fi
# Push the tag. `publish.yml` is triggered by `push: tags: v*` and
# owns Build / Sigstore attestation / PyPI / npm / GitHub Release.
# GHCR Docker is owned by `publish-docker.yml`, which fires on the
# `release: published` event emitted by `publish.yml::github-release`.
- name: Tag
if: steps.final.outputs.deferred != 'true'
env:
FINAL_VERSION: ${{ steps.final.outputs.version }}
run: |
git tag "v${FINAL_VERSION}"
git push origin "v${FINAL_VERSION}"
- name: GitHub Release
if: steps.final.outputs.deferred != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.final.outputs.version }}
REPO: ${{ github.repository }}
run: |
# Delete any pre-existing *draft* release with the same tag
# (release-drafter often has one queued). Skip if it's a real
# published release — never touch cleanup-tag on a real tag.
IS_DRAFT=$(gh release view "v${VERSION}" --json isDraft --jq '.isDraft' 2>/dev/null || echo "none")
if [ "$IS_DRAFT" = "true" ]; then
gh release delete "v${VERSION}" --yes
fi
# Build human-readable notes from conventional-commit messages
# since the previous release tag. Commits are grouped by prefix
# (feat/fix/refactor/perf/docs/ci/chore/test); their subject
# lines — not raw hashes — become bullet points.
PREV_TAG=$(git describe --tags --abbrev=0 "HEAD^" 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
git log --no-merges --pretty='%s' -20 > /tmp/commits.txt
else
git log --no-merges --pretty='%s' "${PREV_TAG}..HEAD" > /tmp/commits.txt
fi
python3 scripts/format_release_notes.py \
--version "$VERSION" \
--prev-tag "$PREV_TAG" \
--repo "$REPO" \
--commits /tmp/commits.txt \
> RELEASE_NOTES.md
# Create the release without uploading dist artefacts — those are
# uploaded by `publish.yml::github-release` once the tag-push
# workflow finishes its build step. If publish.yml ran first
# (race), this `gh release create` will exit non-zero and we
# fall back to editing the existing release's notes.
if ! gh release create "v${VERSION}" \
--title "v${VERSION}" \
--notes-file RELEASE_NOTES.md; then
echo "::notice::Release v${VERSION} already exists (publish.yml beat us). Updating notes only."
gh release edit "v${VERSION}" --notes-file RELEASE_NOTES.md
fi