diff --git a/.agents/skills/kitup-release/SKILL.md b/.agents/skills/kitup-release/SKILL.md new file mode 100644 index 0000000..6aea526 --- /dev/null +++ b/.agents/skills/kitup-release/SKILL.md @@ -0,0 +1,50 @@ +--- +name: kitup-release +description: Use when preparing or publishing a kitup release, including release branch preparation, version bumps, manual PR handoff, manual tags, GitHub Actions publishing, and public smoke checks. +--- + +# kitup Release + +Use this only inside `/Users/x/git/samzong/kitup` or the upstream kitup repository. + +## Release Contract + +Do not publish from a pull request. Do not tag the release branch. + +The maintainer-owned flow is: + +1. Start from a clean, up-to-date `main`. +2. Run one release prep command: + +```bash +make release-patch +make release-minor +make release-major +``` + +3. Let the command create `release/vX.Y.Z`, update versions, run `make check`, and commit `chore: prepare vX.Y.Z release`. +4. Ask the maintainer to open and merge the release PR manually. +5. After merge, the maintainer tags `main` manually: + +```bash +git checkout main +git pull --ff-only +git tag vX.Y.Z +git push origin vX.Y.Z +``` + +## Version Surfaces + +Release prep must keep these in sync: + +- `ts/package.json` +- `rust/Cargo.toml` +- `rust/Cargo.lock` +- `examples/rust/Cargo.lock` +- `go-cobra/go.mod` + +## Automation + +The root `vX.Y.Z` tag triggers `.github/workflows/release.yml`. The workflow runs `make check`, verifies package versions, publishes npm and crates.io packages, creates `go/vX.Y.Z` and `go-cobra/vX.Y.Z`, creates GitHub Release notes, and runs `scripts/smoke-release.sh X.Y.Z`. + +If a registry already accepted a version, do not delete and recreate tags without an explicit recovery plan. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7367eda..9ee8e1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,8 @@ jobs: VERSION="$version" node -e 'const { readFileSync } = require("node:fs"); const version = process.env.VERSION; const pkg = JSON.parse(readFileSync("ts/package.json", "utf8")); if (pkg.version !== version) { throw new Error(`ts/package.json version ${pkg.version} != ${version}`); }' rust_version="$(awk -F '"' '/^version = / { print $2; exit }' rust/Cargo.toml)" test "$rust_version" = "$version" + go_cobra_core_version="$(awk '/github.com\/samzong\/kitup\/go / { print $2; exit }' go-cobra/go.mod)" + test "$go_cobra_core_version" = "v$version" - name: Check published versions id: published run: | @@ -72,16 +74,18 @@ jobs: cargo publish --locked env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - name: Publish Go module tag + - name: Publish Go module tags run: | version="${GITHUB_REF_NAME#v}" - go_tag="go/v${version}" - if git rev-parse -q --verify "refs/tags/${go_tag}" >/dev/null; then - test "$(git rev-list -n 1 "${go_tag}")" = "$GITHUB_SHA" - else - git tag "$go_tag" "$GITHUB_SHA" - git push origin "refs/tags/${go_tag}" - fi + for module in go go-cobra; do + go_tag="${module}/v${version}" + if git rev-parse -q --verify "refs/tags/${go_tag}" >/dev/null; then + test "$(git rev-list -n 1 "${go_tag}")" = "$GITHUB_SHA" + else + git tag "$go_tag" "$GITHUB_SHA" + git push origin "refs/tags/${go_tag}" + fi + done - name: Create GitHub release run: | gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb525da..4679e62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,11 +87,12 @@ The check must pass locally before claiming parity. Do not publish packages from a pull request. -Release tags are cut from `main` after `make check` passes. The release workflow publishes: +Use `make release-patch`, `make release-minor`, or `make release-major` from a clean, up-to-date `main` branch to create the release branch and version commit. Open and merge the release PR manually, then tag `main` manually. The release workflow publishes: - `@kitup/sdk` - `kitup` on crates.io - `github.com/samzong/kitup/go` through the `go/vX.Y.Z` tag +- `github.com/samzong/kitup/go-cobra` through the `go-cobra/vX.Y.Z` tag - GitHub Release notes See [docs/RELEASE.md](docs/RELEASE.md) for the release flow, first npm release recovery, and public install smoke check. diff --git a/Makefile b/Makefile index ae8614f..e5e074c 100644 --- a/Makefile +++ b/Makefile @@ -1,86 +1,119 @@ SHELL := /bin/sh -.DEFAULT_GOAL := check + +BOLD := \033[1m +CYAN := \033[36m +GREEN := \033[32m +RESET := \033[0m + +.DEFAULT_GOAL := help TS_DIR := ts GO_DIR := go +GO_COBRA_DIR := go-cobra RUST_DIR := rust EXAMPLE_TS_DIR := examples/ts EXAMPLE_GO_DIR := examples/go EXAMPLE_RUST_DIR := examples/rust -GO_FILES := $(shell find $(GO_DIR) $(EXAMPLE_GO_DIR) -name '*.go' -type f) +GO_FILES := $(shell find $(GO_DIR) $(GO_COBRA_DIR) $(EXAMPLE_GO_DIR) -name '*.go' -type f) + +# ── Quality ────────────────────────────────────────────────────────────────── -.PHONY: check hooks generate generate-check build build-ts test test-ts test-go test-rust examples example-ts example-go example-rust fmt fmt-ts fmt-go fmt-rust clean clean-ts clean-go clean-rust clean-examples +.PHONY: check test test-ts test-go test-go-cobra test-rust fmt fmt-ts fmt-go fmt-rust -check: +check: ## Full parity gate node scripts/check.mjs -hooks: - git config core.hooksPath .githooks +test: test-ts test-go test-go-cobra test-rust ## Run SDK tests -generate: - node scripts/sync-hosts.mjs +test-ts: ## Run TypeScript tests + pnpm --dir $(TS_DIR) test -generate-check: - node scripts/sync-hosts.mjs --check +test-go: ## Run Go SDK tests + cd $(GO_DIR) && go test ./... -build: generate-check build-ts +test-go-cobra: ## Run Go Cobra adapter tests + cd $(GO_COBRA_DIR) && go test ./... -build-ts: generate-check - pnpm --dir $(TS_DIR) build +test-rust: ## Run Rust SDK tests + cargo test --manifest-path $(RUST_DIR)/Cargo.toml -test: test-ts test-go test-rust +fmt: fmt-ts fmt-go fmt-rust ## Format all SDK code -test-ts: - pnpm --dir $(TS_DIR) test +fmt-ts: ## Format TypeScript code + cd $(TS_DIR) && pnpm exec prettier --write src test ../examples/ts/cli.ts ../scripts/prepare-release.mjs -test-go: - cd $(GO_DIR) && go test ./... +fmt-go: ## Format Go code + gofmt -w $(GO_FILES) -test-rust: - cargo test --manifest-path $(RUST_DIR)/Cargo.toml +fmt-rust: ## Format Rust code + cargo fmt --manifest-path $(RUST_DIR)/Cargo.toml + cargo fmt --manifest-path $(EXAMPLE_RUST_DIR)/Cargo.toml + +# ── Generated Data ─────────────────────────────────────────────────────────── + +.PHONY: generate generate-check -examples: example-ts example-go example-rust +generate: ## Refresh generated host constants + node scripts/sync-hosts.mjs + +generate-check: ## Verify generated host constants + node scripts/sync-hosts.mjs --check + +# ── Examples ───────────────────────────────────────────────────────────────── + +.PHONY: examples example-ts example-go example-rust -example-ts: +examples: example-ts example-go example-rust ## Run all examples + +example-ts: ## Run TypeScript example tmp="$$(mktemp -d)" && mkdir -p "$$tmp/.codex" && HOME="$$tmp" pnpm --dir $(EXAMPLE_TS_DIR) install-skill -example-go: +example-go: ## Run Go example cd $(EXAMPLE_GO_DIR) && tmp="$$(mktemp -d)" && mkdir -p "$$tmp/.codex" && HOME="$$tmp" go run . -example-rust: +example-rust: ## Run Rust example cd $(EXAMPLE_RUST_DIR) && tmp="$$(mktemp -d)" && mkdir -p "$$tmp/.codex" && CARGO_HOME="$${CARGO_HOME:-$$HOME/.cargo}" RUSTUP_HOME="$${RUSTUP_HOME:-$$HOME/.rustup}" HOME="$$tmp" cargo run --quiet -fmt: fmt-ts fmt-go fmt-rust +# ── Release ────────────────────────────────────────────────────────────────── -fmt-ts: - cd $(TS_DIR) && pnpm exec prettier --write src test ../examples/ts/cli.ts +.PHONY: release-patch release-minor release-major -fmt-go: - gofmt -w $(GO_FILES) +release-patch: ## Prepare patch release branch and commit + node scripts/prepare-release.mjs patch -fmt-rust: - cargo fmt --manifest-path $(RUST_DIR)/Cargo.toml - cargo fmt --manifest-path $(EXAMPLE_RUST_DIR)/Cargo.toml +release-minor: ## Prepare minor release branch and commit + node scripts/prepare-release.mjs minor -clean: clean-ts clean-go clean-rust clean-examples +release-major: ## Prepare major release branch and commit + node scripts/prepare-release.mjs major -clean-ts: +# ── Maintenance ────────────────────────────────────────────────────────────── + +.PHONY: hooks clean clean-ts clean-go clean-rust clean-examples + +hooks: ## Install repo git hooks + git config core.hooksPath .githooks + +clean: clean-ts clean-go clean-rust clean-examples ## Remove build artifacts + +clean-ts: ## Remove TypeScript build artifacts rm -rf \ $(TS_DIR)/node_modules \ $(TS_DIR)/dist \ $(TS_DIR)/coverage \ $(TS_DIR)/*.tsbuildinfo -clean-go: +clean-go: ## Remove Go build artifacts rm -f \ - $(GO_DIR)/coverage.out + $(GO_DIR)/coverage.out \ + $(GO_COBRA_DIR)/coverage.out -clean-rust: +clean-rust: ## Remove Rust build artifacts rm -rf \ target \ $(RUST_DIR)/target -clean-examples: +clean-examples: ## Remove example build artifacts rm -rf \ $(EXAMPLE_TS_DIR)/node_modules \ $(EXAMPLE_TS_DIR)/dist \ @@ -88,3 +121,13 @@ clean-examples: $(EXAMPLE_TS_DIR)/*.tsbuildinfo \ $(EXAMPLE_RUST_DIR)/target \ $(EXAMPLE_GO_DIR)/coverage.out + +# ── Help ───────────────────────────────────────────────────────────────────── + +.PHONY: help + +help: ## Show available targets + @awk 'BEGIN {FS = ":.*## "; printf "$(BOLD)kitup$(RESET) — bundled Agent Skill installer SDK\n"} \ + /^# ── / {n = $$0; gsub(/(^# ── | ─+$$)/, "", n); printf "\n$(BOLD)%s$(RESET)\n", n} \ + /^[a-zA-Z_-]+:.*## / {printf " $(CYAN)make %-18s$(RESET) %s\n", $$1, $$2} \ + END {printf "\n"}' $(MAKEFILE_LIST) diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 13a1edd..de0d88e 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,29 +1,57 @@ # Release -`kitup` publishes one version across three SDKs: +`kitup` publishes one version across four package surfaces: - npm: `@kitup/sdk` - crates.io: `kitup` - Go module: `github.com/samzong/kitup/go` +- Go Cobra adapter: `github.com/samzong/kitup/go-cobra` ## Normal Release -1. Update package versions. -2. Run: +Start from an up-to-date `main` branch: ```bash -make check +git checkout main +git pull --ff-only ``` -3. Merge to `main`. -4. Push a release tag: +Prepare the release branch and version commit: ```bash +make release-patch +# or +make release-minor +# or +make release-major +``` + +The release target creates `release/vX.Y.Z`, updates: + +- `ts/package.json` +- `rust/Cargo.toml` +- `rust/Cargo.lock` +- `examples/rust/Cargo.lock` +- `go-cobra/go.mod` + +It then runs `make check` and commits: + +```bash +chore: prepare vX.Y.Z release +``` + +Open the release PR manually. After it is merged, tag the merge commit on `main` manually: + +```bash +git checkout main +git pull --ff-only git tag vX.Y.Z git push origin vX.Y.Z ``` -The release workflow publishes npm and crates.io packages, creates the `go/vX.Y.Z` tag, creates the GitHub Release, and runs the public install smoke check. +Do not tag the release branch. Do not publish packages by hand during the normal flow. + +The release workflow publishes npm and crates.io packages, creates the `go/vX.Y.Z` and `go-cobra/vX.Y.Z` tags, creates the GitHub Release, and runs the public install smoke check. ## First npm Release @@ -44,7 +72,7 @@ The release workflow is resumable: - If npm already has the version, npm publish is skipped. - If crates.io already has the version, crate publish is skipped. -- If `go/vX.Y.Z` already exists, the workflow verifies that it points at the release commit. +- If `go/vX.Y.Z` or `go-cobra/vX.Y.Z` already exists, the workflow verifies that it points at the release commit. Do not delete and recreate a release tag after any registry has accepted the version unless the tag points at the wrong commit and the recovery plan is explicit. @@ -56,4 +84,4 @@ Run the public install smoke check manually with: scripts/smoke-release.sh X.Y.Z ``` -The smoke check installs from npm, crates.io, and the public Go module, then verifies that each SDK can load the default host spec. +The smoke check installs from npm, crates.io, the public Go module, and the public Go Cobra adapter, then verifies that each SDK can load the default host spec or instantiate its adapter. diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100755 index 0000000..6fd762e --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const rootUrl = new URL("../", import.meta.url); +const root = fileURLToPath(rootUrl); +const args = process.argv.slice(2); +const positional = args.filter((arg) => !arg.startsWith("-")); +const flags = args.filter((arg) => arg.startsWith("-")); +const dryRun = flags.includes("--dry-run"); + +if ( + positional.length !== 1 || + !["patch", "minor", "major"].includes(positional[0]) +) { + fail( + "Usage: node scripts/prepare-release.mjs [--dry-run]", + ); +} +if (flags.some((arg) => arg !== "--dry-run")) { + fail(`Unknown flag: ${flags.find((arg) => arg !== "--dry-run")}`); +} + +const bump = positional[0]; +const packagePath = "ts/package.json"; +const cargoPath = "rust/Cargo.toml"; +const goCobraModPath = "go-cobra/go.mod"; +const rustLockPath = "rust/Cargo.lock"; +const exampleRustLockPath = "examples/rust/Cargo.lock"; +const pkg = JSON.parse(read(packagePath)); +const currentVersion = pkg.version; +const nextVersion = bumpVersion(currentVersion, bump); +const tag = `v${nextVersion}`; +const branch = `release/${tag}`; +const changedFiles = [ + packagePath, + cargoPath, + rustLockPath, + exampleRustLockPath, + goCobraModPath, +]; + +if (!dryRun) { + assertCleanMain(branch); + run("git", ["switch", "-c", branch]); +} + +pkg.version = nextVersion; +write(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); +replaceOne(cargoPath, /^version = "([^"]+)"$/m, `version = "${nextVersion}"`); +replaceOne( + goCobraModPath, + /(github\.com\/samzong\/kitup\/go\s+)v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/, + `$1${tag}`, +); + +if (dryRun) { + console.log(`Would create branch: ${branch}`); + console.log(`Would update version: ${currentVersion} -> ${nextVersion}`); + console.log(`Would update files: ${changedFiles.join(", ")}`); + console.log(`Would run: cargo generate-lockfile, make check, git commit -s`); + process.exit(0); +} + +run("cargo", ["generate-lockfile", "--manifest-path", cargoPath]); +run("cargo", [ + "generate-lockfile", + "--manifest-path", + "examples/rust/Cargo.toml", +]); +run("make", ["check"]); +run("git", ["add", ...changedFiles]); +run("git", ["commit", "-s", "-m", `chore: prepare ${tag} release`]); + +console.log(""); +console.log(`Prepared ${tag} on ${branch}.`); +console.log("Open and merge the release PR manually, then tag main manually."); + +function read(path) { + return readFileSync(new URL(path, rootUrl), "utf8"); +} + +function write(path, content) { + if (!dryRun) writeFileSync(new URL(path, rootUrl), content); +} + +function replaceOne(path, pattern, replacement) { + const before = read(path); + let count = 0; + const after = before.replace(pattern, (...match) => { + count += 1; + return replacement.replace( + /\$(\d+)/g, + (_, index) => match[Number(index)] ?? "", + ); + }); + if (count !== 1) fail(`Expected one match in ${path}, found ${count}`); + write(path, after); +} + +function bumpVersion(version, kind) { + if (!/^\d+\.\d+\.\d+$/.test(version)) fail(`Unsupported version: ${version}`); + const parts = version.split(".").map((part) => Number.parseInt(part, 10)); + if (kind === "major") return `${parts[0] + 1}.0.0`; + if (kind === "minor") return `${parts[0]}.${parts[1] + 1}.0`; + return `${parts[0]}.${parts[1]}.${parts[2] + 1}`; +} + +function assertCleanMain(nextBranch) { + const branchName = output("git", ["branch", "--show-current"]); + if (branchName !== "main") + fail(`Release prep must start from main, current branch is ${branchName}`); + if (output("git", ["status", "--porcelain"]) !== "") + fail("Release prep requires a clean worktree"); + if ( + commandSucceeds("git", [ + "rev-parse", + "--verify", + `refs/heads/${nextBranch}`, + ]) + ) { + fail(`Branch already exists: ${nextBranch}`); + } +} + +function run(command, commandArgs) { + console.log(`$ ${[command, ...commandArgs].join(" ")}`); + execFileSync(command, commandArgs, { cwd: root, stdio: "inherit" }); +} + +function output(command, commandArgs) { + return execFileSync(command, commandArgs, { + cwd: root, + encoding: "utf8", + }).trim(); +} + +function commandSucceeds(command, commandArgs) { + try { + execFileSync(command, commandArgs, { cwd: root, stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function fail(message) { + console.error(message); + process.exit(1); +} diff --git a/scripts/smoke-release.sh b/scripts/smoke-release.sh index 073bff4..a672a4f 100755 --- a/scripts/smoke-release.sh +++ b/scripts/smoke-release.sh @@ -70,6 +70,34 @@ GO go run . } +smoke_go_cobra() { + dir="$(mktemp -d "$tmp/go-cobra.XXXXXX")" + cd "$dir" + go mod init kitup-release-smoke-cobra >/dev/null + go get "github.com/samzong/kitup/go-cobra@v$version" >/dev/null + test "$(go list -m -f '{{.Version}}' github.com/samzong/kitup/go)" = "v$version" + cat > main.go <<'GO' +package main + +import ( + "fmt" + + kitup "github.com/samzong/kitup/go" + kitupcobra "github.com/samzong/kitup/go-cobra" +) + +func main() { + cmd := kitupcobra.NewSkillCommand(kitupcobra.Options{}) + if cmd.Use != kitup.InstallUX.SkillUse { + panic(fmt.Sprintf("expected %s, got %s", kitup.InstallUX.SkillUse, cmd.Use)) + } + fmt.Printf("go-cobra ok: %s\n", cmd.Use) +} +GO + go run . +} + retry npm smoke_npm retry rust smoke_rust retry go smoke_go +retry go-cobra smoke_go_cobra