Skip to content
Merged
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
4 changes: 3 additions & 1 deletion scripts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The repo hosts **multiple plugins** (`docks`, `plan-lifecycle`, `effect-kit`) un
| `transformGuard` | run `transform-guard.mjs` (curated transformers) |
| `release` | Closed, data-only release policy. Every plugin declares exactly `{ kind: 'generic', install }`. No callbacks, commands, safety gates, or ordering belong in descriptors. |

`lib/plugin-release.mjs` owns ordinary release ordering behind `runGenericPluginRelease({ argv, repo, plugins, io })`. Its IO value is an exact closed adapter of filesystem, Git, Claude, GitHub, selected-CI, and logging operations; production composes those operations in `release.mjs`, while descriptors remain inert policy data. The engine validates every policy before touching IO and enforces dry-run no-mutation itself rather than trusting an adapter.
`lib/plugin-release.mjs` owns ordinary release ordering behind `runGenericPluginRelease({ argv, repo, plugins, io })`. Its IO value is an exact closed adapter that carries sixteen filesystem, Git, Claude, GitHub, selected-CI, and logging operations. `release.mjs` composes the production operations. Descriptors remain inert policy data. The fifteenth operation, `wouldStageChange`, answers whether `git add` of release bytes would stage anything different from HEAD. Production hashes the proposed bytes with `git hash-object --path <file> --stdin`. It compares that hash with `git rev-parse --quiet --verify HEAD:<path>`. `--path` applies the same clean filters that `git add` applies. The probe never passes `-w`, so it never writes an object. The sixteenth operation, `tagPublished`, answers whether a release tag is already published: it checks the local ref, then asks origin, and refuses to guess when origin is unreachable. It is an adapter operation because reaching origin is IO. A caller that cannot stub it puts the network inside every test of the surrounding decision. The engine validates every policy before touching IO. It enforces dry-run no-mutation itself rather than trusting an adapter.

`ci.mjs` is **registry-driven**. A full invocation runs repo-wide checks once (workflow YAML, both marketplace catalogs, tree/guard, durable anchors, author tooling, unit tests, and CI targeting), then selects every present plugin's shell hooks, repository author suites, and capability-driven `gatePlugin` work. `--plugin <name>` skips repo-wide sections and runs only the named plugin's owned author checks, target-derived shell lint, and plugin validation. When Docks plan author checks apply, CI runs `scripts/tests/plan-cli.mjs` plus `scripts/tests/plan-skill-phases.mjs` with the `bounded-workflows` and `plan-workspace-template` cases. Trigger-collision checks audit Docks and Effect Kit together once.

