Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.

107 changes: 107 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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.
#
# Loop safety: the bump is committed with the default GITHUB_TOKEN, and pushes made
# with that token do NOT trigger new workflow runs. The `if:` guard below is a
# second belt for the case someone swaps in a PAT/app token.

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

- 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 }}
run: |
set -euo pipefail
scripts/release/prepare_release.sh --set "${{ steps.ver.outputs.new }}" >/dev/null
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Nothing to commit — manifests and CHANGELOG already at ${{ steps.ver.outputs.new }}."
else
git commit -aqm "chore(release): ${{ steps.ver.outputs.tag }} [skip ci]"
git push origin HEAD:main
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Extract release notes from CHANGELOG
run: |
set -euo pipefail
# Body of the "## [NEW]" section, up to the next "## [" header.
awk -v prefix="## [${{ steps.ver.outputs.new }}]" '
/^## \[/ { 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" "${{ steps.ver.outputs.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."
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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`). Replaces `release-tag.yml`, which only tagged manual bumps. Bump commits use `GITHUB_TOKEN`, so they do not retrigger the workflow.
- **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
11 changes: 9 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,19 @@ 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. The bump commit is pushed with `GITHUB_TOKEN`, so it does not retrigger the workflow.
- 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.

## Reporting bugs & requesting features

Expand Down
103 changes: 103 additions & 0 deletions scripts/release/prepare_release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/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"

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"
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"
case "$v" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) die "--set expects a semver X.Y.Z, got: $v" ;;
esac
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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