Auto-release #1356
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: 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 |