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
86 changes: 0 additions & 86 deletions .github/workflows/release-tag.yml

This file was deleted.

129 changes: 129 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
name: release

# Every push to main cuts a release. Ordinary merges auto-bump the patch version
# across the three manifests, promote the CHANGELOG's [Unreleased] section, commit
# the bump back to main, then tag + create the GitHub Release. A merge that already
# introduced a new version (a manual minor/major bump) is released as-is instead of
# being patched past.
#
# Supersedes release-tag.yml, which only tagged a manually-bumped version.
#
# main is a protected branch (required checks + conversation resolution). The
# default GITHUB_TOKEN cannot push the bump commit past that protection, so the
# push uses RELEASE_TOKEN — a fine-grained PAT owned by a repo admin (Contents:
# write). Because branch protection has enforce_admins=false, an admin identity
# bypasses the required checks. See CONTRIBUTING → Versioning & releases for setup.
#
# Loop safety: a PAT push DOES retrigger workflows (unlike GITHUB_TOKEN), so the
# `if:` guard below is load-bearing — it skips the run whose head commit is our
# own "chore(release):" bump, preventing an infinite bump loop.

on:
push:
branches: [main]

permissions:
contents: write

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

jobs:
release:
if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
# PAT so the bump commit can be pushed to protected main; falls back to
# the default token when RELEASE_TOKEN is unset (works only while main
# is unprotected — otherwise the push step fails loudly).
token: ${{ secrets.RELEASE_TOKEN || github.token }}

- name: Configure git identity
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

- name: Resolve the release version
id: ver
env:
CLAUDE_PLUGIN_ROOT: ${{ github.workspace }}
run: |
set -euo pipefail
# --current also asserts the three manifests agree; it exits 10 (not a
# version) if they drift, failing the release loudly instead of tagging
# a mismatch.
CUR=$(scripts/release/prepare_release.sh --current)
if git rev-parse -q --verify "refs/tags/v$CUR" >/dev/null; then
NEW=$(echo "$CUR" | awk -F. '{printf "%d.%d.%d", $1, $2, $3 + 1}')
echo "vCUR already released — auto-bumping patch $CUR -> $NEW"
else
NEW="$CUR"
echo "v$CUR not yet tagged — releasing it as-is (manual bump / first release)"
fi
echo "new=$NEW" >> "$GITHUB_OUTPUT"
echo "tag=v$NEW" >> "$GITHUB_OUTPUT"

- name: Apply the version + CHANGELOG, commit if changed
id: prep
env:
CLAUDE_PLUGIN_ROOT: ${{ github.workspace }}
# Route step outputs through env, never splice ${{ }} into run: — that
# substitutes into the script text before the shell parses it, which is
# a code-injection vector (version originates from a manifest field).
NEW_VERSION: ${{ steps.ver.outputs.new }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
scripts/release/prepare_release.sh --set "$NEW_VERSION" >/dev/null
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Nothing to commit — manifests and CHANGELOG already at $NEW_VERSION."
else
git commit -aqm "chore(release): $TAG [skip ci]"
# Direct push to main. This requires main to be either unprotected or
# to let the github-actions bot bypass protection (Settings → Branches).
# If protected without a bypass, this push is rejected and the step
# fails loudly — that is intentional, not a silent no-release.
git push origin HEAD:main
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Extract release notes from CHANGELOG
env:
NEW_VERSION: ${{ steps.ver.outputs.new }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
# Body of the "## [NEW]" section, up to the next "## [" header.
awk -v prefix="## [$NEW_VERSION]" '
/^## \[/ { if (started) exit;
if (substr($0, 1, length(prefix)) == prefix) { started=1; next } }
started { print }
' CHANGELOG.md > release_notes.md
if [ ! -s release_notes.md ]; then
printf "Release %s\n" "$TAG" > release_notes.md
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Tag and create the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
if [ -n "$(git tag --list "$TAG")" ]; then
echo "Tag $TAG already exists — nothing to release."
exit 0
fi
# HEAD is the bump commit when one was made, else the merge commit.
git tag "$TAG"
git push origin "$TAG"
gh release create "$TAG" \
--target "$(git rev-parse HEAD)" \
--title "$TAG" \
--notes-file release_notes.md
echo "Released $TAG."
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]

### Added
- **Automated patch releases on merge (`.github/workflows/release.yml`).** Every push to `main` now cuts a release: an ordinary merge auto-bumps the patch version across all three manifests, promotes the `## [Unreleased]` CHANGELOG section into a dated section, commits the bump back to `main`, then tags and publishes the GitHub Release. A merge that already carries a new version (a manual minor/major bump) is released as-is instead of being patched past. The version-writing + CHANGELOG promotion is extracted to `scripts/release/prepare_release.sh` (covered by `tests/scripts/test_prepare_release.sh`), with strict `^X.Y.Z$` validation so a malformed manifest version can't reach the workflow. Replaces `release-tag.yml`, which only tagged manual bumps.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify that release-bot pushes are excluded.