Expand Down Expand Up @@ -184,6 +184,8 @@ final implementation tree → node scripts/ci.mjs --plugin <name> (LAYER 1 —
└── tag-CI fails → exits non-zero, prints recovery
```

The release tag, not the manifest number, is the fact that a version was released. When manifests are already at this version, a re-cut stages nothing. The release tags existing HEAD instead of creating a commit. A dry run consults the clean-tree gate. On a dirty tree, it reports the refusal instead of forecasting a landing it cannot predict.

The positional flow above is preserved for docks/effect-kit/plan-lifecycle, including its existing bump resolution, local and tag CI gates, commit/push/tag behavior, release notes, and read-only dry run.

GitHub pull requests resolve their diff into a shard set and run `node scripts/ci.mjs --lane <shard>` for each, then require the unchanged `validate` join status. `resolve-shards` maps changed paths onto plugin roots from `lib/plugins.mjs` and emits the matrix; the `repo` shard always runs, `core` runs when the diff implicates a plugin it owns, and every resolution failure — unresolvable base, empty diff, non-pull-request event, or a path outside every plugin root — falls open to both shards. `workflow_dispatch` runs one untargeted `node scripts/ci.mjs` full invocation. A release-tag push strictly resolves the tag's plugin identity, rejects malformed or unknown targets, and runs `node scripts/ci.mjs --plugin <name>` as the authoritative selected-plugin gate; PR sharding never touches that path. The `repo` shard owns the repo-wide workflow, standalone catalog, tree/durable-anchor, and CI-targeting sections, so a plugin shard runs only the selected plugins' owned author checks, shell-hook lint, and plugin gates, including marketplace/version coherence. Targeted `--plugin` CI skips the repo-wide sections entirely. The Bun dependency cache only reduces repeated download work. Its contents are never validation evidence: the frozen lockfile, release preflight, and `ci.mjs` result remain authoritative.
Expand Down
147 changes: 108 additions & 39 deletions scripts/lib/plugin-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

// Preview and execution share these reasons so a dry run cannot drift from the release
// decision it forecasts.
const RECUT_TAG_REASON = 'manifests already at this version — tagging existing HEAD';
const DIRTY_TREE_REASON = 'working tree dirty — commit/stash first';

const IO_KEYS = Object.freeze([
'commit',
'createRelease',
Expand All @@ -15,7 +20,9 @@ const IO_KEYS = Object.freeze([
'readReleaseNotes',
'resolveTagCommit',
'runSelectedCi',
'tagPublished',
'waitForTagCi',
'wouldStageChange',
'writeJson',
]);

Expand Down Expand Up @@ -190,24 +197,6 @@ function parseGenericArgs(argv, plugins) {
return { dryRun, plugin, versionArgument };
}

function releaseTagExists(tag, repo) {
if (
spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], { cwd: repo, stdio: 'ignore' }).status === 0
)
return true;
// Local tags are themselves only a proxy: nothing in this flow fetches, so a clone with stale
// refs would let an already-published version through. origin holds the fact. An unreachable
// origin must not read as "never released" — this flow pushes, tags, and creates a GitHub
// release, so it cannot proceed offline anyway, and refusing to guess costs nothing.
const remote = spawnSync('git', ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`], {
cwd: repo,
encoding: 'utf8',
});
if ((remote.status ?? 1) !== 0)
throw new Error(`cannot reach origin to check whether ${tag} is already released — refusing to guess`);
return (remote.stdout ?? '').trim() !== '';
}

function nextVersion(current, requested) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(current || '');
if (!match) throw new Error(`current version not semver: ${current}`);
Expand All @@ -223,16 +212,27 @@ function formattedJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}

