From b3600848a106cc6d24a74242fd7c5b9f22b8b575 Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Mon, 13 Jul 2026 00:25:26 +0530 Subject: [PATCH 1/3] ci: auto patch-release on every merge to main Replace release-tag.yml (which only tagged a manually-bumped version) with release.yml, which cuts a release on every push to main: - ordinary merge -> bump the patch across all three manifests, promote the CHANGELOG [Unreleased] section into a dated one, commit the bump back to main, then tag + publish the GitHub Release. - merge that already carries a new version (a manual minor/major bump) -> release it as-is rather than patching past it, detected by whether vCUR is already tagged. The version-write + CHANGELOG promotion is extracted to scripts/release/prepare_release.sh per CONTRIBUTING (more than a couple lines of bash/jq -> a tested script), covered by tests/scripts/test_prepare_release.sh (manifest agreement, semver validation, changelog promotion + idempotency, exit-10 config-error contract). Loop-safe: the bump commit is pushed with GITHUB_TOKEN, and GITHUB_TOKEN pushes do not trigger new workflow runs; a startsWith(chore(release):) guard covers a PAT. Update CONTRIBUTING and CHANGELOG to document the automation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release-tag.yml | 86 --------------------- .github/workflows/release.yml | 107 ++++++++++++++++++++++++++ CHANGELOG.md | 1 + CONTRIBUTING.md | 11 ++- scripts/release/prepare_release.sh | 103 +++++++++++++++++++++++++ tests/scripts/test_prepare_release.sh | 80 +++++++++++++++++++ 6 files changed, 300 insertions(+), 88 deletions(-) delete mode 100644 .github/workflows/release-tag.yml create mode 100644 .github/workflows/release.yml create mode 100755 scripts/release/prepare_release.sh create mode 100755 tests/scripts/test_prepare_release.sh diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml deleted file mode 100644 index 3ae5a23..0000000 --- a/.github/workflows/release-tag.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Release tag on version bump - -# When a version bump lands on `main` (e.g. a merged release PR), cut the -# matching git tag and GitHub Release automatically. Idempotent: if the tag -# already exists the run is a no-op, so ordinary merges that don't touch the -# version are harmless. Source of truth is .claude-plugin/plugin.json; the other -# two manifests must agree or the run fails loudly rather than tagging a mismatch. - -on: - push: - branches: [main] - -permissions: - contents: write - -concurrency: - group: release-tag - cancel-in-progress: false - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - name: Checkout (full history + tags) - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Resolve version and verify the three manifests agree - id: ver - run: | - set -euo pipefail - P=$(jq -r '.version' .claude-plugin/plugin.json) - M=$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json) - C=$(jq -r '.version' .codex-plugin/plugin.json) - [ -n "$P" ] && [ "$P" != "null" ] || { echo "::error::no .version in .claude-plugin/plugin.json"; exit 1; } - if [ "$P" != "$M" ] || [ "$P" != "$C" ]; then - echo "::error::manifest versions disagree — plugin=$P marketplace=$M codex=$C" - exit 1 - fi - echo "version=$P" >> "$GITHUB_OUTPUT" - echo "tag=v$P" >> "$GITHUB_OUTPUT" - - - name: Skip if the tag already exists - id: exists - env: - TAG: ${{ steps.ver.outputs.tag }} - run: | - set -euo pipefail - if [ -n "$(git tag --list "$TAG")" ]; then - echo "already=true" >> "$GITHUB_OUTPUT" - echo "Tag $TAG already exists — nothing to release." - else - echo "already=false" >> "$GITHUB_OUTPUT" - fi - - - name: Extract release notes from CHANGELOG - if: steps.exists.outputs.already == 'false' - env: - VERSION: ${{ steps.ver.outputs.version }} - TAG: ${{ steps.ver.outputs.tag }} - run: | - set -euo pipefail - # Print the body of the "## [VERSION]" section, up to the next "## [" header. - awk -v prefix="## [$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 - - - name: Create tag and GitHub Release - if: steps.exists.outputs.already == 'false' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.ver.outputs.tag }} - run: | - set -euo pipefail - gh release create "$TAG" \ - --target "$GITHUB_SHA" \ - --title "$TAG" \ - --notes-file release_notes.md - echo "Released $TAG at $GITHUB_SHA" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fa8c1ee --- /dev/null +++ b/.github/workflows/release.yml @@ -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 + + - 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." diff --git a/CHANGELOG.md b/CHANGELOG.md index f912728..07736af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fd1f54..9f7cba6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/scripts/release/prepare_release.sh b/scripts/release/prepare_release.sh new file mode 100755 index 0000000..1289e21 --- /dev/null +++ b/scripts/release/prepare_release.sh @@ -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" +} + +write_version() { # write_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 + 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 diff --git a/tests/scripts/test_prepare_release.sh b/tests/scripts/test_prepare_release.sh new file mode 100755 index 0000000..f2fae15 --- /dev/null +++ b/tests/scripts/test_prepare_release.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Unit test for scripts/release/prepare_release.sh. Builds a fixture manifest +# tree in a mktemp dir and points CLAUDE_PLUGIN_ROOT at it — never touches the +# real manifests. +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/release/prepare_release.sh" + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +fixture() { # write a fresh fixture tree at version $1 + local v="$1" + mkdir -p "$tmpdir/.claude-plugin" "$tmpdir/.codex-plugin" + jq -n --arg v "$v" '{name:"superhuman", version:$v}' > "$tmpdir/.claude-plugin/plugin.json" + jq -n --arg v "$v" '{name:"superhuman-marketplace", plugins:[{name:"superhuman", version:$v}]}' \ + > "$tmpdir/.claude-plugin/marketplace.json" + jq -n --arg v "$v" '{name:"superhuman", version:$v}' > "$tmpdir/.codex-plugin/plugin.json" + cat > "$tmpdir/CHANGELOG.md" <<'EOF' +# Changelog + +## [Unreleased] + +### Added +- A shiny new thing. + +## [0.6.2] — 2026-07-06 + +### Added +- The previous thing. +EOF +} + +run() { CLAUDE_PLUGIN_ROOT="$tmpdir" bash "$SCRIPT" "$@"; } +vof() { jq -r "$2" "$tmpdir/$1"; } + +# --- --current: agreeing manifests print the version -------------------------- +fixture 0.6.2 +got=$(run --current) +[ "$got" = "0.6.2" ] || { echo "FAIL --current: want 0.6.2, got '$got'"; exit 1; } + +# --- --current: disagreeing manifests are a config error (exit 10) ------------ +jq '.version = "0.6.3"' "$tmpdir/.claude-plugin/plugin.json" > "$tmpdir/p" && mv "$tmpdir/p" "$tmpdir/.claude-plugin/plugin.json" +set +e; run --current >/dev/null 2>&1; rc=$?; set -e +[ "$rc" = "10" ] || { echo "FAIL --current disagreement: want rc 10, got $rc"; exit 1; } + +# --- --set: writes all three manifests --------------------------------------- +fixture 0.6.2 +new=$(run --set 0.6.3 --date 2026-07-13) +[ "$new" = "0.6.3" ] || { echo "FAIL --set echo: want 0.6.3, got '$new'"; exit 1; } +[ "$(vof .claude-plugin/plugin.json .version)" = "0.6.3" ] || { echo "FAIL plugin.json not bumped"; exit 1; } +[ "$(vof .claude-plugin/marketplace.json .plugins[0].version)" = "0.6.3" ] || { echo "FAIL marketplace.json not bumped"; exit 1; } +[ "$(vof .codex-plugin/plugin.json .version)" = "0.6.3" ] || { echo "FAIL codex plugin.json not bumped"; exit 1; } +# after --set the three agree again +[ "$(run --current)" = "0.6.3" ] || { echo "FAIL post-set --current"; exit 1; } + +# --- --set: CHANGELOG [Unreleased] is promoted, a fresh [Unreleased] opens ---- +grep -qE '^## \[0\.6\.3\] — 2026-07-13' "$tmpdir/CHANGELOG.md" || { echo "FAIL changelog: no dated 0.6.3 section"; exit 1; } +grep -qE '^## \[Unreleased\]' "$tmpdir/CHANGELOG.md" || { echo "FAIL changelog: no fresh Unreleased"; exit 1; } +# the old Unreleased content now lives under 0.6.3, not under Unreleased +awk '/^## \[Unreleased\]/{u=1;next} /^## \[/{u=0} u && /shiny new thing/{found=1} END{exit found?1:0}' "$tmpdir/CHANGELOG.md" \ + || { echo "FAIL changelog: shiny content should have moved out of Unreleased"; exit 1; } +awk '/^## \[0\.6\.3\]/{s=1;next} /^## \[/{s=0} s && /shiny new thing/{found=1} END{exit found?0:1}' "$tmpdir/CHANGELOG.md" \ + || { echo "FAIL changelog: shiny content should sit under 0.6.3"; exit 1; } + +# --- --set: idempotent — re-running does not duplicate the section ------------ +run --set 0.6.3 --date 2026-07-13 >/dev/null +[ "$(grep -cE '^## \[0\.6\.3\]' "$tmpdir/CHANGELOG.md")" = "1" ] || { echo "FAIL changelog: 0.6.3 section duplicated"; exit 1; } + +# --- --set: rejects a non-semver value --------------------------------------- +set +e; run --set 1.2 >/dev/null 2>&1; rc=$?; set -e +[ "$rc" = "10" ] || { echo "FAIL --set bad semver: want rc 10, got $rc"; exit 1; } + +# --- usage errors are exit 10 ------------------------------------------------ +for args in "" "--bogus" "--set"; do + set +e; run $args >/dev/null 2>&1; rc=$?; set -e + [ "$rc" = "10" ] || { echo "FAIL usage '$args': want rc 10, got $rc"; exit 1; } +done + +echo "OK test_prepare_release.sh" From d5bc2dcd3ceb228824dbac37ef3a893d6aee7984 Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Mon, 13 Jul 2026 01:32:33 +0530 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20close=20workflow=20injection=20+=20strict=20semver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release.yml: route steps.ver.outputs.* through env: and reference "$NEW_VERSION" / "$TAG" in run: blocks instead of splicing ${{ }} into the script text (template-injection / zizmor template-injection, Critical). The version can originate from a manifest field, so this was a real code-execution surface in a contents:write job. - prepare_release.sh: validate the version with an anchored ^X.Y.Z$ regex (is_semver), applied in both current() (reject a malformed manifest version before it reaches the workflow) and set_version() (the old glob accepted 1.2.3-beta / 1.2.3.4 / "1.2.3; cmd"). Closes the second half of the injection. - test_prepare_release.sh: cover the values the loose glob let through (1.2.3.4, 1.2.3-rc1, 1.2.3-beta, a metacharacter payload) and a non-semver manifest reaching --current; assert the payload has no side effect. - release.yml: note the branch-protection requirement at the push step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 22 +++++++++++++++++----- scripts/release/prepare_release.sh | 11 +++++++---- tests/scripts/test_prepare_release.sh | 23 ++++++++++++++++++++--- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa8c1ee..f715e32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,29 +62,41 @@ jobs: 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 "${{ steps.ver.outputs.new }}" >/dev/null + 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 ${{ steps.ver.outputs.new }}." + echo "Nothing to commit — manifests and CHANGELOG already at $NEW_VERSION." else - git commit -aqm "chore(release): ${{ steps.ver.outputs.tag }} [skip ci]" + 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="## [${{ steps.ver.outputs.new }}]" ' + 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" "${{ steps.ver.outputs.tag }}" > release_notes.md + printf "Release %s\n" "$TAG" > release_notes.md fi - name: Tag and create the GitHub Release diff --git a/scripts/release/prepare_release.sh b/scripts/release/prepare_release.sh index 1289e21..6cebc01 100755 --- a/scripts/release/prepare_release.sh +++ b/scripts/release/prepare_release.sh @@ -23,6 +23,11 @@ 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" @@ -40,6 +45,7 @@ current() { 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" } @@ -74,10 +80,7 @@ promote_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 + 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" diff --git a/tests/scripts/test_prepare_release.sh b/tests/scripts/test_prepare_release.sh index f2fae15..d62d338 100755 --- a/tests/scripts/test_prepare_release.sh +++ b/tests/scripts/test_prepare_release.sh @@ -67,9 +67,26 @@ awk '/^## \[0\.6\.3\]/{s=1;next} /^## \[/{s=0} s && /shiny new thing/{found=1} E run --set 0.6.3 --date 2026-07-13 >/dev/null [ "$(grep -cE '^## \[0\.6\.3\]' "$tmpdir/CHANGELOG.md")" = "1" ] || { echo "FAIL changelog: 0.6.3 section duplicated"; exit 1; } -# --- --set: rejects a non-semver value --------------------------------------- -set +e; run --set 1.2 >/dev/null 2>&1; rc=$?; set -e -[ "$rc" = "10" ] || { echo "FAIL --set bad semver: want rc 10, got $rc"; exit 1; } +# --- --set: rejects non-semver values (anchored, not a loose glob) ----------- +# The glob [0-9]*.[0-9]*.[0-9]* accepted these; the workflow splices the value +# into shell/awk, so each is a would-be injection or malformed release. +for bad in "1.2" "1.2.3.4" "1.2.3-rc1" "1.2.3-beta" "1.2.3abc" "v1.2.3" "1.2.3; touch $tmpdir/pwned" "1.2.3 +4.5.6"; do + set +e; run --set "$bad" >/dev/null 2>&1; rc=$?; set -e + [ "$rc" = "10" ] || { echo "FAIL --set rejects '$bad': want rc 10, got $rc"; exit 1; } +done +[ ! -e "$tmpdir/pwned" ] || { echo "FAIL --set metacharacter value had a side effect"; exit 1; } + +# --- --current: agreeing but non-semver manifests are a config error ---------- +fixture 0.6.2 +for f in .claude-plugin/plugin.json .codex-plugin/plugin.json; do + jq '.version = "1.2.3-beta"' "$tmpdir/$f" > "$tmpdir/x" && mv "$tmpdir/x" "$tmpdir/$f" +done +jq '.plugins[0].version = "1.2.3-beta"' "$tmpdir/.claude-plugin/marketplace.json" > "$tmpdir/x" \ + && mv "$tmpdir/x" "$tmpdir/.claude-plugin/marketplace.json" +set +e; run --current >/dev/null 2>&1; rc=$?; set -e +[ "$rc" = "10" ] || { echo "FAIL --current non-semver: want rc 10, got $rc"; exit 1; } +fixture 0.6.2 # --- usage errors are exit 10 ------------------------------------------------ for args in "" "--bogus" "--set"; do From 37c32f5ec69cbb0bfce86d0215547bb69949b81e Mon Sep 17 00:00:00 2001 From: gaurav0107 Date: Mon, 13 Jul 2026 01:41:26 +0530 Subject: [PATCH 3/3] ci: gate merges on main (required checks + conversation resolution) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protect main so a failing review or CI blocks the merge: - required status checks: suite (ubuntu-latest), suite (macos-latest) - required conversation resolution: unresolved CodeRabbit comments block merge (CodeRabbit's own check reports success even on findings, so resolution — not the check status — is the gate) - require PR, enforce_admins=false so an admin is never locked out Branch protection also blocks the release bot's push to main, so release.yml now checks out with RELEASE_TOKEN (admin PAT, Contents: write) and falls back to the default token. A PAT push retriggers workflows, so the chore(release): if-guard is now load-bearing against a bump loop. Document the setup in CONTRIBUTING + CHANGELOG. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 16 +++++++++++++--- CHANGELOG.md | 3 ++- CONTRIBUTING.md | 8 +++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f715e32..d6086cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,15 @@ name: release # # 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. +# 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: @@ -32,6 +38,10 @@ jobs: 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: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 07736af..eb9a1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +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`). Replaces `release-tag.yml`, which only tagged manual bumps. Bump commits use `GITHUB_TOKEN`, so they do not retrigger the workflow. +- **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. +- **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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f7cba6..d94ca47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,11 +77,17 @@ Versions follow [semver](https://semver.org/). The three manifests — `.claude- **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 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 Open a [GitHub issue](https://github.com/gaurav0107/superhuman/issues). For anything security- or safety-sensitive, follow [SECURITY.md](./SECURITY.md) instead of filing a public issue.