Release - cut monthly branch #4
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: 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" |