diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7f2d6eba..6ee54c4b 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -78,12 +78,14 @@ git push && git push --tags ./scripts/tag-plugins.sh X.Y.Z # 10. Push each extension to its standalone GitHub repo -# (tractorjuice/arckit-gemini, arckit-codex, …): +# (tractorjuice/arckit-gemini, arckit-codex, …). This also creates or +# preserves each extension repo's vX.Y.Z tag and GitHub Release: ./scripts/push-extensions.sh ``` After step 10, confirm the GitHub Release was created (the `release.yml` workflow runs on the -`vX.Y.Z` tag push) and report the release URL and which extension repos were pushed. +`vX.Y.Z` tag push). Also confirm every standalone extension repo has a `vX.Y.Z` tag and GitHub +Release, then report the release URLs and which extension repos were pushed. ## Common Gotchas @@ -113,7 +115,12 @@ The highest-signal failures — collected from real releases. Read these before the same number by design; don't try to skew them. - **`push-extensions.sh` needs `GH_TOKEN`** and skips repos that don't yet exist on GitHub — a "skipped" line is not an error for a brand-new extension, but double-check it's not skipping a - repo that *should* exist. + repo that *should* exist. It now creates/preserves standalone extension `vX.Y.Z` tags and + GitHub Releases; use `ARCKIT_SKIP_EXTENSION_RELEASES=1` only when intentionally doing a + commit-only sync. +- **Do not put release numbers in extension READMEs.** Extension release identity lives in + `VERSION` files, manifests, Git tags, and GitHub Releases. README-pinned versions drift and + are blocked by `tests/plugin/test_release_process.py`. - **Order is load-bearing.** bump → convert → commit → validate → tag → tag-plugins → push-extensions. Re-running an earlier step after a later one (e.g. editing files after the commit) means the tag no longer points at the released tree. If you edit after committing, redo from the commit. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 60469297..85c0890f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -33,7 +33,7 @@ ArcKit ships in seven formats, each with its own version file. They are all bump |--------|---------| | `scripts/bump-version.sh ` | Updates all version files in one pass | | `scripts/generate-release-notes.sh [prev-tag]` | Parses `git log` between tags into Keep a Changelog markdown (Added / Fixed / Changed / Breaking Changes), filters out `chore: bump version` commits, auto-detects previous tag if omitted | -| `scripts/push-extensions.sh [name...]` | Pushes extension dirs to their separate GitHub repos (`tractorjuice/arckit-gemini`, `tractorjuice/arckit-codex`, etc.). Uses `GH_TOKEN`. Skips repos that don't yet exist on GitHub | +| `scripts/push-extensions.sh [name...]` | Pushes extension dirs to their separate GitHub repos (`tractorjuice/arckit-gemini`, `tractorjuice/arckit-codex`, etc.), then creates or preserves each repo's `vX.Y.Z` tag and GitHub Release. Uses `GH_TOKEN`. Skips repos that don't yet exist on GitHub. Set `ARCKIT_SKIP_EXTENSION_RELEASES=1` only for a commit-only sync | | `.github/workflows/release.yml` | Creates the GitHub Release automatically on `v*` tag push (tag-push triggered, does not commit back to main) | ## Development Workflow @@ -87,10 +87,21 @@ claude plugin prune --dry-run git tag -a vX.Y.Z -m "vX.Y.Z" git push && git push --tags -# 10. Push to extension repos (Gemini, Codex, etc.) +# 10. Push to extension repos (Gemini, Codex, etc.). +# This also publishes each extension repo's vX.Y.Z tag and GitHub Release. ./scripts/push-extensions.sh ``` +After step 10, verify the umbrella GitHub Release and every extension GitHub Release exists: + +- `tractorjuice/arc-kit` +- `tractorjuice/arckit-gemini` +- `tractorjuice/arckit-codex` +- `tractorjuice/arckit-opencode` +- `tractorjuice/arckit-copilot` +- `tractorjuice/arckit-paperclip` +- `tractorjuice/arckit-vibe` + ### Note on `claude plugin tag` This command creates `{plugin-name}--vX.Y.Z` style tags (e.g. `arckit--v4.14.0`), which would not trigger `.github/workflows/release.yml` (it matches `v[0-9]+.[0-9]+.[0-9]+`). We use `--dry-run` for its validation behaviour only — it cross-checks `plugins/arckit-claude/.claude-plugin/plugin.json` against the marketplace entry in `.claude-plugin/marketplace.json` and exits non-zero on mismatch, catching version drift before the real `git tag -a vX.Y.Z` runs. @@ -115,6 +126,10 @@ After the umbrella tag (step 9), also create native per-plugin tags: This creates `arckit--vX.Y.Z`, `arckit-uae--vX.Y.Z`, ..., `arckit-at--vX.Y.Z` for the Claude Code plugin system's bookkeeping. Idempotent — re-running skips tags that already exist. +Extension README files do not carry release numbers. Keep release identity in `VERSION` files, +manifests, tags, and GitHub Releases so README content cannot drift from marketplace-visible +artifacts. + ## Adding New Package Data Files Update `pyproject.toml` `[tool.hatch.build.targets.wheel.shared-data]` so the file ships with the CLI wheel. diff --git a/extensions/arckit-codex/README.md b/extensions/arckit-codex/README.md index a57e3531..9ad8580b 100644 --- a/extensions/arckit-codex/README.md +++ b/extensions/arckit-codex/README.md @@ -322,10 +322,4 @@ arckit-codex/ └── guides/ # Command usage guides ``` -## Version - -**Current Release: v5.14.0** - ---- - **ArcKit Codex CLI Extension** -- Generated by `scripts/converter.py` diff --git a/extensions/arckit-vibe/README.md b/extensions/arckit-vibe/README.md index 2aa1f34a..5bafdb96 100644 --- a/extensions/arckit-vibe/README.md +++ b/extensions/arckit-vibe/README.md @@ -3,8 +3,6 @@ The Enterprise Architecture Governance Harness for Mistral Vibe CLI. > **Status**: Beta (Community Preview) 🟡 -> **Version**: 5.14.0 -> **ArcKit Version**: 5.14.0 > > **Note**: This extension is currently in beta. The extension is published from the standalone `tractorjuice/arckit-vibe` repository and regenerated from ArcKit's canonical plugin sources. @@ -401,13 +399,6 @@ MIT License - see [LICENSE](LICENSE) file for details. - **Discussion**: [GitHub Discussions](https://github.com/tractorjuice/arc-kit/discussions) - **ArcKit Repository**: [tractorjuice/arc-kit](https://github.com/tractorjuice/arc-kit) -## Version History - -| Version | Date | Changes | -|---------|------|---------| -| 5.14.0 | 2026-06-17 | Published as standalone generated extension repository | -| 5.13.1 | 2026-06-16 | Initial Mistral Vibe extension release | - ## Acknowledgments - Built on [Mistral Vibe](https://github.com/mistralai/mistral-vibe) diff --git a/scripts/push-extensions.sh b/scripts/push-extensions.sh index c1c01203..eb2622f7 100755 --- a/scripts/push-extensions.sh +++ b/scripts/push-extensions.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -uo pipefail -# push-extensions.sh — Push extension directories to their separate GitHub repos. +# push-extensions.sh — Publish extension directories to their separate GitHub repos. # Usage: ./scripts/push-extensions.sh [extension...] # # Examples: @@ -9,6 +9,9 @@ set -uo pipefail # ./scripts/push-extensions.sh gemini codex # Push only gemini and codex # # Requires: GH_TOKEN with repo scope, or gh CLI authenticated with push access. +# By default this also creates/preserves a vX.Y.Z tag and GitHub Release in +# each extension repo. Set ARCKIT_SKIP_EXTENSION_RELEASES=1 for a commit-only +# sync. REPO_OWNER="tractorjuice" @@ -40,6 +43,8 @@ fi # ── Read version from root VERSION file ─────────────────────────────────────── VERSION=$(cat "$ROOT_DIR/VERSION") COMMIT_MSG="chore: sync with arc-kit v${VERSION}" +TAG="v${VERSION}" +SKIP_RELEASES="${ARCKIT_SKIP_EXTENSION_RELEASES:-0}" # ── Helpers ─────────────────────────────────────────────────────────────────── green() { printf '\033[0;32m%s\033[0m\n' "$1"; } @@ -55,8 +60,109 @@ check_repo_exists() { fi } +remote_tag_commit() { + local tag="$1" + local commit + + # Annotated tags expose the target commit through ^{}; lightweight tags do not. + commit=$(git ls-remote --tags origin "refs/tags/${tag}^{}" | awk '{print $1}' | head -1) + if [[ -z "$commit" ]]; then + commit=$(git ls-remote --tags origin "refs/tags/${tag}" | awk '{print $1}' | head -1) + fi + printf '%s' "$commit" +} + +ensure_repo_topic() { + local repo_name="$1" + local wanted_topic="$2" + local current_json + local next_json + + current_json=$(gh api "repos/${REPO_OWNER}/${repo_name}/topics" \ + -H 'Accept: application/vnd.github+json' 2>/dev/null) || { + yellow " Could not read topics for ${REPO_OWNER}/${repo_name} — skipping topic check" + return 0 + } + + if jq -e --arg topic "$wanted_topic" '(.names // []) | index($topic)' \ + <<<"$current_json" >/dev/null; then + return 0 + fi + + next_json=$(jq --arg topic "$wanted_topic" \ + '.names = (((.names // []) + [$topic]) | unique)' \ + <<<"$current_json") + + if gh api "repos/${REPO_OWNER}/${repo_name}/topics" \ + -X PUT \ + -H 'Accept: application/vnd.github+json' \ + --input - <<<"$next_json" >/dev/null; then + green " ✓ Added GitHub topic: ${wanted_topic}" + else + yellow " Could not update topics for ${REPO_OWNER}/${repo_name}" + fi +} + +publish_release_artifacts() { + local repo_name="$1" + local head_sha + local existing_tag_commit + local release_notes + + if [[ "$SKIP_RELEASES" == "1" ]]; then + yellow " Extension release publishing disabled by ARCKIT_SKIP_EXTENSION_RELEASES=1" + return 0 + fi + + head_sha=$(git rev-parse HEAD) + existing_tag_commit=$(remote_tag_commit "$TAG") + + if [[ -n "$existing_tag_commit" ]]; then + if [[ "$existing_tag_commit" != "$head_sha" ]]; then + red " Tag ${TAG} already exists but points at ${existing_tag_commit:0:8}, not ${head_sha:0:8}" + return 1 + fi + yellow " Tag ${TAG} already exists at HEAD" + else + echo " Creating tag ${TAG}..." + if ! git tag -a "$TAG" -m "${repo_name} ${TAG}"; then + red " Failed to create tag ${TAG} for ${REPO_OWNER}/${repo_name}" + return 1 + fi + if ! git push --quiet origin "refs/tags/${TAG}"; then + red " Failed to push tag ${TAG} for ${REPO_OWNER}/${repo_name}" + return 1 + fi + green " ✓ Pushed tag ${TAG}" + fi + + if gh release view "$TAG" --repo "${REPO_OWNER}/${repo_name}" &>/dev/null; then + yellow " GitHub Release ${TAG} already exists" + return 0 + fi + + release_notes=$(cat </dev/null; then + green " ✓ Created GitHub Release ${TAG}" + else + red " Failed to create GitHub Release ${TAG} for ${REPO_OWNER}/${repo_name}" + return 1 + fi +} + # ── Main loop ───────────────────────────────────────────────────────────────── -PUSHED=0 +PROCESSED=0 SKIPPED=0 FAILED=0 @@ -116,38 +222,46 @@ for target in "${TARGETS[@]}"; do git add -A if git diff --cached --quiet; then yellow " No changes — already up to date" - ((SKIPPED++)) - cd "$ROOT_DIR" - continue - fi + else + # Show summary of changes + CHANGED=$(git diff --cached --stat | tail -1) + echo " Changes: $CHANGED" - # Show summary of changes - CHANGED=$(git diff --cached --stat | tail -1) - echo " Changes: $CHANGED" + # Commit and push + if ! git commit -m "$COMMIT_MSG" --quiet; then + red " Failed to commit changes for ${REPO_OWNER}/${repo_name}" + ((FAILED++)) + cd "$ROOT_DIR" + continue + fi + if ! git push --quiet; then + red " Failed to push ${REPO_OWNER}/${repo_name}" + ((FAILED++)) + cd "$ROOT_DIR" + continue + fi + green " ✓ Pushed to ${REPO_OWNER}/${repo_name}" + fi - # Commit and push - if ! git commit -m "$COMMIT_MSG" --quiet; then - red " Failed to commit changes for ${REPO_OWNER}/${repo_name}" - ((FAILED++)) - cd "$ROOT_DIR" - continue + if [[ "$target" == "gemini" ]]; then + ensure_repo_topic "$repo_name" "gemini-cli-extension" fi - if ! git push --quiet; then - red " Failed to push ${REPO_OWNER}/${repo_name}" + + if ! publish_release_artifacts "$repo_name"; then ((FAILED++)) cd "$ROOT_DIR" continue fi - green " ✓ Pushed to ${REPO_OWNER}/${repo_name}" - ((PUSHED++)) + + ((PROCESSED++)) cd "$ROOT_DIR" done # ── Summary ─────────────────────────────────────────────────────────────────── echo "" echo "── Summary ──" -echo " Pushed: $PUSHED" -echo " Skipped: $SKIPPED" -echo " Failed: $FAILED" +echo " Processed: $PROCESSED" +echo " Skipped: $SKIPPED" +echo " Failed: $FAILED" [[ $FAILED -eq 0 ]] || exit 1 diff --git a/tests/plugin/test_release_process.py b/tests/plugin/test_release_process.py new file mode 100644 index 00000000..79dedca5 --- /dev/null +++ b/tests/plugin/test_release_process.py @@ -0,0 +1,96 @@ +"""Release process guardrails.""" + +import json +from pathlib import Path +import re +import tomllib + + +REPO_ROOT = Path(__file__).resolve().parents[2] +PUSH_EXTENSIONS = REPO_ROOT / "scripts" / "push-extensions.sh" +RELEASING_DOC = REPO_ROOT / "docs" / "RELEASING.md" +ROOT_VERSION = REPO_ROOT / "VERSION" +EXPECTED_EXTENSIONS = { + "gemini": ("extensions/arckit-gemini", "arckit-gemini"), + "codex": ("extensions/arckit-codex", "arckit-codex"), + "opencode": ("extensions/arckit-opencode", "arckit-opencode"), + "copilot": ("extensions/arckit-copilot", "arckit-copilot"), + "paperclip": ("extensions/arckit-paperclip", "arckit-paperclip"), + "vibe": ("extensions/arckit-vibe", "arckit-vibe"), +} + +PINNED_README_VERSION_PATTERNS = [ + re.compile(r"Current Release:\s*v?\d+\.\d+\.\d+"), + re.compile(r"\*\*ArcKit Version\*\*:\s*v?\d+\.\d+\.\d+"), + re.compile(r"^\s*>\s*\*\*Version\*\*:\s*v?\d+\.\d+\.\d+", re.MULTILINE), + re.compile(r"^## Version History\s*$", re.MULTILINE), +] + + +def extension_path(extension_key: str) -> Path: + local_dir, _repo_name = EXPECTED_EXTENSIONS[extension_key] + return REPO_ROOT / local_dir + + +def test_extension_readmes_do_not_pin_release_versions(): + failures = [] + + for extension_key in EXPECTED_EXTENSIONS: + readme = extension_path(extension_key) / "README.md" + text = readme.read_text(encoding="utf-8") + for pattern in PINNED_README_VERSION_PATTERNS: + if pattern.search(text): + failures.append(f"{readme.relative_to(REPO_ROOT)} matches {pattern.pattern}") + + assert not failures, "Pinned extension README versions found:\n" + "\n".join(failures) + + +def test_release_process_names_every_standalone_extension(): + script = PUSH_EXTENSIONS.read_text(encoding="utf-8") + release_doc = RELEASING_DOC.read_text(encoding="utf-8") + + for extension_key, (local_dir, repo_name) in EXPECTED_EXTENSIONS.items(): + assert f'[{extension_key}]="{local_dir}:{repo_name}"' in script + assert f"tractorjuice/{repo_name}" in release_doc + assert (REPO_ROOT / local_dir / "README.md").is_file() + assert (REPO_ROOT / local_dir / "VERSION").is_file() + + +def test_extension_version_files_match_root_version(): + root_version = ROOT_VERSION.read_text(encoding="utf-8").strip() + + for extension_key in EXPECTED_EXTENSIONS: + version_file = extension_path(extension_key) / "VERSION" + assert version_file.read_text(encoding="utf-8").strip() == root_version + + gemini_manifest = json.loads( + (extension_path("gemini") / "gemini-extension.json").read_text(encoding="utf-8") + ) + assert gemini_manifest["version"] == root_version + + paperclip_manifest = json.loads( + (extension_path("paperclip") / "package.json").read_text(encoding="utf-8") + ) + assert paperclip_manifest["version"] == root_version + + with (extension_path("vibe") / "vibe-config.toml").open("rb") as f: + vibe_config = tomllib.load(f) + assert vibe_config["extension"]["version"] == root_version + + +def test_push_extensions_publishes_tags_and_github_releases(): + script = PUSH_EXTENSIONS.read_text(encoding="utf-8") + + assert 'TAG="v${VERSION}"' in script + assert "remote_tag_commit" in script + assert 'git tag -a "$TAG"' in script + assert 'git push --quiet origin "refs/tags/${TAG}"' in script + assert 'gh release create "$TAG"' in script + assert "ARCKIT_SKIP_EXTENSION_RELEASES=1" in script + + +def test_push_extensions_prepares_gemini_for_gallery_discovery(): + script = PUSH_EXTENSIONS.read_text(encoding="utf-8") + + assert "ensure_repo_topic" in script + assert "gemini-cli-extension" in script