fix(ce-update): compare against main plugin.json, not release tags (#660)
Some checks failed
CI / pr-title (push) Has been cancelled
CI / test (push) Has been cancelled
Release PR / release-pr (push) Has been cancelled
Release PR / publish-cli (push) Has been cancelled

This commit is contained in:
Trevin Chow
2026-04-23 14:36:58 -07:00
committed by GitHub
parent 5e6ec41b95
commit 351d12ec5b
2 changed files with 169 additions and 29 deletions

View File

@@ -13,8 +13,9 @@ ce_platforms: [claude]
# Check Plugin Version
Verify the installed compound-engineering plugin version matches the latest
release, and recommend the update command if it doesn't. Claude Code only.
Verify the installed compound-engineering plugin version matches the upstream
`plugin.json` on `main`, and recommend the update command if it doesn't.
Claude Code only.
## Pre-resolved context
@@ -28,11 +29,17 @@ at skill-load time. For a marketplace-cached install it looks like
`~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>/skills/ce-update`,
so the currently-loaded version is the basename two `dirname` levels up.
The upstream version comes from `plugins/compound-engineering/.claude-plugin/plugin.json`
on `main` rather than the latest GitHub release tag, because the marketplace
installs plugin contents from `main` HEAD. Comparing against release tags
false-positives whenever `main` is ahead of the last tag (the normal state
between releases).
**Skill directory:**
!`echo "${CLAUDE_SKILL_DIR}"`
**Latest released version:**
!`gh release list --repo EveryInc/compound-engineering-plugin --limit 30 --json tagName --jq '[.[] | select(.tagName | startswith("compound-engineering-v"))][0].tagName | sub("compound-engineering-v";"")' 2>/dev/null || echo '__CE_UPDATE_VERSION_FAILED__'`
**Latest upstream version:**
!`version=$(gh api repos/EveryInc/compound-engineering-plugin/contents/plugins/compound-engineering/.claude-plugin/plugin.json --jq '.content | @base64d | fromjson | .version' 2>/dev/null) && [ -n "$version" ] && echo "$version" || echo '__CE_UPDATE_VERSION_FAILED__'`
**Currently loaded version:**
!`case "${CLAUDE_SKILL_DIR}" in */plugins/cache/*/compound-engineering/*/skills/ce-update) basename "$(dirname "$(dirname "${CLAUDE_SKILL_DIR}")")" ;; *) echo '__CE_UPDATE_NOT_MARKETPLACE__' ;; esac`
@@ -49,8 +56,8 @@ requires Claude Code and stop. No further action.
### 2. Handle failure cases
If **Latest released version** contains `__CE_UPDATE_VERSION_FAILED__`: tell
the user the latest release could not be fetched (gh may be unavailable or
If **Latest upstream version** contains `__CE_UPDATE_VERSION_FAILED__`: tell
the user the upstream version could not be fetched (gh may be unavailable or
rate-limited) and stop.
If **Currently loaded version** contains `__CE_UPDATE_NOT_MARKETPLACE__`: this
@@ -68,13 +75,13 @@ Then stop.
### 3. Compare versions
**Up to date**`{currently loaded} == {latest}`:
**Up to date**`{currently loaded} == {latest upstream}`:
> "compound-engineering **v{version}** is installed and up to date."
**Out of date**`{currently loaded} != {latest}`:
**Out of date**`{currently loaded} != {latest upstream}`:
> "compound-engineering is on **v{currently loaded}** but **v{latest}** is available.
> "compound-engineering is on **v{currently loaded}** but **v{latest upstream}** is available.
>
> Update with:
> ```

View File

@@ -1,32 +1,165 @@
import { readFileSync } from "fs"
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"
import { execFileSync } from "child_process"
import { tmpdir } from "os"
import path from "path"
import { describe, expect, test } from "bun:test"
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/556.
//
// `CLAUDE_PLUGIN_ROOT` is set by the Claude Code harness to the currently-loaded
// plugin version directory (e.g. `~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>`).
// It is NOT the plugins cache root. Appending `/cache/<anything>/compound-engineering/`
// onto it produces a path that never exists, which causes the cache-probe to fail
// and emit the `__CE_UPDATE_CACHE_FAILED__` sentinel on every healthy install.
//
// This has regressed twice. Fail fast if the antipattern reappears.
const SKILL_PATH = path.join(
process.cwd(),
"plugins/compound-engineering/skills/ce-update/SKILL.md",
)
const SKILL_BODY = readFileSync(SKILL_PATH, "utf8")
describe("ce-update SKILL.md", () => {
const skillPath = path.join(
process.cwd(),
"plugins/compound-engineering/skills/ce-update/SKILL.md",
)
const body = readFileSync(skillPath, "utf8")
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/556.
//
// `CLAUDE_PLUGIN_ROOT` points at the currently-loaded plugin version directory
// (e.g. `~/.claude/plugins/cache/<marketplace>/compound-engineering/<version>`),
// NOT the plugins cache root. Appending `/cache/<anything>/compound-engineering/`
// produces a path that never exists, which caused the cache-probe to fail and
// emit `__CE_UPDATE_CACHE_FAILED__` on every healthy install. Has regressed twice.
test("does not append a /cache/<marketplace>/ suffix onto CLAUDE_PLUGIN_ROOT", () => {
// Matches any `${CLAUDE_PLUGIN_ROOT}/cache/<segment>/` or `${CLAUDE_PLUGIN_ROOT}/cache/<segment>"`
// pattern. The harness variable is already under `cache/<marketplace>/`,
// so concatenating another `cache/...` segment is always wrong.
const antiPattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/cache\//
expect(
antiPattern.test(body),
antiPattern.test(SKILL_BODY),
"ce-update/SKILL.md reintroduced the ${CLAUDE_PLUGIN_ROOT}/cache/... antipattern — derive the cache dir from dirname \"${CLAUDE_PLUGIN_ROOT}\" instead.",
).toBe(false)
})
})
// Regression guard for https://github.com/EveryInc/compound-engineering-plugin/issues/659.
//
// The marketplace installs plugin contents from `main` HEAD, so the cache
// folder basename reflects `plugin.json` at install time — not any release tag.
// Comparing the installed folder against the latest GitHub release tag caused
// a persistent false-positive "Out of date" whenever `main` was ahead of the
// last tag (the normal state between releases), and the prescribed fix
// (`claude plugin update ...`) reinstalled the same version, looping forever.
//
// Rather than grep-testing the literal shell string, this suite extracts the
// "Latest upstream version" pre-resolution command and executes it against a
// mocked `gh` that returns distinguishable values for `gh api` vs
// `gh release list`. The command must report the version from `plugin.json`,
// not from release tags.
describe("ce-update 'Latest upstream version' pre-resolution command", () => {
test("declares a 'Latest upstream version' pre-resolution section", () => {
// Fails loudly if the SKILL structure regresses (renamed section, removed
// pre-resolution block) so behavioral tests below can rely on it.
expect(SKILL_BODY).toMatch(/\*\*Latest upstream version:\*\*\s*\n!`[^`\n]+`/)
})
test("returns the version from main's plugin.json, not any release tag", () => {
// Chosen so a tag-based fallback would produce a clearly different value
// than the plugin.json-based read. Either 1.0.0 or an empty/sentinel
// output indicates the command is reading the wrong source.
const pluginJsonVersion = "99.0.0"
const releaseTagVersion = "1.0.0"
const stdout = runUpstreamCommand(extractUpstreamVersionCommand(SKILL_BODY), {
pluginJsonVersion,
releaseTagVersion,
})
expect(stdout).toBe(pluginJsonVersion)
})
test("emits __CE_UPDATE_VERSION_FAILED__ when upstream plugin.json cannot be read", () => {
// Simulates gh failing entirely (missing auth, offline, rate-limited).
// The fallback must produce the sentinel so the skill's decision logic
// can stop rather than silently compare against an empty string — a
// pipeline-style `|| echo` only catches last-stage failures, and jq on
// empty input exits 0 with no output.
const stdout = runUpstreamCommand(extractUpstreamVersionCommand(SKILL_BODY), {
ghExitCode: 1,
})
expect(stdout).toContain("__CE_UPDATE_VERSION_FAILED__")
})
})
/**
* Extract the shell command between `**Latest upstream version:**` and the
* next blank line in SKILL.md. The command sits on the next line wrapped in
* backticks with a leading `!` (Claude Code's pre-resolution syntax).
*/
function extractUpstreamVersionCommand(body: string): string {
const match = body.match(/\*\*Latest upstream version:\*\*\s*\n!`([^`\n]+)`/)
if (!match) {
throw new Error(
`Could not extract 'Latest upstream version' pre-resolution command from ${SKILL_PATH}`,
)
}
return match[1]
}
type MockOptions = {
pluginJsonVersion?: string
releaseTagVersion?: string
ghExitCode?: number
}
/**
* Run the skill's upstream-version command with a mocked `gh` on PATH.
* The mock emits distinct payloads for `gh api` vs `gh release list` so the
* test can prove which source the command actually reads from.
*/
function runUpstreamCommand(command: string, options: MockOptions): string {
const { pluginJsonVersion, releaseTagVersion, ghExitCode } = options
const mockDir = mkdtempSync(path.join(tmpdir(), "ce-update-gh-"))
try {
const pluginJsonB64 = pluginJsonVersion
? Buffer.from(
JSON.stringify({ name: "compound-engineering", version: pluginJsonVersion }),
).toString("base64")
: ""
const releaseJson = releaseTagVersion
? JSON.stringify([{ tagName: `compound-engineering-v${releaseTagVersion}` }])
: "[]"
// Emulate gh's behaviour without requiring host `jq`: real `gh --jq` uses
// gojq embedded in the binary, so neither the skill nor this mock needs
// an external jq on PATH. When the skill asks a `--jq` filter that
// extracts `.version`, we emit the pre-computed plugin.json version; when
// it asks for `.tagName`, we emit the pre-computed release tag. Any other
// filter is unexpected and the mock fails loudly so the test doesn't pass
// by accident.
const ghScript = `#!/bin/bash
${ghExitCode !== undefined ? `exit ${ghExitCode}` : `
subcommand="$1"; shift
jq_filter=""
while [ $# -gt 0 ]; do
case "$1" in
--jq) jq_filter="$2"; shift 2 ;;
*) shift ;;
esac
done
case "$subcommand" in
api)
case "$jq_filter" in
*'.version'*) printf '%s\\n' '${pluginJsonVersion ?? ""}' ;;
'') printf '%s\\n' '{"content":"${pluginJsonB64}"}' ;;
*) echo "unexpected --jq filter for gh api: $jq_filter" >&2; exit 2 ;;
esac
;;
release)
# If the skill ever falls back to release-tag lookup, this is what it gets.
case "$jq_filter" in
*'tagName'*) printf '%s\\n' '${releaseTagVersion ?? ""}' ;;
'') printf '%s\\n' '${releaseJson}' ;;
*) echo "unexpected --jq filter for gh release: $jq_filter" >&2; exit 2 ;;
esac
;;
*) exit 1 ;;
esac
`}`
const ghPath = path.join(mockDir, "gh")
writeFileSync(ghPath, ghScript)
chmodSync(ghPath, 0o755)
return execFileSync("bash", ["-c", command], {
env: { ...process.env, PATH: `${mockDir}:${process.env.PATH ?? ""}` },
encoding: "utf8",
}).trim()
} finally {
rmSync(mockDir, { recursive: true, force: true })
}
}