The workflow intentionally skips its own chore(release): bump commit, so “Every push to main now cuts a release” is technically inaccurate. Say “Every non-release push” or equivalent to match the guard.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~10-~10: The official name of this software platform is spelled with a capital “H”.
Context: ... - Automated patch releases on merge (.github/workflows/release.yml). Every push t...

(GITHUB)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` at line 10, Update the CHANGELOG entry describing automated
patch releases to state that every non-release push to main triggers a release,
reflecting the workflow’s exclusion of its own chore(release): bump commit. Keep
the remaining release behavior description unchanged.

- **Merge gating on `main`.** Branch protection now requires the `suite` CI checks to pass and all review conversations (CodeRabbit) to be resolved before a PR can merge — a failing check or an unresolved review comment blocks the merge. The release bot pushes its bump commit with a `RELEASE_TOKEN` admin PAT to get past protection (`enforce_admins=false`); see CONTRIBUTING → Versioning & releases.
- **One-command installer (`install.sh`).** Installs superhuman **and its dependent plugins** — `superpowers` (required) and `everything-claude-code` / ECC (recommended) — in a single command via the Claude Code CLI (`claude plugin marketplace add` / `plugin install`). Idempotent and re-runnable; checks prerequisites (`git`/`gh`/`jq`/`python3` + `gh auth`), and falls back to printing the manual slash-commands when the `claude` CLI isn't on `PATH`. Flags: `--skip-ecc`, `--codex` (clone + symlink the Codex skill), `--dry-run`. Usage: `curl -fsSL https://raw.githubusercontent.com/gaurav0107/superhuman/main/install.sh | bash`. Covered by `tests/scripts/test_install.sh`; README **Installation** section leads with it.

### Fixed
Expand Down
17 changes: 15 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,25 @@ bash tests/scripts/test_state.sh
- [ ] New/changed scripts have tests.
- [ ] Changed shared-state shapes update schema **and** test fixture together.
- [ ] No safety rail weakened without explicit justification in the PR body.
- [ ] If behavior changed meaningfully, `version` is bumped in **all three** manifests `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `.codex-plugin/plugin.json` (they must match — they've drifted before), and `CHANGELOG.md` has an entry.
- [ ] Add your notes under `## [Unreleased]` in `CHANGELOG.md`. A **patch** release is cut automatically when your PR merges (see Versioning & releases) — you do **not** need to bump the manifests for a patch. For a **minor/major** release, bump `version` yourself in **all three** manifests (`.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `.codex-plugin/plugin.json` — they must match) and the merge releases that version as-is.
- [ ] Notable design decisions captured under `docs/` when warranted.

## Versioning & releases

Versions follow [semver](https://semver.org/). The three manifests — `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `.codex-plugin/plugin.json` — **must carry the same version**; a mismatch is a release bug (it happened in v0.4.1→v0.5.0). Add a dated, summarised entry to [CHANGELOG.md](./CHANGELOG.md) for every release.
Versions follow [semver](https://semver.org/). The three manifests — `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, and `.codex-plugin/plugin.json` — **must carry the same version**; a mismatch is a release bug (it happened in v0.4.1→v0.5.0).

**Releases are automatic.** Every push to `main` runs [`.github/workflows/release.yml`](./.github/workflows/release.yml):

- If the current version is already tagged (the normal case for a merge), it **bumps the patch** across all three manifests, promotes the `## [Unreleased]` CHANGELOG section into a dated `## [X.Y.Z]` section, commits that back to `main` (`chore(release): vX.Y.Z`), then tags and publishes the GitHub Release.
- If the merge already introduced a new version (you bumped the manifests for a **minor/major**), it releases that version **as-is** instead of patching past it.

So: put your notes under `## [Unreleased]` and they become the next release's notes. The version-writing and CHANGELOG promotion live in [`scripts/release/prepare_release.sh`](./scripts/release/prepare_release.sh) (tested by `tests/scripts/test_prepare_release.sh`); the workflow only decides the target version and does the git/tag/release plumbing.

### Branch protection & the release token

`main` is protected: PRs merge only when the `suite (ubuntu-latest)` / `suite (macos-latest)` checks pass **and** every review conversation (CodeRabbit included) is resolved. A failing check or an unresolved review comment blocks the merge.

Because protection also blocks the release bot's push, the release workflow authenticates its bump-commit push with a **`RELEASE_TOKEN`** secret — a fine-grained PAT owned by a repo admin, scoped `Contents: write`. Protection is set with `enforce_admins=false`, so an admin token bypasses the required checks for that one automated push. Without the secret the release step fails loudly rather than silently skipping a release. (Pushes made by a PAT re-trigger workflows, so the `chore(release):` commit is filtered by the `if:` guard in `release.yml` to avoid a bump loop.)

## Reporting bugs & requesting features

Expand Down
106 changes: 106 additions & 0 deletions scripts/release/prepare_release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# prepare_release.sh — make the working tree describe a given release version.
#
# prepare_release.sh --current # verify the 3 manifests agree; print the version
# prepare_release.sh --set X.Y.Z [--date D] # write X.Y.Z to the 3 manifests + promote CHANGELOG
#
# Pure filesystem: no network, no git. Operates on the manifests and CHANGELOG
# under $CLAUDE_PLUGIN_ROOT (default: repo root, inferred from this script's
# path). The release workflow calls it; test_prepare_release.sh points
# CLAUDE_PLUGIN_ROOT at a fixture tree.
#
# Exit: 0 ok, 10 config/usage error. 10 is distinct from any version string so a
# caller that captures stdout never mistakes an error for a version.
set -euo pipefail

die() { echo "prepare_release.sh: $*" >&2; exit 10; }

ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}"
PLUGIN="$ROOT/.claude-plugin/plugin.json"
MARKET="$ROOT/.claude-plugin/marketplace.json"
CODEX="$ROOT/.codex-plugin/plugin.json"
CHANGELOG="$ROOT/CHANGELOG.md"

command -v jq >/dev/null 2>&1 || die "jq is required"

# Strict, anchored X.Y.Z. A glob like [0-9]*.[0-9]*.[0-9]* is not enough — it
# accepts "1.2.3-beta", "1.2.3abc", and "1.2.3; rm -rf". The workflow splices this
# value into shell/awk, so a loose check is a code-injection surface.
is_semver() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; }

