-
Notifications
You must be signed in to change notification settings - Fork 1
ci: auto patch-release on every merge to main #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| 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 | ||
|
|
||
| - 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." | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🧰 Tools🪛 LanguageTool[uncategorized] ~10-~10: The official name of this software platform is spelled with a capital “H”. (GITHUB) 🤖 Prompt for AI Agents |
||
| - **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 | ||
|
|
||
| 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" | ||
| } | ||
|
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 | ||
Uh oh!
There was an error while loading. Please reload this page.