function previewJsonWrite({ original, after, file, repo, io }) {
const originalLines = original.split('\n');
const output = formattedJson(after);
const changed = output
.split('\n')
.filter((line, index) => line !== originalLines[index])
.map((line) => line.trim());
io.log(
` [dry-run] would write ${path.relative(repo, file)} (changed: ${changed.join(' | ') || 'none — formatting drift!'})`,
);
// Preview of the manifest writes a real run would make. A dirty tree is never compared:
// the probe below is only meaningful against a clean index, and the executed release
// refuses on a dirty tree, so no staging decision exists to forecast.
function previewJsonWrites({ repo, io, version, versionChanged, cleanTree }) {
const staged = [];
const preview = (file, after) => {
const relative = path.relative(repo, file);
if (!cleanTree) {
io.log(` [dry-run] ${relative}: not compared (working tree dirty)`);
return;
}
const wouldStage = io.wouldStageChange(file, formattedJson(after));
const action = !wouldStage
? `unchanged (already at ${version})`
: versionChanged
? `would write version → ${version}`
: `would rewrite formatting only (already at ${version})`;
io.log(` [dry-run] ${relative}: ${action}`);
staged.push(wouldStage);
};
return { preview, staged };
}

function reportedFailure(message) {
Expand Down Expand Up @@ -268,7 +268,13 @@ export async function runGenericPluginRelease({ argv, repo, plugins, io }) {
if (!dryRun && !io.ensureTool('claude')) throw new Error('claude is required');
if (!io.fileExists(pluginJson)) throw new Error(`plugin.json not found at ${pluginJson}`);
if (!io.fileExists(marketplaceJson)) throw new Error(`marketplace.json not found at ${marketplaceJson}`);
if (!dryRun && !io.ensureCleanTree()) throw new Error('working tree dirty — commit/stash first');
const cleanTree = io.ensureCleanTree();
let dryRunBlocked = false;
if (!cleanTree) {
if (!dryRun) throw new Error(DIRTY_TREE_REASON);
io.log(` [dry-run] refused: ${DIRTY_TREE_REASON}`);
dryRunBlocked = true;
}

io.log(`Running local ci.mjs for ${plugin.name}...`);
const ciResult = await io.runSelectedCi(plugin, ['-q', '--plugin', plugin.name]);
Expand All @@ -284,38 +290,54 @@ export async function runGenericPluginRelease({ argv, repo, plugins, io }) {
// tagged. The manifest is a proxy for "already released", not the fact: a run that bumps the
// manifest and then fails CI leaves the number written but unpublished, and the recovery this
// tool itself prints is to re-cut exactly that number.
if (newVersion === currentVersion && releaseTagExists(`${plugin.name}--v${newVersion}`, repo)) {
if (newVersion === currentVersion && io.tagPublished(`${plugin.name}--v${newVersion}`)) {
throw new Error(`already released: ${plugin.name} v${newVersion} (tag ${plugin.name}--v${newVersion} exists)`);
}
io.log(`Bumping ${plugin.name}: ${currentVersion} → ${newVersion}`);
const versionChanged = newVersion !== currentVersion;
const { preview, staged: wouldStageChanges } = previewJsonWrites({
repo,
io,
version: newVersion,
versionChanged,
cleanTree,
});

const pluginOriginal = dryRun ? formattedJson(pluginManifest) : null;
pluginManifest.version = newVersion;
if (dryRun) previewJsonWrite({ original: pluginOriginal, after: pluginManifest, file: pluginJson, repo, io });
if (dryRun) preview(pluginJson, pluginManifest);
else await io.writeJson(pluginJson, pluginManifest);

const marketplace = await io.readJson(marketplaceJson);
const marketplaceOriginal = dryRun ? formattedJson(marketplace) : null;
const marketplacePlugin = marketplace.plugins.find((candidate) => candidate.name === plugin.name);
if (marketplacePlugin) marketplacePlugin.version = newVersion;
if (dryRun) previewJsonWrite({ original: marketplaceOriginal, after: marketplace, file: marketplaceJson, repo, io });
if (dryRun) preview(marketplaceJson, marketplace);
else await io.writeJson(marketplaceJson, marketplace);

const codexFiles = [];
if (plugin.codex && io.fileExists(codexPluginJson)) {
const codexManifest = await io.readJson(codexPluginJson);
const codexOriginal = dryRun ? formattedJson(codexManifest) : null;
codexManifest.version = newVersion;
if (dryRun) previewJsonWrite({ original: codexOriginal, after: codexManifest, file: codexPluginJson, repo, io });
if (dryRun) preview(codexPluginJson, codexManifest);
else await io.writeJson(codexPluginJson, codexManifest);
codexFiles.push(path.relative(repo, codexPluginJson));
}

const addFiles = [`${plugin.root}/.claude-plugin/plugin.json`, '.claude-plugin/marketplace.json', ...codexFiles];
const tag = `${plugin.name}--v${newVersion}`;
if (dryRun) {
// The executed release refuses before writing on a dirty tree, so there is no
// commit, push, tag, or release outcome to forecast.
if (dryRunBlocked) {
io.log(' [dry-run] no commit, push, tag, or release would run');
io.log('\n[dry-run] BLOCKED — the release would refuse; no changes written, no tag, no release.');
return true;
}
io.log(` [dry-run] git add ${addFiles.join(' ')}`);
io.log(` [dry-run] git commit -m "chore(release): ${plugin.name} v${newVersion}"`);
if (wouldStageChanges.some(Boolean)) {
io.log(` [dry-run] git commit -m "chore(release): ${plugin.name} v${newVersion}"`);
} else {
io.log(` [dry-run] ${RECUT_TAG_REASON}`);
}
io.log(' [dry-run] git push origin HEAD');
io.log(` [dry-run] claude plugin tag --push --message "${plugin.name} plugin %s" ${pluginPath}`);
io.log(` [dry-run] wait for tag-CI on ${tag}, then gh release create (gated on CI green)`);
Expand Down Expand Up @@ -398,7 +420,8 @@ export function createGenericPluginReleaseIo({ repo, plugins }) {
// recovery path after a run that bumped, pushed, and then failed tag-CI — not an error, so
// tag the existing HEAD rather than failing on an empty commit.
if (capture('git', ['diff', '--cached', '--quiet', '--', ...files]).status === 0) {
process.stdout.write(' manifests already at this version — tagging existing HEAD\n');
// Keep the executed re-cut sentence byte-identical to the dry-run reason.
process.stdout.write(` ${RECUT_TAG_REASON}\n`);
return;
}
run('git', ['commit', '-m', message]);
Expand All @@ -413,7 +436,12 @@ export function createGenericPluginReleaseIo({ repo, plugins }) {
return pushedAt;
},
ensureCleanTree() {
return capture('git', ['status', '--porcelain']).stdout.trim() === '';
// A failed `git status` says nothing about the tree; reporting it as clean would
// let a dry run forecast a landing the real release refuses.
const status = capture('git', ['status', '--porcelain']);
if ((status.status ?? 1) !== 0)
throw new Error(`git status --porcelain failed: ${status.stderr?.trim() || `exit ${status.status}`}`);
return status.stdout.trim() === '';
},
ensureTool(tool) {
const result = spawnSync(tool, ['--version'], { stdio: 'ignore' });
Expand Down Expand Up @@ -447,6 +475,27 @@ export function createGenericPluginReleaseIo({ repo, plugins }) {
runSelectedCi(_plugin, ciArgs) {
return spawnSync('node', [path.join(repo, 'scripts/ci.mjs'), ...ciArgs], { stdio: 'inherit' });
},
// Reaching origin is IO, so it belongs in the closed set: a caller that cannot stub it
// drags the network into every test of the surrounding decision. Behaviour is unchanged.
tagPublished(tag) {
if (
spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], { cwd: repo, stdio: 'ignore' }).status ===
0
)
return true;
// Local tags are themselves only a proxy: nothing in this flow fetches, so a clone with
// stale refs would let an already-published version through. origin holds the fact. An
// unreachable origin must not read as "never released" — this flow pushes, tags, and
// creates a GitHub release, so it cannot proceed offline anyway, and refusing to guess
// costs nothing.
const remote = spawnSync('git', ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`], {
cwd: repo,
encoding: 'utf8',
});
if ((remote.status ?? 1) !== 0)
throw new Error(`cannot reach origin to check whether ${tag} is already released — refusing to guess`);
return (remote.stdout ?? '').trim() !== '';
},
// `headBranch` carries the pushed ref's short name, so for a tag push it is the tag
// itself (verified against `gh run list --json headBranch` on this repository: every
// push run reports its `<plugin>--v<version>` tag). Commit plus event alone cannot
Expand Down Expand Up @@ -521,6 +570,26 @@ export function createGenericPluginReleaseIo({ repo, plugins }) {
createdAt: identified.createdAt,
};
},
wouldStageChange(file, content) {
const relative = path.relative(repo, file);
// `git add` compares the filter-normalized blob with HEAD, not the worktree
// bytes. Hash through the path without `-w` to reproduce that decision without
// writing either the index or the object database.
const staged = spawnSync('git', ['hash-object', '--path', file, '--stdin'], {
cwd: repo,
encoding: 'utf8',
input: content,
});
if ((staged.status ?? 1) !== 0) {
throw new Error(`git hash-object failed for ${relative}: ${staged.stderr?.trim() || `exit ${staged.status}`}`);
}
const committed = capture('git', ['rev-parse', '--quiet', '--verify', `HEAD:${relative}`]);
// Callers probe only on a clean tree, where every managed file is tracked. A path
// missing from HEAD is therefore an inconsistency, not a new file to guess about.
if ((committed.status ?? 1) !== 0)
throw new Error(`${relative} is missing from HEAD; refusing to guess whether a release would commit it`);
return (staged.stdout ?? '').trim() !== (committed.stdout ?? '').trim();
},
writeJson(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
},
Expand Down
Loading