Skip to content

Release - cut monthly branch #4

Release - cut monthly branch

Release - cut monthly branch #4

Workflow file for this run

name: Release - cut monthly branch
# Cuts a release branch, bumps versions, reshapes CHANGELOG, and opens
# a draft PR. Does NOT publish. Full runbook:
# docs/developer/release-process.md
on:
schedule:
# 14:00 UTC on the 22nd of every month. Day 22 is the earliest
# date guaranteed to fall within the last 10 days of any month,
# which we treat as "the last week" for scheduling purposes.
- cron: '0 14 22 * *'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g. 0.2.0). Blank = auto-bump minor.'
required: false
type: string
default: ''
bump:
description: 'Which part to auto-bump when version is blank'
required: false
type: choice
options:
- minor
- patch
- major
default: minor
dry_run:
description: 'Dry run: print the plan, do not push or open a PR'
required: false
type: boolean
default: false
permissions:
contents: write
pull-requests: write
concurrency:
group: release-cut
cancel-in-progress: false
jobs:
cut:
name: Cut release branch
runs-on: ubuntu-latest
# Guardrail: do not run scheduled cuts on forks. workflow_dispatch
# is allowed on any repo so maintainers can dry-run from a fork.
if: github.event_name != 'schedule' || github.repository == 'sandialabs/atlas-ui-3'
env:
# Prefer a PAT so the branch push and PR creation trigger
# downstream workflows (CI/CD Pipeline, Security Checks). PRs
# opened with the default GITHUB_TOKEN do not trigger other
# workflows, which would leave the release-PR checklist
# permanently blocking on missing required status checks. If
# RELEASE_PAT is unset we fall back to GITHUB_TOKEN and annotate
# the PR body so the release captain knows they must kick CI
# manually. See docs/developer/release-process.md → "Kicking CI
# on the release PR".
RELEASE_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }}
HAS_RELEASE_PAT: ${{ secrets.RELEASE_PAT != '' && 'true' || 'false' }}
steps:
- name: Warn if no PAT is configured
if: env.HAS_RELEASE_PAT != 'true'
run: |
echo "::warning::RELEASE_PAT secret is not configured; falling back to GITHUB_TOKEN."
echo "::warning::The release PR will open, but CI/CD Pipeline and Security Checks will NOT auto-run on it."
echo "::warning::The release captain must kick CI manually — see docs/developer/release-process.md."
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
token: ${{ env.RELEASE_TOKEN }}
- name: Compute release metadata
id: meta
shell: bash
run: |
set -euo pipefail
current="$(grep '^VERSION' atlas/version.py | cut -d'"' -f2)"
if [[ -z "$current" ]]; then
echo "::error::Could not read VERSION from atlas/version.py"
exit 1
fi
IFS='.' read -r cur_major cur_minor cur_patch <<< "$current"
input_version="${{ github.event.inputs.version }}"
bump_kind="${{ github.event.inputs.bump }}"
if [[ -z "$bump_kind" ]]; then
bump_kind="minor"
fi
if [[ -n "$input_version" ]]; then
if ! [[ "$input_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version input must look like X.Y.Z, got '$input_version'"
exit 1
fi
new_version="$input_version"
else
case "$bump_kind" in
major) new_version="$((cur_major + 1)).0.0" ;;
minor) new_version="${cur_major}.$((cur_minor + 1)).0" ;;
patch) new_version="${cur_major}.${cur_minor}.$((cur_patch + 1))" ;;
*) echo "::error::Unknown bump kind '$bump_kind'"; exit 1 ;;
esac
fi
year_month="$(date -u +%Y.%m)"
release_date="$(date -u +%Y-%m-%d)"
release_branch="release/${year_month}"
commit_sha="$(git rev-parse HEAD)"
commit_short="$(git rev-parse --short HEAD)"
{
echo "current_version=$current"
echo "new_version=$new_version"
echo "year_month=$year_month"
echo "release_date=$release_date"
echo "release_branch=$release_branch"
echo "commit_sha=$commit_sha"
echo "commit_short=$commit_short"
} >> "$GITHUB_OUTPUT"
echo "::notice::Planning release ${current} -> ${new_version} on ${release_branch} from ${commit_short}"
- name: Check branch and PR state
id: state
shell: bash
env:
GH_TOKEN: ${{ env.RELEASE_TOKEN }}
BRANCH: ${{ steps.meta.outputs.release_branch }}
run: |
set -euo pipefail
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo "branch_exists=true" >> "$GITHUB_OUTPUT"
pr_count="$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')"
if [[ "$pr_count" == "0" ]]; then
echo "pr_exists=false" >> "$GITHUB_OUTPUT"
echo "::warning::Release branch $BRANCH exists on origin but has no open PR — recovery path will open one."
else
echo "pr_exists=true" >> "$GITHUB_OUTPUT"
echo "::notice::Release branch $BRANCH already exists and has an open PR. This run is a no-op."
fi
else
echo "branch_exists=false" >> "$GITHUB_OUTPUT"
echo "pr_exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Stop early if nothing to do
if: steps.state.outputs.branch_exists == 'true' && steps.state.outputs.pr_exists == 'true'
run: |
echo "Nothing to do: release branch and open PR already exist."
exit 0
# --- Fresh-cut path: branch does not exist ---
- name: Create release branch (fresh cut)
if: steps.state.outputs.branch_exists == 'false'
env:
BRANCH: ${{ steps.meta.outputs.release_branch }}
run: |
set -euo pipefail
git checkout -b "$BRANCH"
- name: Bump atlas/version.py
if: steps.state.outputs.branch_exists == 'false'
env:
OLD_VERSION: ${{ steps.meta.outputs.current_version }}
NEW_VERSION: ${{ steps.meta.outputs.new_version }}
shell: bash
run: |
set -euo pipefail
python - <<'PY'
import os, re, pathlib
path = pathlib.Path("atlas/version.py")
text = path.read_text()
old, new = os.environ["OLD_VERSION"], os.environ["NEW_VERSION"]
replaced, n = re.subn(
rf'^VERSION\s*=\s*"{re.escape(old)}"',
f'VERSION = "{new}"',
text,
flags=re.MULTILINE,
)
if n != 1:
raise SystemExit(
f"Expected exactly 1 VERSION line matching {old!r} in atlas/version.py, found {n}"
)
path.write_text(replaced)
print(f"atlas/version.py: {old} -> {new}")
PY
- name: Bump pyproject.toml
if: steps.state.outputs.branch_exists == 'false'
env:
OLD_VERSION: ${{ steps.meta.outputs.current_version }}
NEW_VERSION: ${{ steps.meta.outputs.new_version }}
shell: bash
run: |
set -euo pipefail
python - <<'PY'
import os, re, pathlib
path = pathlib.Path("pyproject.toml")
text = path.read_text()
old, new = os.environ["OLD_VERSION"], os.environ["NEW_VERSION"]
# Match the first occurrence of `version = "X.Y.Z"` in the
# top-level [project] table; we do not want to touch
# dependency pin strings that happen to match the version.
pattern = re.compile(
rf'(?m)^(?P<prefix>version\s*=\s*")' + re.escape(old) + r'(?P<suffix>")'
)
replaced, n = pattern.subn(lambda m: f'{m.group("prefix")}{new}{m.group("suffix")}', text, count=1)
if n != 1:
raise SystemExit(
f"Expected exactly 1 top-level version={old!r} in pyproject.toml, found {n}"
)
path.write_text(replaced)
print(f"pyproject.toml: {old} -> {new}")
PY
- name: Update CHANGELOG.md
if: steps.state.outputs.branch_exists == 'false'
env:
NEW_VERSION: ${{ steps.meta.outputs.new_version }}
RELEASE_DATE: ${{ steps.meta.outputs.release_date }}
shell: bash
run: |
set -euo pipefail
python - <<'PY'
import os, pathlib, re
path = pathlib.Path("CHANGELOG.md")
text = path.read_text()
new_version = os.environ["NEW_VERSION"]
release_date = os.environ["RELEASE_DATE"]
# Rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and
# insert a fresh empty Unreleased section above it.
# Use [ \t]* rather than \s* so the regex does not consume
# the terminating newline of the heading line; otherwise the
# blank line between the new version heading and the first
# entry would be eaten.
unreleased_re = re.compile(r'^## \[Unreleased\][ \t]*$', re.MULTILINE)
m = unreleased_re.search(text)
if not m:
raise SystemExit(
"CHANGELOG.md is missing a '## [Unreleased]' section; refusing to touch it."
)
new_heading = f"## [{new_version}] - {release_date}"
fresh_unreleased = "## [Unreleased]\n\n"
before = text[:m.start()]
after = text[m.end():]
text_out = before + fresh_unreleased + new_heading + after
path.write_text(text_out)
print(f"CHANGELOG.md: [Unreleased] -> [{new_version}] - {release_date}")
PY
- name: Show diff
if: steps.state.outputs.branch_exists == 'false'
run: |
git --no-pager diff --stat
git --no-pager diff
- name: Dry run stops here
if: steps.state.outputs.branch_exists == 'false' && github.event.inputs.dry_run == 'true'
run: |
echo "::notice::dry_run=true — not committing, not pushing, not opening a PR."
- name: Commit bump
if: steps.state.outputs.branch_exists == 'false' && github.event.inputs.dry_run != 'true'
env:
NEW_VERSION: ${{ steps.meta.outputs.new_version }}
YEAR_MONTH: ${{ steps.meta.outputs.year_month }}
run: |
set -euo pipefail
git config user.name "atlas-release-bot"
git config user.email "atlas-release-bot@users.noreply.github.com"
git add atlas/version.py pyproject.toml CHANGELOG.md
git commit -m "chore(release): cut ${YEAR_MONTH} at v${NEW_VERSION}"
- name: Push release branch
if: steps.state.outputs.branch_exists == 'false' && github.event.inputs.dry_run != 'true'
env:
BRANCH: ${{ steps.meta.outputs.release_branch }}
run: |
set -euo pipefail
git push --set-upstream origin "$BRANCH"
# --- Recovery path: branch already exists but no open PR ---
- name: Check out existing release branch (recovery)
if: steps.state.outputs.branch_exists == 'true' && steps.state.outputs.pr_exists == 'false'
env:
BRANCH: ${{ steps.meta.outputs.release_branch }}
run: |
set -euo pipefail
git fetch origin "$BRANCH"
git checkout "$BRANCH"
- name: Recover metadata from existing branch
if: steps.state.outputs.branch_exists == 'true' && steps.state.outputs.pr_exists == 'false'
id: recover
shell: bash
run: |
set -euo pipefail
python - >> "$GITHUB_OUTPUT" <<'PY'
import pathlib, re, subprocess
def sh(*cmd):
return subprocess.check_output(list(cmd), text=True).strip()
def read_version(blob):
for line in blob.splitlines():
m = re.match(r'^VERSION\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', line)
if m:
return m.group(1)
return ""
new_version = read_version(pathlib.Path("atlas/version.py").read_text())
if not new_version:
raise SystemExit("Could not read VERSION from release branch atlas/version.py")
# Best-effort: the tip of a freshly cut branch IS the bump
# commit, so HEAD~1 carries the pre-bump version.
try:
pre = sh("git", "show", "HEAD~1:atlas/version.py")
old_version = read_version(pre) or "unknown"
except subprocess.CalledProcessError:
old_version = "unknown"
release_date = sh("git", "show", "-s", "--format=%cs", "HEAD")
commit_sha = sh("git", "rev-parse", "HEAD")
print(f"new_version={new_version}")
print(f"current_version={old_version}")
print(f"release_date={release_date}")
print(f"commit_sha={commit_sha}")
PY
# --- Consolidate metadata for PR body / title ---
- name: Select final metadata
id: final
if: steps.state.outputs.pr_exists == 'false' && github.event.inputs.dry_run != 'true'
shell: bash
env:
BRANCH_EXISTS: ${{ steps.state.outputs.branch_exists }}
YEAR_MONTH: ${{ steps.meta.outputs.year_month }}
FRESH_VERSION: ${{ steps.meta.outputs.new_version }}
FRESH_OLD: ${{ steps.meta.outputs.current_version }}
FRESH_DATE: ${{ steps.meta.outputs.release_date }}
FRESH_SHA: ${{ steps.meta.outputs.commit_sha }}
REC_VERSION: ${{ steps.recover.outputs.new_version }}
REC_OLD: ${{ steps.recover.outputs.current_version }}
REC_DATE: ${{ steps.recover.outputs.release_date }}
REC_SHA: ${{ steps.recover.outputs.commit_sha }}
run: |
set -euo pipefail
if [[ "$BRANCH_EXISTS" == "true" ]]; then
version="$REC_VERSION"
old_version="$REC_OLD"
release_date="$REC_DATE"
commit_sha="$REC_SHA"
is_recovery="true"
else
version="$FRESH_VERSION"
old_version="$FRESH_OLD"
release_date="$FRESH_DATE"
commit_sha="$FRESH_SHA"
is_recovery="false"
fi
{
echo "version=$version"
echo "old_version=$old_version"
echo "release_date=$release_date"
echo "commit_sha=$commit_sha"
echo "is_recovery=$is_recovery"
echo "pr_title=release: ${version} (${YEAR_MONTH})"
} >> "$GITHUB_OUTPUT"
- name: Build PR body from checklist template
if: steps.state.outputs.pr_exists == 'false' && github.event.inputs.dry_run != 'true'
id: pr_body
env:
VERSION: ${{ steps.final.outputs.version }}
OLD_VERSION: ${{ steps.final.outputs.old_version }}
YEAR_MONTH: ${{ steps.meta.outputs.year_month }}
RELEASE_DATE: ${{ steps.final.outputs.release_date }}
COMMIT_SHA: ${{ steps.final.outputs.commit_sha }}
IS_RECOVERY: ${{ steps.final.outputs.is_recovery }}
HAS_PAT: ${{ env.HAS_RELEASE_PAT }}
shell: bash
run: |
set -euo pipefail
python - <<'PY'
import os, pathlib
body = pathlib.Path(".github/release-checklist.md").read_text()
subs = {
"{{VERSION}}": os.environ["VERSION"],
"{{OLD_VERSION}}": os.environ["OLD_VERSION"],
"{{YEAR_MONTH}}": os.environ["YEAR_MONTH"],
"{{RELEASE_DATE}}": os.environ["RELEASE_DATE"],
"{{COMMIT_SHA}}": os.environ["COMMIT_SHA"],
}
for k, v in subs.items():
body = body.replace(k, v)
banners = []
if os.environ.get("IS_RECOVERY") == "true":
banners.append(
"> **Recovery open**: the release branch already existed "
"on origin but no open PR tracked it — this PR was opened "
"by the `release-cut` workflow's recovery path. Verify the "
"version and commit metadata above against the branch "
"contents before tagging."
)
if os.environ.get("HAS_PAT") != "true":
banners.append(
"> **CI kick required**: this PR was opened with the "
"default `GITHUB_TOKEN`, which does not trigger "
"`pull_request` workflows. To satisfy the checklist's "
"`CI/CD Pipeline` and `Security Checks` gates, the "
"release captain must either (a) close this PR and "
"immediately reopen it, or (b) push any commit to this "
"branch from a personal account. See "
"`docs/developer/release-process.md` → \"Kicking CI on "
"the release PR\"."
)
if banners:
body = "\n\n".join(banners) + "\n\n---\n\n" + body
pathlib.Path("release-pr-body.md").write_text(body)
PY
echo "body_file=release-pr-body.md" >> "$GITHUB_OUTPUT"
- name: Ensure 'release' label exists
if: steps.state.outputs.pr_exists == 'false' && github.event.inputs.dry_run != 'true'
env:
GH_TOKEN: ${{ env.RELEASE_TOKEN }}
run: |
set -euo pipefail
# Idempotent label create. gh exits non-zero if the label
# already exists; swallow that specific case only.
if ! gh label create "release" \
--description "Monthly release cut PR" \
--color "0e8a16" 2>/tmp/label-err; then
if grep -qi "already exists" /tmp/label-err; then
echo "Label 'release' already exists — continuing."
else
cat /tmp/label-err
exit 1
fi
fi
- name: Open draft release PR
if: steps.state.outputs.pr_exists == 'false' && github.event.inputs.dry_run != 'true'
env:
GH_TOKEN: ${{ env.RELEASE_TOKEN }}
TITLE: ${{ steps.final.outputs.pr_title }}
HEAD: ${{ steps.meta.outputs.release_branch }}
BODY_FILE: ${{ steps.pr_body.outputs.body_file }}
run: |
set -euo pipefail
gh pr create \
--draft \
--base main \
--head "$HEAD" \
--title "$TITLE" \
--body-file "$BODY_FILE" \
--label "release"
- name: Summary
if: always()
env:
BRANCH_EXISTS: ${{ steps.state.outputs.branch_exists }}
PR_EXISTS: ${{ steps.state.outputs.pr_exists }}
BRANCH: ${{ steps.meta.outputs.release_branch }}
OLD: ${{ steps.meta.outputs.current_version }}
NEW: ${{ steps.meta.outputs.new_version }}
DRY: ${{ github.event.inputs.dry_run }}
HAS_PAT: ${{ env.HAS_RELEASE_PAT }}
run: |
{
echo "## Release cut summary"
echo ""
echo "- Branch: \`$BRANCH\`"
echo "- Version: \`$OLD\` → \`$NEW\`"
echo "- Pre-existing branch: \`$BRANCH_EXISTS\`"
echo "- Pre-existing PR: \`$PR_EXISTS\`"
echo "- Dry run: \`${DRY:-false}\`"
echo "- RELEASE_PAT configured: \`$HAS_PAT\`"
echo ""
if [[ "$BRANCH_EXISTS" == "true" && "$PR_EXISTS" == "true" ]]; then
echo "No changes made — release branch and PR already exist."
elif [[ "$DRY" == "true" ]]; then
echo "No changes pushed — dry run only."
elif [[ "$BRANCH_EXISTS" == "true" && "$PR_EXISTS" == "false" ]]; then
echo "Recovery path: existing release branch had no open PR. Opened a new draft PR."
else
echo "Draft release PR opened. Next step: a maintainer claims it and follows the checklist in the PR body."
fi
if [[ "$HAS_PAT" != "true" ]]; then
echo ""
echo "> RELEASE_PAT not set → CI/Security workflows will not auto-run on the PR. See docs/developer/release-process.md for the kick-CI recipe."
fi
} >> "$GITHUB_STEP_SUMMARY"