diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 0000000..109574a --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,132 @@ +--- +name: release +description: Release tang end-to-end: infer the next version from git history, update CHANGELOG.md, test, tag, build the macOS arm64 binary, upload the GitHub release asset, update onevcat/homebrew-tap, and verify Homebrew installation. +--- + +# tang Release Skill + +Use this skill when the user says `$release`, "发布", "release", "发版", or asks to ship a new tang version. + +The goal is to complete the whole release, not just describe it. The agent owns the release bookkeeping, but must not invent a version blindly. +If the user does not provide a version, infer it from history and proceed. Ask +only when the history supports multiple materially different release choices, +such as a possible breaking release versus an ordinary patch release. + +## Release Policy + +- Current distribution target: Homebrew formula in `onevcat/homebrew-tap`. +- Current binary target: macOS arm64 only. +- Source version tag in this repo: `vX.Y.Z`. +- GitHub release tag in `onevcat/homebrew-tap`: `tang-X.Y.Z`. +- Homebrew asset: `tang-X.Y.Z-darwin-arm64.tar.gz`. +- The release script performs the mechanical release after changelog and version are ready: + +```sh +scripts/release.sh X.Y.Z +``` + +The default tap checkout path is: + +```text +/Users/onevcat/Sync/github/homebrew-tap +``` + +## Decide The Version + +1. Inspect the latest release tag: + +```sh +git tag --list 'v*' --sort=-version:refname | head -1 +``` + +2. Inspect history since that tag: + +```sh +git log --oneline --decorate ..HEAD +git diff --stat ..HEAD +``` + +3. Choose the next SemVer version: + +- Patch: bug fixes, docs, release tooling, internal cleanup, small CLI behavior fixes. +- Minor: new commands, new user-visible workflows, config keys, or meaningful capability additions. +- Major: incompatible CLI/data/config changes after `1.0.0`. Before `1.0.0`, prefer minor for breaking user-facing changes unless the user explicitly wants `1.0.0`. + +For this early project, most ordinary releases after `0.0.1` should be patch releases unless there is a clear new capability. + +## Prepare CHANGELOG.md + +Before running the release script, update `CHANGELOG.md` with a top-level entry: + +```markdown +## X.Y.Z + +- User-facing change. +- Another user-facing change. +``` + +Guidelines: + +- Derive bullets from git history since the previous tag. +- Prefer user-facing language over commit-message wording. +- Include release/distribution changes when they affect installation. +- Do not include every internal refactor. +- Keep the existing `0.0.1` entry intact. + +Commit and push the changelog before tagging: + +```sh +go test ./... +git add CHANGELOG.md +git commit -m "Prepare X.Y.Z release" +git push origin main +``` + +If other files are intentionally part of the release preparation, include them in the same commit. Do not include unrelated dirty files. + +## Run The Release + +Run: + +```sh +scripts/release.sh X.Y.Z +``` + +The script will: + +- require clean `tang` and `homebrew-tap` worktrees, +- require `CHANGELOG.md` to contain `## X.Y.Z`, +- run `go test ./...`, +- run `make build`, +- create and push `vX.Y.Z`, +- call `scripts/release-homebrew.sh` to build, ad-hoc sign, package, checksum, and upload the macOS arm64 asset, +- update `Formula/tang.rb` in `onevcat/homebrew-tap`, +- commit and push the tap, +- update the installed `onevcat/tap` checkout if present, +- run `brew audit --strict --online onevcat/tap/tang`, +- run `brew reinstall onevcat/tap/tang`, +- run `brew test onevcat/tap/tang`, +- print `tang version`. + +## Verification To Report + +After release, report: + +- source tag, for example `vX.Y.Z`, +- source commit SHA, +- GitHub release URL, +- Homebrew tap commit SHA, +- asset SHA256, +- final `tang version`, +- the install command: + +```sh +brew install onevcat/tap/tang +``` + +## Failure Handling + +- If the tag push succeeds but GitHub release or tap update fails, stop and report the exact completed steps. Do not delete published tags or releases automatically. +- If Homebrew audit fails after the tap commit, fix `Formula/tang.rb`, commit, push, and rerun audit/install/test. +- If SSH auth to Tangled fails, retry only after checking that the relevant public key is present with `tang ssh-key list`; do not force-push unrelated refs. +- If a GitHub write is required, use the currently authenticated `gh` account only if it is `onevcat`; otherwise stop and ask the user to switch auth. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7e484b5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable user-facing changes are recorded here. + +## 0.0.1 + +- Initial Homebrew release for macOS arm64. +- Added authentication, SSH key management, repository, issue, pull request, and + browser workflows for Tangled. +- Added configurable clone protocol support. +- Documented current Tangled AppView synchronization and patch-merge + limitations. diff --git a/README.md b/README.md index a6b2051..f869fc0 100644 --- a/README.md +++ b/README.md @@ -351,17 +351,16 @@ make build ./bin/tang --help ``` -Release a macOS arm64 Homebrew asset: +Prepare a release by updating `CHANGELOG.md`, then run the full release script: ```sh -git tag -a v0.0.1 -m "tang v0.0.1" -git push origin v0.0.1 -scripts/release-homebrew.sh 0.0.1 --upload +scripts/release.sh 0.0.2 ``` -The release script builds a precompiled macOS arm64 binary, applies an ad-hoc -code signature when `codesign` is available, writes `dist/checksums.txt`, and -uploads the asset to `onevcat/homebrew-tap` when `--upload` is present. +The release script runs tests, tags and pushes `vX.Y.Z`, builds a precompiled +macOS arm64 binary, applies an ad-hoc code signature when `codesign` is +available, uploads the asset to `onevcat/homebrew-tap`, updates the Homebrew +formula, and verifies installation with `brew`. The main implementation areas are: diff --git a/scripts/release-homebrew.sh b/scripts/release-homebrew.sh index afa263a..ba032fe 100755 --- a/scripts/release-homebrew.sh +++ b/scripts/release-homebrew.sh @@ -4,7 +4,7 @@ set -euo pipefail usage() { cat <<'USAGE' Usage: - scripts/release-homebrew.sh [--upload] [--repo owner/repo] + scripts/release-homebrew.sh [--upload] [--repo owner/repo] [--notes-file path] Build the macOS arm64 Homebrew release asset for tang. @@ -15,6 +15,7 @@ Options: --upload Create a GitHub release and upload the generated asset. --repo owner/repo GitHub repository that receives release assets. Defaults to onevcat/homebrew-tap. + --notes-file path Release notes used when --upload creates the GitHub release. The script expects tag v to exist and point to HEAD. It creates: dist/tang--darwin-arm64.tar.gz @@ -25,6 +26,7 @@ USAGE version="" upload="false" release_repo="onevcat/homebrew-tap" +notes_file="" while [[ $# -gt 0 ]]; do case "$1" in @@ -40,6 +42,14 @@ while [[ $# -gt 0 ]]; do fi shift 2 ;; + --notes-file) + notes_file="${2:-}" + if [[ -z "$notes_file" ]]; then + echo "error: --notes-file requires a path" >&2 + exit 2 + fi + shift 2 + ;; -h|--help) usage exit 0 @@ -124,8 +134,15 @@ echo "Homebrew URL:" echo "https://github.com/${release_repo}/releases/download/${release_tag}/${asset_name}.tar.gz" if [[ "$upload" == "true" ]]; then - gh release create "$release_tag" "$archive" "$checksums" \ - --repo "$release_repo" \ - --title "tang ${version}" \ - --notes "macOS arm64 prebuilt binary for tang ${version}." + release_args=( + release create "$release_tag" "$archive" "$checksums" + --repo "$release_repo" + --title "tang ${version}" + ) + if [[ -n "$notes_file" ]]; then + release_args+=(--notes-file "$notes_file") + else + release_args+=(--notes "macOS arm64 prebuilt binary for tang ${version}.") + fi + gh "${release_args[@]}" fi diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..bdb0b19 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + scripts/release.sh [options] + +Release tang and update the Homebrew tap. + +Arguments: + Release version without a leading "v", for example 0.0.2. + +Options: + --tap-path Local checkout of onevcat/homebrew-tap. + Defaults to /Users/onevcat/Sync/github/homebrew-tap. + --release-repo GitHub repository that receives release assets. + Defaults to onevcat/homebrew-tap. + --remote Git remote used for the tang repository. Defaults to origin. + --dry-run Validate and prepare local changes without pushing tags, + creating GitHub releases, or pushing the tap. + +The script performs: + 1. Validate version, clean worktrees, and CHANGELOG.md entry. + 2. Run go test ./... and make build. + 3. Create and push tag v. + 4. Build, sign, package, checksum, and upload the macOS arm64 asset. + 5. Update Formula/tang.rb in the Homebrew tap. + 6. Commit and push the Homebrew tap. + 7. Pull the installed tap checkout when present. + 8. Run brew audit, reinstall, and brew test. +USAGE +} + +version="" +tap_path="/Users/onevcat/Sync/github/homebrew-tap" +release_repo="onevcat/homebrew-tap" +remote="origin" +dry_run="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --tap-path) + tap_path="${2:-}" + if [[ -z "$tap_path" ]]; then + echo "error: --tap-path requires a path" >&2 + exit 2 + fi + shift 2 + ;; + --release-repo) + release_repo="${2:-}" + if [[ -z "$release_repo" ]]; then + echo "error: --release-repo requires owner/repo" >&2 + exit 2 + fi + shift 2 + ;; + --remote) + remote="${2:-}" + if [[ -z "$remote" ]]; then + echo "error: --remote requires a git remote name" >&2 + exit 2 + fi + shift 2 + ;; + --dry-run) + dry_run="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "error: unknown option $1" >&2 + usage >&2 + exit 2 + ;; + *) + if [[ -n "$version" ]]; then + echo "error: version was provided more than once" >&2 + usage >&2 + exit 2 + fi + version="$1" + shift + ;; + esac +done + +if [[ -z "$version" ]]; then + usage >&2 + exit 2 +fi + +if [[ "$version" == v* ]]; then + echo "error: version must not start with v" >&2 + exit 2 +fi +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "error: version must be X.Y.Z" >&2 + exit 2 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +tag="v${version}" +release_tag="tang-${version}" +asset_name="tang-${version}-darwin-arm64" +asset_url="https://github.com/${release_repo}/releases/download/${release_tag}/${asset_name}.tar.gz" +tmp_dir="$(mktemp -d)" +notes_file="${tmp_dir}/release-notes-${version}.md" +temporary_tag_created="false" + +cleanup() { + rm -rf "$tmp_dir" + if [[ "$temporary_tag_created" == "true" ]]; then + git tag -d "$tag" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +require_clean_worktree() { + local path="$1" + if [[ -n "$(git -C "$path" status --porcelain)" ]]; then + echo "error: working tree is not clean: $path" >&2 + git -C "$path" status --short >&2 + exit 1 + fi +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: required command not found: $1" >&2 + exit 1 + fi +} + +changelog_entry() { + awk -v version="$version" ' + $0 == "## " version { found=1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md +} + +update_formula() { + local formula="$1" + local sha="$2" + cat >"$formula" <&2 + exit 1 +fi + +tap_path="$(cd "$tap_path" && pwd)" +if [[ ! -d "$tap_path/.git" ]]; then + echo "error: --tap-path is not a git checkout: $tap_path" >&2 + exit 1 +fi + +if [[ ! -f CHANGELOG.md ]]; then + echo "error: CHANGELOG.md is required" >&2 + exit 1 +fi + +notes="$(changelog_entry)" +if [[ -z "$(printf "%s" "$notes" | tr -d '[:space:]')" ]]; then + echo "error: CHANGELOG.md does not contain a ## ${version} entry" >&2 + exit 1 +fi + +require_clean_worktree "$repo_root" +require_clean_worktree "$tap_path" + +git fetch "$remote" main --tags +if [[ "$(git branch --show-current)" != "main" ]]; then + echo "error: release must run from tang main" >&2 + exit 1 +fi +if [[ "$(git rev-parse HEAD)" != "$(git rev-parse "${remote}/main")" ]]; then + echo "error: local main is not equal to ${remote}/main" >&2 + exit 1 +fi +git -C "$tap_path" fetch origin main --tags +if [[ "$(git -C "$tap_path" branch --show-current)" != "main" ]]; then + echo "error: tap checkout must be on main" >&2 + exit 1 +fi +git -C "$tap_path" merge --ff-only origin/main + +if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + echo "error: local tag already exists: ${tag}" >&2 + exit 1 +fi +if git ls-remote --exit-code --tags "$remote" "$tag" >/dev/null 2>&1; then + echo "error: remote tag already exists: ${tag}" >&2 + exit 1 +fi +if gh release view "$release_tag" --repo "$release_repo" >/dev/null 2>&1; then + echo "error: GitHub release already exists: ${release_tag}" >&2 + exit 1 +fi + +go test ./... +make build + +mkdir -p dist +{ + echo "macOS arm64 prebuilt binary for tang ${version}." + echo + echo "Changes:" + printf "%s\n" "$notes" +} >"$notes_file" + +if [[ "$dry_run" == "true" ]]; then + git tag -a "$tag" -m "tang ${tag}" + temporary_tag_created="true" + scripts/release-homebrew.sh "$version" --repo "$release_repo" +else + git tag -a "$tag" -m "tang ${tag}" + git push "$remote" "$tag" + scripts/release-homebrew.sh "$version" --upload --repo "$release_repo" --notes-file "$notes_file" +fi + +sha="$(shasum -a 256 "dist/${asset_name}.tar.gz" | awk '{print $1}')" +formula_path="$tap_path/Formula/tang.rb" +if [[ "$dry_run" == "true" ]]; then + formula_path="dist/tang.rb" +fi +update_formula "$formula_path" "$sha" + +if [[ "$dry_run" == "true" ]]; then + echo "dry-run: wrote $formula_path" + exit 0 +fi + +git -C "$tap_path" add Formula/tang.rb README.md +git -C "$tap_path" commit -m "Update tang to ${version}" +git -C "$tap_path" push origin main + +if brew tap | grep -qx "onevcat/tap"; then + installed_tap_path="$(brew --repo onevcat/tap)" + git -C "$installed_tap_path" pull --ff-only +fi + +HOMEBREW_NO_AUTO_UPDATE=1 brew audit --strict --online onevcat/tap/tang +HOMEBREW_NO_AUTO_UPDATE=1 brew reinstall onevcat/tap/tang +HOMEBREW_NO_AUTO_UPDATE=1 brew test onevcat/tap/tang +tang version