read_current() { # echoes "plugin marketplace codex" versions
[ -r "$PLUGIN" ] || die "cannot read $PLUGIN"
[ -r "$MARKET" ] || die "cannot read $MARKET"
[ -r "$CODEX" ] || die "cannot read $CODEX"
local p m c
p=$(jq -r '.version // empty' "$PLUGIN")
m=$(jq -r '.plugins[0].version // empty' "$MARKET")
c=$(jq -r '.version // empty' "$CODEX")
[ -n "$p" ] && [ -n "$m" ] && [ -n "$c" ] || die "a manifest is missing .version"
printf '%s %s %s' "$p" "$m" "$c"
}

current() {
local p m c
read -r p m c <<<"$(read_current)"
[ "$p" = "$m" ] && [ "$p" = "$c" ] \
|| die "manifests disagree: plugin=$p marketplace=$m codex=$c"
is_semver "$p" || die "manifest version is not a valid X.Y.Z semver: $p"
printf '%s\n' "$p"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

write_version() { # write_version <file> <jq-set-filter> <version>
local f="$1" filter="$2" v="$3" tmp
tmp=$(mktemp)
jq --arg v "$v" "$filter" "$f" > "$tmp"
mv "$tmp" "$f"
}

# Promote CHANGELOG's [Unreleased] section into a dated version section and open
# a fresh empty [Unreleased] above it. Idempotent (a [X.Y.Z] section already
# present → no-op) and a no-op when there is no [Unreleased] heading.
promote_changelog() {
local v="$1" d="$2" tmp
[ -f "$CHANGELOG" ] || return 0
grep -qE "^## \[$v\]( |\$)" "$CHANGELOG" && return 0
grep -qE '^## \[Unreleased\]' "$CHANGELOG" || return 0
tmp=$(mktemp)
awk -v v="$v" -v d="$d" '
/^## \[Unreleased\]/ && !done {
print "## [Unreleased]"
print ""
print "## [" v "] — " d
done = 1
next
}
{ print }
' "$CHANGELOG" > "$tmp"
mv "$tmp" "$CHANGELOG"
}

set_version() {
local v="$1" d="$2"
is_semver "$v" || die "--set expects a semver X.Y.Z, got: $v"
write_version "$PLUGIN" '.version = $v' "$v"
write_version "$MARKET" '.plugins[0].version = $v' "$v"
write_version "$CODEX" '.version = $v' "$v"
promote_changelog "$v" "$d"
printf '%s\n' "$v"
}

MODE="" VERSION="" DATE=""
while [ $# -gt 0 ]; do
case "$1" in
--current) MODE="current"; shift ;;
--set) [ $# -ge 2 ] || die "--set needs a value"; MODE="set"; VERSION="$2"; shift 2 ;;
--date) [ $# -ge 2 ] || die "--date needs a value"; DATE="$2"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
[ -n "$MODE" ] || die "one of --current or --set is required"
DATE="${DATE:-$(date -u +%Y-%m-%d)}"

case "$MODE" in
current) current ;;
set) set_version "$VERSION" "$DATE" ;;
esac
Loading
Loading