Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .claude/plans/nx-affected-release-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Plan: nx-affected release pipeline

## Problem

`release.yaml` runs on **every** push to `main` (no path filter). It cuts a repo-wide
`vX.Y.Z` GitHub release via `release-it` and opens a Homebrew formula bump PR
(`agent-team` / `claude-team`). Those formulas ship **only** `bin/**` scripts
(`agent-team.rb.gotmpl` installs `bin/claude-team`, `bin/ct`, `bin/agent-launch.ts`,
`bin/lib/*`). So a `plugins/**`-only change produces a new agent-team release +
Homebrew PR that installs **byte-identical** binaries — pure churn.

Meanwhile `cd.yaml` already correctly path-filters to `plugins/**` and owns the
plugin version bump + `marketplace.json` regen.

## Goal (handler directive)

> Each module that releases gets its own release run script; the nx task graph makes
> `release` depend on the former tasks; `release.yaml` runs the release task for what's
> been **affected since last release**; a floating `last-released` tag marks the last
> released commit and is used with nx to compute affected modules; on success the
> workflow moves the tag. Lean into the nx monorepo pattern.

## Design

### Module → release mapping

| nx project | What "release" means | Today |
| ------------------------- | ---------------------------------------------------------------- | -------------------- |
| `root` | `release-it --ci` → version + tag + GitHub release + changelog | `release.yaml` job 1 |
| `@nsheaps/agents-plugins` | bump changed plugin versions + regen `marketplace.json` + commit | `cd.yaml` main job |

Other apps/packages/services get **no** `release` target yet — nx auto-skips projects
without one, so they stay inert until they actually publish (YAGNI).

### Each release script is self-contained

So `nx affected --target=release` can run both without dirty-tree coupling:

- **root** `release` = `release-it --ci` (already commits/tags/pushes/creates GH release).
- **plugins** `release` = `plugins/release.sh`: runs `mise run auto-bump-plugins` +
`mise run update-marketplace` (file edits only) then commits + pushes its own slice
(`plugins/claude-code/*/.claude-plugin/plugin.json` + `.claude-plugin/marketplace.json`)
with the resilient sync+retry pattern lifted from today's `cd.yaml`.

### nx task graph

`nx.json` → `targetDefaults.release`:

```jsonc
"release": { "dependsOn": ["build", "test", "lint", "^release"], "cache": false }
```

A module builds/tests/lints (and upstream deps release) before it releases. nx omits
edges for targets a project lacks (root has no build/test; plugins has no build).

### Floating tag

- Introduce lightweight floating tag **`last-released`**, retire `cd/last-release`.
- `release.yaml` resolves base = `last-released` (fallback `HEAD~1` when absent — first run).
- nx affected: `bunx nx show projects --affected --target=release --base="$BASE"`.
- plugins `release.sh` uses the same base (`RELEASE_BASE` env) for per-plugin diffing.
- **On success only**, `release.yaml` moves `last-released` to HEAD and force-pushes it.

### release.yaml (thin, affected-driven)

1. checkout `fetch-depth: 0`; fetch tags; auth app token; setup mise; `bun install`.
2. resolve `BASE` (`last-released` or `HEAD~1`).
3. `AFFECTED=$(bunx nx show projects --affected --target=release --base="$BASE" --head=HEAD)`.
4. `RELEASE_BASE="$BASE" bunx nx affected --target=release --base="$BASE" --head=HEAD`.
5. if `root` ∈ AFFECTED → run the Homebrew formula PR job (reads the `v*` tag release-it made).
6. on success → move `last-released` to HEAD, force-push.

### cd.yaml → preview-only

- Keep the PR `version-preview` job (sticky comment + `::notice` annotations).
- Remove the `bump-and-update-marketplace` main job (now the plugins `release` target).
- Remove the `cd/last-release` tag update.

### Docs

Update `docs/MONOREPO.md` release/CD section + tag name.

## Net effect on the bug

- `plugins/**`-only merge → only `@nsheaps/agents-plugins` affected → only marketplace/version
bump runs. No agent-team GitHub release, no Homebrew PR.
- `bin/**` change → `root` affected → release-it + Homebrew fire.

## Testable here vs CI-only

- **Local (this sandbox):** nx project/target graph, affected resolution
(`nx show projects --affected`), script syntax (`bash -n`, shellcheck), prettier/lint.
- **CI-only (no GitHub API / cross-repo here):** real release-it GitHub release, the
cross-repo Homebrew PR, the `last-released` tag move, concurrent-merge resilience.
These are called out in the PR for reviewer/CI validation.

## Follow-ups (out of scope — move-first)

- Scope release-it's changelog to root-only commit paths (currently scans repo-wide).
- Switch CI lint/build/test to `nx affected` too, once the workspace is large enough.
148 changes: 10 additions & 138 deletions .github/workflows/cd.yaml
Original file line number Diff line number Diff line change
@@ -1,43 +1,27 @@
name: cd

# PREVIEW ONLY. The actual plugin version bump + marketplace.json regen now runs on merge
# to main as the `@nsheaps/agents-plugins` module's nx `release` target, driven by
# release.yaml's `nx affected --target=release`. This workflow only previews, on PRs, what
# that release will do — so it never pushes to a branch and never races the release.
'on':
push:
branches:
- main
paths:
- 'plugins/**'
- '.github/workflows/cd.yaml'
# Ignore version/marketplace files to prevent bump loops
- '!plugins/claude-code/*/.claude-plugin/plugin.json'
- '!.claude-plugin/marketplace.json'
pull_request:
paths:
- 'plugins/**'
- '.github/workflows/cd.yaml'
# Ignore version/marketplace files to prevent bump loops
- '!plugins/claude-code/*/.claude-plugin/plugin.json'
- '!.claude-plugin/marketplace.json'
workflow_dispatch:
inputs:
from_sha:
description: 'commit sha from...HEAD to determine what to bump (overrides tag-based detection)'
required: false
default: ''
type: string

