Skip to content

Commit c59b57b

Browse files
Carlos D. Escobar-Valbuenaclaude
andcommitted
feat(release): auto-tag + GitHub Release on merge (0.3.1)
Closes the last manual step in the release workflow. When a PR bumping VERSION merges to main, GitHub Actions now tags vX.Y.Z and creates the GitHub Release automatically — using the matching ## X.Y.Z section of CHANGELOG.md as the release body. This release validates itself: when this PR merges, release.yml fires for the first time and creates v0.3.1 automatically. Files: - NEW .github/workflows/release.yml — triggers on push: main with paths: [VERSION]. Reads VERSION, checks if vX.Y.Z exists (idempotent silent skip if so), extracts the matching CHANGELOG section, creates the annotated tag, pushes it, gh release create. Title is the first `### ` heading inside the section. Composes with validate-release.yml (PR gate) so this workflow trusts that the merged VERSION is semver, monotonic, and has a CHANGELOG section. - EDIT bin/bstack — `release tag` clean-tree precondition now only blocks on modified/staged tracked files. Untracked files (e.g. workspace-level .agents/, skills-lock.json) no longer prevent the manual helper from running in a development checkout. Error message now lists offending paths instead of saying "dirty" with no detail. - EDIT VERSION → 0.3.1 - EDIT CHANGELOG.md — 0.3.1 entry Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c9bf3c9 commit c59b57b

4 files changed

Lines changed: 123 additions & 3 deletions

File tree

