Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
name: Release on merge

# Auto-tag + auto-publish GitHub Release whenever VERSION changes on main.
# Composes with validate-release.yml (PR gate) so this workflow trusts that
# the merged VERSION is semver, monotonic, and matched by a CHANGELOG section.
#
# Idempotent: if `vX.Y.Z` already exists (e.g. tagged manually before this
# workflow shipped) the run skips silently — no overwrite, no duplicate.

on:
push:
branches: [main]
paths:
- VERSION

permissions:
contents: write # tag push + gh release create

concurrency:
group: release-on-merge
cancel-in-progress: false

jobs:
release:
name: Tag + GitHub Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so we can check existing tags

- name: Read VERSION
id: version
run: |
set -euo pipefail
v="$(tr -d '[:space:]' < VERSION)"
if ! printf '%s' "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error file=VERSION::not semver X.Y.Z: '$v'"
exit 1
fi
echo " version=$v"
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"

- name: Check existing tag
id: tag_check
run: |
set -euo pipefail
tag="${{ steps.version.outputs.tag }}"
if git rev-parse "$tag" >/dev/null 2>&1; then
echo " $tag already exists — skipping release."
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo " $tag is new — will create."
echo "exists=false" >> "$GITHUB_OUTPUT"
fi

- name: Extract release notes from CHANGELOG
if: steps.tag_check.outputs.exists == 'false'
id: notes
run: |
set -euo pipefail
v="${{ steps.version.outputs.version }}"
awk -v ver="$v" '
$0 ~ "^## " ver "( |$)" { flag=1; next }
flag && /^## / { exit }
flag { print }
' CHANGELOG.md > /tmp/release-notes.md
if [ ! -s /tmp/release-notes.md ]; then
echo "::error file=CHANGELOG.md::no '## $v' section found"
exit 1
fi
# Title = first `### ` heading inside the section, else fall back to vX.Y.Z.
title=$(awk '/^### / { sub(/^### /, ""); print; exit }' /tmp/release-notes.md)
[ -z "$title" ] && title="${{ steps.version.outputs.tag }}"
echo " title=$title"
# Multi-line outputs need the heredoc form.
{
echo "title<<EOT"
echo "$title"
echo "EOT"
} >> "$GITHUB_OUTPUT"

- name: Tag + create GitHub Release
if: steps.tag_check.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tag="${{ steps.version.outputs.tag }}"
title="${{ steps.notes.outputs.title }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$tag" -m "$tag — $title"
git push origin "$tag"
gh release create "$tag" \
--title "$tag — $title" \
--notes-file /tmp/release-notes.md
echo " ✓ released $tag"
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.3.1 — 2026-05-18

### Auto-release on merge-to-main

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.

- **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.
- **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.

### Self-validation

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.

## 0.3.0 — 2026-05-18

### SessionStart auto-upgrade (push-to-main → live-on-next-session)
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.3.0
0.3.1
12 changes: 10 additions & 2 deletions bin/bstack
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,16 @@ bstack_upgrade() {
# with the matching CHANGELOG section as body.
bstack_release_tag() {
cd "$BSTACK_DIR"
if [ -n "$(git status --porcelain)" ]; then
echo "release: working tree is dirty. Commit or stash first." >&2
# Block on modified/staged tracked files only; untracked files (e.g. a
# workspace's .agents/ or skills-lock.json) are unrelated to the release
# state and would otherwise prevent the helper from running in a
# working development checkout.
local dirty
dirty="$(git status --porcelain | grep -v '^?? ' || true)"
if [ -n "$dirty" ]; then
echo "release: working tree has modified or staged tracked files:" >&2
printf ' %s\n' "$dirty" >&2
echo "Commit or stash first." >&2
return 1
fi
local current_branch
Expand Down
Loading