concurrency:
# On push to main, share concurrency group with CI to prevent races where
# both CI (lint auto-fix) and CD (version bump) try to push commits
# simultaneously. On PRs, use a separate group since CD only previews.
group: ${{ github.ref == 'refs/heads/main' && 'ci-cd-main' || format('cd-{0}', github.ref) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: cd-${{ github.ref }}
cancel-in-progress: true

jobs:
# For PRs: PREVIEW ONLY. Computes the version bumps + marketplace updates that
# WILL happen on merge, but does NOT commit or push anything to the PR branch.
# Bumping versions and regenerating the shared marketplace.json inside PR
# branches caused constant cross-PR merge conflicts; the real bump now happens
# exactly once on merge to main (see bump-and-update-marketplace below).
# PREVIEW ONLY. Computes the version bumps + marketplace updates that WILL happen when
# this PR merges to main (where they run as the plugins module's nx release target), but
# does NOT commit or push anything to the PR branch. Bumping versions and regenerating the
# shared marketplace.json inside PR branches caused constant cross-PR merge conflicts.
# The preview surfaces two ways:
# 1. A sticky PR comment with the version table.
# 2. ::notice annotations on each affected plugin.json showing the pending bump.
Expand Down Expand Up @@ -117,115 +101,3 @@ jobs:
_Preview only — plugin versions and `marketplace.json` are bumped automatically on merge to `main`, **not** in this PR. Manual bumps to higher versions are preserved. See the file annotations for the pending change on each `plugin.json`._

${{ steps.bump.outputs.report-md }}

# For main: the SOLE place bumps + marketplace updates are committed.
# Auto-bumps each changed plugin (patch), preserving any manual bumps to a
# higher version, then regenerates and commits marketplace metadata.
bump-and-update-marketplace:
runs-on: ubuntu-latest
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
concurrency:
group: cd-main
cancel-in-progress: false
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Authenticate as GitHub App
id: auth
uses: ./.github/actions/github-app-auth
with:
app-id: ${{ secrets.AUTOMATION_GITHUB_APP_ID }}
private-key: ${{ secrets.AUTOMATION_GITHUB_APP_PRIVATE_KEY }}

- name: Resolve base ref from release tag
id: resolve-base
env:
FROM_SHA: ${{ inputs.from_sha }}
run: |
TAG_NAME="cd/last-release"
git fetch origin --tags
# workflow_dispatch with explicit from_sha takes priority
if [ -n "$FROM_SHA" ]; then
echo "base-ref=$FROM_SHA" >> $GITHUB_OUTPUT
echo "Using workflow_dispatch override: $FROM_SHA"
elif git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
echo "base-ref=$TAG_NAME" >> $GITHUB_OUTPUT
echo "Using release tag '$TAG_NAME' ($(git rev-parse --short $TAG_NAME)) as base ref"
else
echo "base-ref=HEAD~1" >> $GITHUB_OUTPUT
echo "Release tag '$TAG_NAME' not found, falling back to HEAD~1"
fi

- name: Setup mise
uses: jdx/mise-action@v4

- name: Install dependencies
run: bun install --frozen-lockfile

# Bump versions, regenerate marketplace, and push — resilient to main
# advancing under us. Feature PRs merge to main in rapid succession, so
# the SHA checked out at trigger time can be stale by the time we push;
# a bare `git push` then fails with "non-fast-forward" and the marketplace
# update is silently lost (see drift between plugin.json and
# marketplace.json). Both the version bumps and marketplace.json are
# fully DERIVED (bumps from the cd/last-release diff, marketplace from the
# plugin.json set), so on every attempt we resync to the latest origin/main
# and recompute from scratch. This is self-healing: if a concurrent run
# already bumped a plugin and advanced cd/last-release, our recompute
# simply produces no further changes.
- name: Bump versions, update marketplace, and push (sync + retry)
shell: bash
env:
BASE_REF: ${{ steps.resolve-base.outputs.base-ref }}
run: |
set -euo pipefail
MAX_ATTEMPTS=5
attempt=0
while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do
attempt=$((attempt + 1))

# Resync to the latest main (and tags, in case a concurrent CD run
# already moved cd/last-release) and start from a clean tree.
git fetch origin main --tags --force
git reset --hard origin/main

# Recompute bumps against the (stable) release-tag base, then
# regenerate marketplace.json from the resulting plugin.json set.
mise run auto-bump-plugins "--change-base=$BASE_REF" "--version-base=$BASE_REF" >/dev/null
mise run update-marketplace
mise run format 2>/dev/null || true

# Stage only the files this job is allowed to touch.
git add plugins/claude-code/*/.claude-plugin/plugin.json .claude-plugin/marketplace.json 2>/dev/null || true

if git diff --cached --quiet; then
echo "No version/marketplace changes to push"
break
fi

git commit --no-verify -m 'chore: bump plugin versions and update marketplace [skip ci]'

if git push origin HEAD:main; then
echo "Pushed plugin/marketplace updates on attempt $attempt"
break
fi

if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then
echo "::error::CD push failed after $MAX_ATTEMPTS attempts (main kept advancing)"
exit 1
fi
echo "Push rejected (attempt $attempt) — main advanced; resyncing and retrying"
sleep $((attempt * 3))
done

- name: Update release tag
run: |
TAG_NAME="cd/last-release"
git tag -f "$TAG_NAME" HEAD
git push origin "$TAG_NAME" --force
echo "Moved tag '$TAG_NAME' to $(git rev-parse --short HEAD)"
Loading
Loading