.github/workflows/release.yml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
name: Release on merge
2+
3+
# Auto-tag + auto-publish GitHub Release whenever VERSION changes on main.
4+
# Composes with validate-release.yml (PR gate) so this workflow trusts that
5+
# the merged VERSION is semver, monotonic, and matched by a CHANGELOG section.
6+
#
7+
# Idempotent: if `vX.Y.Z` already exists (e.g. tagged manually before this
8+
# workflow shipped) the run skips silently — no overwrite, no duplicate.
9+
10+
on:
11+
push:
12+
branches: [main]
13+
paths:
14+
- VERSION
15+
16+
permissions:
17+
contents: write # tag push + gh release create
18+
19+
concurrency:
20+
group: release-on-merge
21+
cancel-in-progress: false
22+
23+
jobs:
24+
release:
25+
name: Tag + GitHub Release
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v4
29+
with:
30+
fetch-depth: 0 # full history so we can check existing tags
31+
32+
- name: Read VERSION
33+
id: version
34+
run: |
35+
set -euo pipefail
36+
v="$(tr -d '[:space:]' < VERSION)"
37+
if ! printf '%s' "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
38+
echo "::error file=VERSION::not semver X.Y.Z: '$v'"
39+
exit 1
40+
fi
41+
echo " version=$v"
42+
echo "version=$v" >> "$GITHUB_OUTPUT"
43+
echo "tag=v$v" >> "$GITHUB_OUTPUT"
44+
45+
- name: Check existing tag
46+
id: tag_check
47+
run: |
48+
set -euo pipefail
49+
tag="${{ steps.version.outputs.tag }}"
50+
if git rev-parse "$tag" >/dev/null 2>&1; then
51+
echo " $tag already exists — skipping release."
52+
echo "exists=true" >> "$GITHUB_OUTPUT"
53+
else
54+
echo " $tag is new — will create."
55+
echo "exists=false" >> "$GITHUB_OUTPUT"
56+
fi
57+
58+
- name: Extract release notes from CHANGELOG
59+
if: steps.tag_check.outputs.exists == 'false'
60+
id: notes
61+
run: |
62+
set -euo pipefail
63+
v="${{ steps.version.outputs.version }}"
64+
awk -v ver="$v" '
65+
$0 ~ "^## " ver "( |$)" { flag=1; next }
66+
flag && /^## / { exit }
67+
flag { print }
68+
' CHANGELOG.md > /tmp/release-notes.md
69+
if [ ! -s /tmp/release-notes.md ]; then
70+
echo "::error file=CHANGELOG.md::no '## $v' section found"
71+
exit 1
72+
fi
73+
# Title = first `### ` heading inside the section, else fall back to vX.Y.Z.
74+
title=$(awk '/^### / { sub(/^### /, ""); print; exit }' /tmp/release-notes.md)
75+
[ -z "$title" ] && title="${{ steps.version.outputs.tag }}"
76+
echo " title=$title"
77+
# Multi-line outputs need the heredoc form.
78+
{
79+
echo "title<<EOT"
80+
echo "$title"
81+
echo "EOT"
82+
} >> "$GITHUB_OUTPUT"
83+
84+
- name: Tag + create GitHub Release
85+
if: steps.tag_check.outputs.exists == 'false'
86+
env:
87+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
88+
run: |
89+
set -euo pipefail
90+
tag="${{ steps.version.outputs.tag }}"
91+
title="${{ steps.notes.outputs.title }}"
92+
git config user.name "github-actions[bot]"
93+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
94+
git tag -a "$tag" -m "$tag — $title"
95+
git push origin "$tag"
96+
gh release create "$tag" \
97+
--title "$tag — $title" \
98+
--notes-file /tmp/release-notes.md
99+
echo " ✓ released $tag"

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## 0.3.1 — 2026-05-18
4+
5+
### Auto-release on merge-to-main
6+
7+
Closes the last manual step in the release workflow. When a PR bumping VERSION merges to main, GitHub Actions now tags `vX.Y.Z` and creates the GitHub Release automatically — using the matching `## X.Y.Z` section of `CHANGELOG.md` as the release body.
8+
9+
- **NEW** `.github/workflows/release.yml` — triggers on `push: branches: [main]` with `paths: [VERSION]`. Reads VERSION, checks if `vX.Y.Z` already exists (idempotent: skips silently if so), extracts the matching CHANGELOG section, creates the annotated tag, pushes it, and runs `gh release create`. The release title is the first `### ` heading inside the section, falling back to the tag. Composes with `validate-release.yml` (PR gate) so this workflow trusts that the merged VERSION is semver, monotonic, and has a CHANGELOG section.
10+
- **CHANGED** `bin/bstack` `release tag` — the clean-tree precondition now only blocks on **modified or staged tracked files**. Untracked files (e.g. workspace-level `.agents/`, `skills-lock.json`, scratch artifacts) no longer prevent the manual helper from running, so it works in a normal development checkout. The error message now lists the offending paths instead of saying "dirty" with no detail.
11+
12+
### Self-validation
13+
14+
This release validates itself: when this PR merges, `release.yml` fires for the first time and creates v0.3.1 automatically — no manual tag or `gh release create` needed.
15+
316
## 0.3.0 — 2026-05-18
417

518
### SessionStart auto-upgrade (push-to-main → live-on-next-session)

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.0
1+
0.3.1

bin/bstack

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,16 @@ bstack_upgrade() {
107107
# with the matching CHANGELOG section as body.
108108
bstack_release_tag() {
109109
cd "$BSTACK_DIR"
110-
if [ -n "$(git status --porcelain)" ]; then
111-
echo "release: working tree is dirty. Commit or stash first." >&2
110+
# Block on modified/staged tracked files only; untracked files (e.g. a
111+
# workspace's .agents/ or skills-lock.json) are unrelated to the release
112+
# state and would otherwise prevent the helper from running in a
113+
# working development checkout.
114+
local dirty
115+
dirty="$(git status --porcelain | grep -v '^?? ' || true)"
116+
if [ -n "$dirty" ]; then
117+
echo "release: working tree has modified or staged tracked files:" >&2
118+
printf ' %s\n' "$dirty" >&2
119+
echo "Commit or stash first." >&2
112120
return 1
113121
fi
114122
local current_branch

0 commit comments

Comments
 (0)