agentleFS
metratonmetraton/gaia/skills/gaia-release/SKILL.md

gaia-release

Skill4 starsChanged 5 months agoMITView on GitHub

Use when testing, validating, or publishing Gaia releases -- "install local", "pre-release", "dry-run", "release", RC, stable, plugin dry-run, "instala local", "hagamos un release", "probemos el pre-release", "publiquemos la versión estable

  • Installs packages
  • Commits and pushes

2 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    When the user says one of these, run the *whole* sequence.

    line 43
  2. Very specific:

    Run gaia-verify in live mode against <TARGET>.

    line 72
  3. Very specific:

    Run gaia doctor and read Install provenance: the channel you served must name the worktree's commit.

    line 74
  4. Very specific:

    Add --dry-run to preview the sequence first.

    line 116

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Gaia Release

The norm for getting Gaia onto a machine and into the registry, organized as three layers of increasing confidence. The user expresses exactly one of three intentions -- install local (Layer 1, fast iteration), pre-release (Layer 2, the confidence gate), or release (Layer 3, the official publish) -- and each maps to a complete, automated sequence the orchestrator runs end-to-end. The user never recalls a sub-step and never runs a release script by hand: the script is a tool the flow invokes, not a command the human must remember. This is the lesson of the sagas that shipped broken -- a release failed because a version source was bumped one file at a time and a forgotten pyproject.toml drifted; another needed a force-push to reconcile a tag. Every one of those was a manual step a human was trusted to remember and didn't. The fix is to norm the sequence so the steps cannot be forgotten: they are the flow, not a checklist beside it.

This skill orchestrates the sequence; it does not define what a healthy install looks like. Every layer closes by installing into a target install folder and then validating it -- and "how you validate" lives in gaia-verify, which owns the wire-up checklist and the per-surface checks. When a layer says "verify," it means "run gaia-verify for the matching mode." Keep the two apart: release is the when and in what order; verify is the did it come out right.

The delivery model: one tree, three channels

Gaia ships as a single plugin named gaia (scripts/build-plugin.py has VALID_PLUGINS = ("gaia",)). The npm package root IS the plugin root, and both are the git repository root -- there is no dist/ bundle. That one tree reaches an install folder through three channels, and a change can pass on one while breaking another:

                         one source tree (git repo root = package root)
                                         |
            +----------------------------+----------------------------+
            |                            |                            |
    git ref (branch/tag)        npm registry tarball          npm registry tarball
            |                            |                            |
   Claude Code plugin           package + gaia install         package + gaia install
   gaia@gaia-marketplace        --channel npm                  --channel opencode
  • Package (npm/pnpm + gaia install, for Claude Code). The artifact is the @jaguilar87/gaia tarball on npm, published by publish.yml. npm|pnpm install @jaguilar87/gaia provides the gaia CLI (node_modules/.bin/gaia, invoked as npx gaia / pnpm exec gaia), and gaia install wires the folder it runs in, declaring no workspace (that is gaia workspace declare <name> <path>): it links .claude/{agents,tools,hooks,config,skills,opencode} (plus CHANGELOG.md) to the installed package and merges permissions and hook registrations into .claude/settings.local.json from the package's generated hooks/hooks.json (merge_local_hooks in _install_helpers.py). The DB is bootstrapped lazily on first gaia CLI use (_ensure_db_bootstrapped in bin/gaia) -- there is no npm postinstall, so the install behaves the same under npm and pnpm (pnpm ignores lifecycle scripts by default).
  • Claude Code plugin (gaia@gaia-marketplace). The artifact is the git tree at a ref. .claude-plugin/marketplace.json names the marketplace gaia-marketplace and gives the gaia entry "source": "." with no version, so /plugin marketplace add metraton/gaia#<branch-or-tag> installs that ref's code at the version its .claude-plugin/plugin.json declares. Claude Code loads hooks from the root hooks/hooks.json, never from settings.local.json; the root plugin.json is metadata only (no inline hooks block -- declaring hooks in both places fired every event twice, fixed in a1b1245). Both files are generated from the manifest (prepack / generate:plugin-root) and tracked in git, so the fetched tree already carries them. The plugin's sessions merge the permission set and the attribution setting into .claude/settings.local.json (setup_project_permissions in hooks/modules/core/plugin_setup.py) and record those writes in .claude/gaia-manifest.json; it does not put gaia on the terminal's PATH.
  • OpenCode (on the package). The artifact is the same npm tarball. gaia install --channel opencode writes opencode.json pointing at the packaged opencode/plugin.ts instead of touching .claude/.
ChannelArtifactHow an rc reaches itHow a stable reaches itUpdating after a publish
Package (Claude Code)@jaguilar87/gaia tarball on npmgaia release publish x.y.z-rc.N from the accumulating branch -> publish.yml publishes it under the rc dist-taggaia release publish x.y.z from main -> publish.yml publishes it under latestnpm install @jaguilar87/gaia@latest (or @rc, or pnpm add ...), then npx gaia update in the install folder, then restart Claude Code
Claude Code pluginthe git tree at a ref, via gaia-marketplace (source: ".")the same publish pushes tag vx.y.z-rc.N; /plugin marketplace add metraton/gaia#vx.y.z-rc.N installs itthe same publish pushes tag vx.y.z to mainclaude plugin marketplace update gaia-marketplace, then claude plugin update gaia@gaia-marketplace, then restart. Auto-update is off for third-party marketplaces, and the plugin only updates when the version in the fetched plugin.json differs from the installed one -- re-fetching a ref at the same version installs nothing new
OpenCodethe same npm tarballthe rc dist-tag, as for the packagethe latest dist-tag, as for the packageinstall the new package version, then npx gaia update, which re-wires the recorded channels, then restart OpenCode

Installing an rc on another machine is one command per channel: pnpm add @jaguilar87/gaia@rc (or npm install @jaguilar87/gaia@rc) followed by npx gaia install --channel npm -- --channel opencode for OpenCode -- and, for the plugin, /plugin marketplace add metraton/gaia#vx.y.z-rc.N followed by /plugin install gaia@gaia-marketplace.

All three channels ship from the same source tree, which is why Layer 2 exists: the plugin surface is only proven by materializing the plugin root -- packing the tarball, extracting it, and validating the extracted root -- and the OpenCode surface only by wiring that tarball into an install folder; nothing else exercises the root plugin.json / hooks.json, Claude Code's plugin loader, or opencode/plugin.ts's relative paths.

The three intentions

When the user says one of these, run the whole sequence. Do not stop after the first command and wait to be told the next one -- the sequence below IS the intention.

Layer 1 -- "install local": serve the working tree through one channel, fast

The fast iteration loop, and it needs neither a merge nor a publish. The PR's code lives in its worktree and is tried in a dev install folder (<dev-install-folder>). The folder that runs the published version (<released-install-folder>) is never a gaia dev target. One command replaces the manual npm pack -> npm/pnpm add <tarball> -> gaia install sequence, for the channel you name:

python3 <pr-worktree>/bin/gaia dev --channel <npm|plugin|opencode> --workspace <dev-install-folder>
# or, from the installed CLI:
gaia dev --from-worktree <pr-worktree> --channel <channel> --workspace <dev-install-folder>

gaia dev (bin/cli/dev.py) packs the chosen source tree (via the shared _pack_helpers.pack_tarball primitive) and serves the packed tarball through the channel -- never the checkout itself, so there is no source-linking mode:

--channelWhat it changes in the install folderHow the change is picked up
npminstalls the tarball into node_modules (npm or pnpm, auto-detected) and runs the fresh copy's own gaia installrestart Claude Code
pluginextracts the tarball into a stable per-install-folder directory that is a local marketplace gaia-dev, installs gaia@gaia-dev at local scope, and disables gaia@gaia-marketplace in that install folder/reload-plugins, no restart
opencodeinstalls the tarball and wires OpenCoderestart OpenCode

The channel is required and there is no all: without --channel the command fails listing the three (--host is kept as an alias: claude_code = npm). npm and plugin refuse each other in one install folder, naming the channel found and the command that removes it; opencode joins either. --ref <full-sha> refuses to build unless the worktree's HEAD is that commit. Then gaia doctor in the install folder: its Install provenance check prints one line per channel with the source commit it was built from and how many commits that source has moved since. gaia dev --help documents the full flag set (--workspace, --channel, --host, --from-worktree, --ref, --pack-dest, --quiet, --verbose, and the compatibility no-ops --keep-tarball and --no-global-link).

Drift-free convergence. gaia dev never touches the global npm surface -- the consumer install folder's tarball install is the only thing it changes, and --no-global-link is a compatibility no-op kept for callers that still pass it. It prints a read-only convergence report of the 5 surfaces vs the origin (aligned / stale / absent; bin/cli/_converge.py). The DB half is guarded at bootstrap: an install NEVER runs code older than the DB (the reverse, finalize-breaking direction is refused, no clobber); it migrates forward when the code is newer. gaia doctor REPORTS this 5-surface + schema-direction skew but never fixes it. No --from flag: the command IS the origin.

Two properties of gaia dev shape the flow:

  • It is T3. gaia dev installs into an install folder, so it is classified state-mutating (anchored in COMMAND_PATH_MUTATIVE_UPGRADES, mutative_verbs.py) and blocks for approval before it runs -- expected, not a failure. The gaia launcher and python3 <path>/bin/gaia dev classify identically (the bin/gaia dispatcher re-dispatches through the classifier), so approval behaves the same from either entry point.
  • It runs NO tests. Install local is deliberately cheap -- edit, install, restart, poke. The L1 test subset runs in Layer 2 (gaia release check) and Layer 3 (gaia release publish) and in CI, never in this fast loop.

Then, without being asked:

  1. Run gaia-verify in live mode against <TARGET>. If any check fails, jump to reference.md -> "Diagnostic guide".
  2. Pick up the change the channel's way. On npm and opencode, restart the host: gaia dev prints an explicit restart notice because the harness pins each hook's command at session start and does NOT hot-reload -- the open session keeps running the OLD hooks until it is restarted. On plugin, run /reload-plugins. Do not tell the user the change is live before that step. See "Reloading a change".
  3. Run gaia doctor and read Install provenance: the channel you served must name the worktree's commit.

Installing into a different install folder is the same intention with a different target -- pass gaia dev --channel <channel> --workspace <TARGET>. Wiping install metadata first is NOT a gaia dev flag (its register() exposes no --fresh); --fresh belongs to the underlying validate-sandbox.sh form. See reference.md -> "Layer 1 runbook" for both, and for the raw pnpm pack / npm run gaia:install-local sequence gaia dev wraps (useful when diagnosing a failure inside the wrapped steps). Always pass --workspace explicitly when invoking from inside the gaia repo: the self-referencing node_modules/@jaguilar87/gaia/ entry tricks auto-detect (guarded by bin/validate-sandbox.sh::is_gaia_repo_root, but explicit is safer).

Layer 2 -- "pre-release": prove a clean install works on every channel, reproducing CI

This is the confidence gate before a version is cut. It runs locally with no registry -- its only network use is gate 4's read-only GitHub API lookup -- and it must run the same gates CI runs (see the pre-flight principle) and exercise the plugin and OpenCode surfaces, which nothing else validates. One command runs all seven gates, always, and reports a complete PASS/FAIL/SKIP picture (never stopping at the first red light):

gaia release check --gh <wrapper>     # --functional: opt-in live plugin probe; --local-suite: force npm test in gate 4

This command is T3 -- request consent before running it. It is local and it is not free: gates 2 and 3 pack the tarball, which runs npm's prepack, which executes scripts/build-plugin.py and rewrites the plugin root manifests including hooks/hooks.json, a categorically protected path. So it is anchored MUTATIVE in COMMAND_PATH_MUTATIVE_UPGRADES (hooks/modules/security/mutative_verbs.py) and asks for approval on every run -- an accepted cost, not a misclassification to work around. Plan for it: request the approval together with the other release-sequence T3 commands rather than discovering the block mid-runbook. The command reports tier=T3 and is enforced as T3: tiers.py consults detect_mutative_command before T1_PATTERNS, so the word check does not downgrade it (the same account as security-tiers).

gaia release check (bin/cli/release.py) first installs the locked Node dependencies a fresh worktree lacks, with the same step publish runs (bin/cli/release.py::step_node_deps: npm ci --ignore-scripts, only when a declared dependency is missing from node_modules), so neither command needs a manual npm ci. A missing or out-of-sync lockfile fails the check there, alone, since every gate needs those dependencies. Then it runs, in order, these six gates -- gates 1-4 each a subprocess call to the existing script (never reimplemented), gates 5 and 6 in-process inspections:

  1. pre-publish:validate -- the version-drift gate (validate-manifests in ci.yml, via bin/pre-publish-validate.js --validate-only). This is what catches a package.json / pyproject.toml / plugin.json / CHANGELOG.md desync, or a version reappearing in a marketplace.json entry, before it ships.
  2. gaia:verify-install:local -- packs (via the shared _pack_helpers.pack_tarball, the same primitive gaia dev uses) and installs into a throwaway sandbox (bin/validate-sandbox.sh --tarball <packed-tarball> --target sandbox, as release.py invokes it). This proves the npm surface of exactly what a registry publish would ship. (This is gaia-verify mode npm-sandbox.)
  3. Plugin-surface dry-run (bin/plugin-dryrun.sh) -- packs the tarball itself, extracts it to a throwaway temp dir (the package root IS the plugin), and runs a headless, offline gate: filesystem asserts (root plugin.json with NO inline hooks block, hooks/hooks.json, bin/gaia, agents/, skills/, and NO dist/) plus claude plugin validate. It touches no real install folder and spawns no session. --functional forwards to the script's own opt-in live functional probe, which starts the host on the extracted plugin (needs Claude auth/tokens -- never implicit). SKIPs (not fails) when the claude binary is not on PATH. This is the only place the plugin surface is proven before a tag exists. (This is gaia-verify mode plugin.) See the plugin-surface principle below for why a green npm sandbox does not cover it.
  4. Tests: the CI verdict, or the local suite. CI tests every tree once (see "CI/CD"), so this gate first asks .github/scripts/ci_verdict.py whether a green CI verdict already covers HEAD's tree. If one does, the gate PASSes citing that CI run and npm test does not run. It falls back to npm test -- the same L1 selection CI runs -- when there is no such verdict: HEAD not pushed, a working tree that differs from HEAD, CI red or still pending, or no network (the lookup is bounded and a failure is never a FAIL, only a reason to run locally). --local-suite skips the lookup and always runs npm test.
  5. convergence -- the SAME drift-free convergence gaia dev runs after its reconcile, but with the origin = the release artifact (this repo's package.json version) rather than the local source -- the one difference from dev. It reuses bin/cli/_converge.py to inspect the destination's 5 install surfaces and applies the schema-DIRECTION guard (scripts/bootstrap_database.py): a live ~/.gaia/gaia.db NEWER than the artifact's expected schema (reverse-direction drift) is a hard FAIL, because installing that artifact would be REFUSED by bootstrap (never ship code older than the DB, the finalize-breaking drift). Forward/stale surfaces are informational and do NOT fail the gate -- a release does not reconcile the developer's machine, so only the reverse-direction guard is a stop; an inspection error is a SKIP. This is what makes gaia release as drift-safe as gaia dev.
  6. opencode:surface -- the OpenCode surface of gate 2's tarball, without starting OpenCode (gate_opencode_surface): it extracts the tarball, wires it into a temp install folder as gaia install --channel opencode does, and FAILs naming what is missing -- opencode/plugin.ts, a file it resolves by relative path (./bridge.py, ../bin/gaia), an agent {file:...} prompt in opencode.json, or a skill link. Starting OpenCode live stays outside the gate.
  7. install:smoke -- the tarball laid out as every install channel in a throwaway home (bin/cli/_install_smoke.py::smoke): the marketplace plugin cache, a workspace's node_modules/@jaguilar87/gaia with its .bin launcher, the npm global prefix with its launcher (gaia.cmd/gaia.ps1/gaia on Windows), and a dev-plugin cache entry. For each, the hooks loaded from that copy judge the session smoke set -- --version, now, doctor, memory show <seeded slug>, approvals question <seeded id>, and for the subagent contract finalize (the orchestrator's guard refuses it by design) -- spelled by path (and, on Windows, as Git Bash and backslash paths) and as a bare gaia, as the orchestrator and as a subagent, from a workspace folder outside the install; every one must run free. The commands then run, gaia doctor must show no install-identity error (after a gaia dev record and a registry reinstall, on npm-local), and the prompt hook must print the seeded conversation preference. gaia release publish runs the same gate after its sandbox install, and CI's Windows job runs it on Windows. gaia release smoke --tarball <tgz> runs it alone.

A check run that reports FAIL on gate 1 has failed a subset of CI, not passed a stand-in for it; a FAIL/SKIP on gate 3 means the plugin surface was never run (SKIP only when claude is genuinely absent) -- both gaps surface only after publish, when the fix costs another release. For the raw npm-script forms each gate wraps (useful when diagnosing which gate failed), see reference.md -> "Layer 2 runbook".

Layer 3 -- "release [version]": end-to-end publish, fully automated

A stable is promoted only from an RC that was published to the rc channel, installed on this machine the way users install it, and passed the smoke set live. No stable ships from a dev install: 5.7.0 did, every check ran on the dev-plugin path, and the published marketplace build locked the orchestrator out of its own CLI. The sequence for every version:

  1. Publish the RC. gaia release publish rc --gh <wrapper> (the next -rc.N; an explicit x.y.z-rc.N works too) from the branch holding the release. It runs gate 7 on the bumped tarball, pushes vx.y.z-rc.N, and publish.yml publishes it under the rc dist-tag.
  2. Install it through the published channels on this machine, never with gaia dev: /plugin marketplace add metraton/gaia#vx.y.z-rc.N and /plugin install gaia@gaia-marketplace (or claude plugin marketplace update gaia-marketplace + claude plugin update gaia@gaia-marketplace), and npm install @jaguilar87/gaia@x.y.z-rc.N in an install folder followed by npx gaia update (or npm install -g). On Windows, the same through npm's gaia.cmd.
  3. Pass the smoke set live. gaia release smoke --live --version x.y.z-rc.N --workspace <install-folder> drives the copies those channels installed -- the marketplace cache and npm (global, or the folder's node_modules) are both required -- through their own hooks with a throwaway home and data directory, and records the pass under the state directory (release-smoke/<rc>.json). Then open a session in the install folder and run the same set by hand as orchestrator and through one dispatch that closes its contract; the per-prompt preferences line must show.
  4. Promote. gaia release publish x.y.z --gh <wrapper> from main. Its preconditions gate (_check_promoted_from_rc) refuses a stable unless origin holds a vx.y.z-rc.N tag, the newest one has a recorded live pass on this machine, and HEAD holds that RC's tree with only the version sources changed; anything merged after the RC means a new RC.

The orchestrator runs every step below in order. The user supplies (or confirms) the version and approves the T3 operations; the orchestrator does the rest. The user does not run release:prepare by hand -- gaia release publish invokes it as step 2 of one command, after step 1 installs the Node dependencies.

gaia release publish <version> --gh <wrapper>
python3 <worktree>/bin/gaia release publish <version> --gh <wrapper>

Add --dry-run to preview the sequence first; it names the push target and the gh account. The second form runs the release from a Gaia-managed worktree's own code: release check/publish resolve their repository from the bin/gaia they are run from.

gaia release publish (bin/cli/release.py) collapses steps (b)-(g) below into ONE command that runs steps 1-6 (local, no approval needed) then steps 7-8 (Tier 3, will block for approval), stopping at the first failure -- unlike release check's always-run-all-gates design, these steps are causally dependent: tagging an untested tree or pushing before the tag exists is actively harmful. [version] accepts a bare semver (5.1.0-rc.1) or the bump keywords patch/minor/major (computed from the current package.json version -- patch is the default). It never runs npm's own registry-publish command itself: that stays inside publish.yml, which publishes through npm trusted publishing (OIDC) with no npm token.

Before step 1, a read-only (T0) preconditions gate (preflight_publish) runs and fails EARLY, loud, and actionably rather than blowing up mid-sequence -- the failure mode behind the release saga (a tag created, then a permission error at gh; or a 30-minute npm test wait that only failed because pytest-xdist was missing). It checks six definite blockers -- the sixth, for a stable only, is the RC promotion rule above -- and, if any fails, runs no step: (1) the --gh program's account has push/admin on metraton/gaia -- this is a real Layer 3 precondition, because step (g) is a Tier-3 gh release create; a missing program, an account the program cannot resolve or has no token for, an unauthenticated gh and a push:false all fail naming --gh <program>, while a network failure is "could not verify" and does not block; (2) tag v<version> does not already exist (local or remote) -- if it does, the gate names the fix (gh release create v<version> to finish a half-completed release, or delete the tag to redo it); (3) pytest-xdist is importable (npm test runs pytest with -n auto) -- caught in ~1s instead of after the full npm-test wait; (4) the push target resolves and HEAD fast-forwards it -- see "The push target" below; (5) a stable version publishes only to main, an rc to any branch -- see "Stable to main, rc to the branch" below.

The push target is an origin branch, not the local branch. A Gaia-managed worktree always sits on a branch of its own, so step 7 never relies on the local branch's name or upstream: it runs git push --atomic origin HEAD:refs/heads/<branch> refs/tags/v<version>, landing the bump commit and the tag together or not at all, with no + and no --force, so git refuses anything but a fast-forward. <branch> is --branch <name> when given, else the current branch's origin upstream, else the one origin branch whose tip is HEAD -- the accumulating branch a worktree was cut from. Ambiguity (several branches at HEAD) or none fails asking for --branch, and the preconditions gate checks before step 1 that origin's tip is in HEAD's history; when it is not, merge it (never rebase) and re-run.

Stable to main, rc to the branch. A stable version (no -rc./-beta./-alpha.) goes to the latest dist-tag, which every package and OpenCode install takes by default, so it ships only what main holds. _check_stable_from_main reads the push target, not the local branch name, and for a stable version whose target is anything but main fails with: stable version <v> can only be published to main, but this release would push to <branch>. Merge to main and publish from there, or publish a pre-release instead (gaia release publish <v>-rc.N). Since step 7 only fast-forwards that target, a stable passes exactly when what it pushes lands on main. A pre-release publishes to any branch: run gaia release publish x.y.z-rc.N --gh <wrapper> from a worktree cut at the accumulating branch's tip, and step 7 lands the bump commit and the tag there. There is no flag to bypass the check.

The GitHub account is resolved per process, by the program the release runs gh through. gh keeps ONE active account per host, so it is global state shared with every concurrent session and agent on the machine; gh auth switch mutates that state for all of them and is the demonstrated source of accounts drifting mid-release. Every gh call of release check and release publish -- the push-permission check, the CI-verdict lookup and gh release create -- runs through --gh <program> (default gh) with the repository as its working directory. Name a wrapper that picks the account for its own process, the one the user documented, which maps the origin owner to an account and runs gh with GH_TOKEN set for that process only; the release command stays one program, so it is signed as one command, and nothing else the release runs (git, npm, node) sees the token. With the default gh the release runs as gh's active account, and --dry-run says so. git push takes no part in this: it authenticates with git's own per-remote credentials or SSH key. gh auth status lists what is available without changing it. gh auth login adds a missing account and makes it the active one, so adding an account is the user's call. gh auth switch and gh auth logout also change the active account, and both classify T3 for that reason -- see _check_generic_cli_shape (hooks/modules/security/mutative_verbs.py).

Nothing irreversible happens before the sandbox install has passed on the bumped tree. Step 1 installs the locked Node dependencies a fresh worktree lacks (npm ci --ignore-scripts, only when a declared dependency is missing from node_modules), so release:prepare can import chalk; a missing or out-of-sync lockfile fails there, before any bump, naming the fix. Step 3 packs the bumped tree and installs it with bin/validate-sandbox.sh --target sandbox -- the gate publish.yml runs only after the tag and the GitHub release exist, and the one a reused CI verdict never exercises (v5.5.0-rc.4 was tagged and then failed there). It refuses a tree that differs from HEAD outside the version sources, since the tag would not hold that difference. Neither step needs gaia release check first; Layer 2 remains where the plugin and OpenCode surfaces are proven.

Step 4 is the same tests gate as gaia release check's gate 4. It runs after release:prepare, so HEAD is the parent of the version-only bump commit step 5 is about to make: a green CI verdict on HEAD covers the tree being released, and the step PASSes citing that CI run without the local suite. Only the version sources release:prepare rewrote may differ from HEAD; any other local change, a HEAD that is not pushed, CI red or pending, or no network makes it run npm test instead, and gaia release publish --local-suite forces npm test. CI then reuses the same verdict for the bump commit, since its diff touches version sources only.

When the tests step does fall back to the local suite, npm test has a configurable timeout: the DEFAULT is 1800s (raised from 1200s as the suite grew), overridable per-run via the GAIA_RELEASE_NPM_TEST_TIMEOUT env var (a positive integer of seconds). The timeout applies only to that local fallback; a reused CI verdict never waits on it. On expiry the gate reports an explicit TIMEOUT after Ns message that names the env-var lever and pytest-xdist, so a slow run is never mistaken for a test failure.

StepActionNotes
(a)Determine the versionDefault to the next patch. If the change is major/minor, confirm with the user (NEEDS_INPUT) before proceeding -- never silently pick major/minor. Pass the confirmed version as gaia release publish's argument, or let it default to patch.
(b)Node deps, then release:prepare <version> -- gaia release publish steps 1-2Step 1 runs npm ci --ignore-scripts when a declared dependency is missing from node_modules, and nothing otherwise. Step 2 is the atomic core: bumps the hand-owned version sources at once (package.json, pyproject.toml, CHANGELOG.md, where a stable folds [Unreleased] and its own pre-release sections into one section and a pre-release only adds its header -- no body is edited by hand; .claude-plugin/marketplace.json is never written -- its entry has source: "." and no version), runs generate:plugin-root (regenerating the ROOT .claude-plugin/plugin.json (metadata only) + hooks/hooks.json from the manifest -- plugin.json version is inherited from package.json, not hand-bumped), then pre-publish:validate. Fails loud on any drift. No dist/ bundle. This wraps scripts/release-prepare.mjs -- invoked by the flow, never run by hand.
(c)Sandbox install, then the pre-flight that reproduces CI -- steps 3-4pre-publish:validate already ran inside (b). Step 3 packs the bumped tree and runs publish.yml's sandbox gate on it (bin/validate-sandbox.sh --target sandbox). Step 4 proves the tree with tests before the tag exists: a green CI verdict for HEAD (the bump commit's parent) PASSes citing the CI run; without one, npm test runs (--local-suite forces it).
(d)Commit -- step 5git add (the version-source paths only) + git commit -- local-safe, not T3. Idempotent: nothing-to-commit on a tree already at the target version is a PASS, not a failure.
(e)Tag, force-free -- step 6A new annotated tag (v<version>); never moves an existing one. If the remote diverged, reconcile with merge, not rebase (rebase forces a tag move, a signed rewrite of published history the release never makes). See reference.md -> "Reconciling a diverged remote".
(f)Push -- step 7, Tier 3git push --atomic origin HEAD:refs/heads/<branch> refs/tags/v<version> (the commit and the new tag in one push, to the push target above, never forced). If diverged, the merge from (e) makes this a fast-forward. The hook layer blocks this for approval -- expected.
(g)gh release create v<version> -- step 8, Tier 3Triggers publish.yml, which packs (prepack regenerates root manifests), validates, sandbox-gates, and publishes to npm through trusted publishing (OIDC, no npm token) with the auto-detected tag (-rc. -> rc, else latest). It no longer builds/commits a dist/ bundle or force-moves the tag. RC/beta/alpha versions are marked --prerelease automatically.
(h)Verify from the registry, then update every install folder on every channelWatch the workflow to its outcome, then gaia-verify mode registry (gaia:verify-install:rc / :latest) confirms npm serves the new version. A publish updates no install folder on its own: an install folder installed from a local file: tarball keeps running the dev-pack code, and a plugin install keeps its version until updated. Per install folder and channel, apply the "Updating after a publish" column of the channel table (pnpm add @jaguilar87/gaia@<dist-tag> + npx gaia update; claude plugin marketplace update gaia-marketplace + claude plugin update gaia@gaia-marketplace), restart or /reload-plugins as that channel needs, and run gaia-verify live. To find WHICH local install folders are running stale code, run gaia doctor in each -- its Install provenance check reports whether a file: install is fresh vs source and hints gaia dev --workspace <install-folder> --channel <channel> to fix. The release is not done when the tag is pushed -- it is done when the published version is installed and validated in every target.

For the raw command forms gaia release publish wraps, the schema-migration lockstep, and the diverged-remote reconciliation, see reference.md.

Reloading a change

A fresh install or an edit is invisible until Claude Code picks it up, and how depends on what changed:

  • After gaia dev --channel npm or --channel opencode -- restart the host. The harness snapshots each hook's command (and the settings.local.json hook registration) at session start and does not hot-reload it, so an open session keeps running the OLD hooks until it is restarted -- a freshly installed fix is inert until then. gaia dev prints this restart notice on success; heed it. This is why the fast loop is edit source -> gaia dev -> restart -> test, not edit -> test.
  • After gaia dev --channel plugin -- /reload-plugins. The gaia-dev marketplace is a local directory loaded in place, so skills, agents, hooks, and MCP servers refresh in-session without a version bump.
  • Plugin from gaia-marketplace: the plugin cache holds the tree at the installed version, not your working tree. claude plugin update gaia@gaia-marketplace installs a new tree only when the fetched plugin.json declares a different version; re-fetching the marketplace at the same version changes nothing. Use gaia dev --channel plugin to try unpublished code.
  • Any surface, slash-command change: adding or renaming a slash-command needs a full restart -- /reload-plugins loads skills into context but does not rebuild the slash-command parser index.

Uninstalling, per channel

Each channel takes back only what it wrote; ~/.gaia/gaia.db is never touched.

  • Package and OpenCode: run npx gaia uninstall (add --workspace <folder> for an OpenCode-only folder, --dry-run to preview) before npm uninstall @jaguilar87/gaia / pnpm remove @jaguilar87/gaia. The order is forced: npm >= 7 does not run a package's preuninstall script and pnpm skips lifecycle scripts by default, so removing the package first leaves .claude/ links, settings keys and opencode.json pointing at nothing, with no gaia left to revert them. gaia uninstall reverts .claude/gaia-manifest.json entry by entry, removes what the package manager and gaia dev left outside it (the package entry and .bin shim, Gaia's package.json and package-lock.json lines, the gaia dev tarballs) or lists it as left in place with the reason, and snapshots the DB to ~/.gaia/snapshots/ unless --no-backup.
  • Claude Code plugin: gaia uninstall in the install folder first, then claude plugin uninstall gaia@gaia-marketplace (and optionally claude plugin marketplace remove gaia-marketplace). The plugin's sessions record their writes into the install folder -- the permissions and attribution merged into .claude/settings.local.json, the .claude/hooks link -- in .claude/gaia-manifest.json (plugin_setup.recorded_in_manifest), so gaia uninstall reverts them and keeps what the user added since.
  • One channel beside another: without --channel, gaia uninstall takes back every channel the install folder records. Where the plugin and OpenCode run side by side, gaia uninstall --channel opencode removes OpenCode on its own (opencode.json, .opencode/, and the package copy once no remaining channel runs from it) and leaves the plugin recorded; --channel plugin or --channel npm takes back the Claude Code side. npm and the plugin share .claude/, so uninstalling either takes back every Claude Code entry in the manifest; a plugin still enabled re-records its own writes on its next session. A channel the manifest does not record fails, naming the recorded ones.

CI/CD

WorkflowFileTriggers
CI.github/workflows/ci.ymlPush / PR -- a Reuse decision job first asks .github/scripts/ci_verdict.py whether this tree already passed; if not, pytest (Python 3.12) runs in 4 shards over the same selection as npm test (the exhaustive opencode alias matrix is deselected), and beside them the upgrade matrix (ubuntu and windows x every dump in tests/fixtures/published_bases) installs the Linux-packed tarball, upgrades that base with gaia migrate, runs the npm and plugin channels' registered hook commands as written and requires gaia uninstall to leave no manifest, and the test-opencode job runs the OpenCode plugin's TypeScript tests with bun test ./tests/opencode (bun pinned to the shards' version) -- both skipped with the shards when the tree is reused. ESLint on one Node (20), plugin build verification, validate-manifests, and the Windows compatibility job run alongside. The CI verdict job aggregates them all and is the one check gaia release check/publish read. The bun tests are the only non-Python test lane; npm test stays pytest, so bun run test and bun --cwd <dir> test run pytest, not bun's runner.
Nightly.github/workflows/nightly.ymlDaily schedule and manual dispatch -- runs the exhaustive opencode alias matrix that CI deselects
Publish.github/workflows/publish.ymlGitHub Release event -- packs the npm tarball (prepack regenerates root manifests), validates, runs the sandbox gate, auto-detects npm tag from version (-rc. -> rc, -beta. -> beta, else -> latest), and publishes through npm trusted publishing (OIDC). It no longer builds/commits a dist/ bundle or force-moves the tag (read-only checkout).

Publishing authenticates through npm trusted publishing (OIDC) from publish.yml -- no npm token in the publish step; local npm publish bypasses build verification and is not the supported path.

Principles -- why the sequence is normed, not optional

  • The pre-flight reproduces what CI validates, not a subset of it. When the local check skips a gate CI runs (pre-publish:validate), that gate's failures surface only after publishing, on the published tarball, where the only remedy is another release. That is exactly how a pyproject.toml drift shipped green-local and red-CI. Layer 2 step 1 and Layer 3 step (c) close the gap. See reference.md -> "The pre-flight reproduces what CI validates".
  • The plugin surface is only proven by packing the tarball and mounting the extracted root. The npm sandbox exercises the symlink / settings.local.json path; it never touches the root plugin.json / hooks.json or CC's plugin loader. The tarball can be missing files, carry a broken hooks.json, or fail to expose bin/gaia on PATH -- and none of that shows until CC mounts it. Layer 2 step 3 (gaia:plugin-dryrun -- pack, extract, headless validate) is the only pre-tag check that runs the plugin surface. Skipping it means the plugin breaks silently in production.
  • Bump every version source in one step, never one at a time. pre-publish:validate requires package.json, pyproject.toml, .claude-plugin/plugin.json (generated), and the CHANGELOG.md top header to agree, and rejects a version in any .claude-plugin/marketplace.json entry. release:prepare writes the hand-owned sources from one target version and regenerates plugin.json (version inherited from package.json), so a hand-desync is impossible. See scripts/release-prepare.mjs.
  • Tag force-free; reconcile with merge, never rebase. publish.yml no longer commits artifacts back to main (read-only checkout), so the remote does not auto-advance on release. But if the remote ever diverges, rebasing rewrites hashes and forces a tag move (git tag -f / --force), a rewrite of published history that signs: a force push asks under sign.destroys (hooks/modules/security/verdict.py::effect_rule; BLOCKED_PATTERNS in blocked_commands.py has no git entry), and git tag -f asks through mutative_verbs.py::_classify_git_tag. Merge preserves hashes and tags; a new release gets a new tag, never a moved one. See reference.md -> "Reconciling a diverged remote".
  • A release ends at an installed, validated version -- not at the tag. Pushing the tag only starts publish.yml. The intention is not satisfied until the workflow reaches its outcome, npm serves the new version, and it installs cleanly into the target (Layer 3 step (h)).

Anti-Patterns

  • Stopping after the first command of an intention -- "install local" is not just gaia dev's pack step; "release" is not just release:prepare. Each intention is the whole sequence, and gaia dev / gaia release check / gaia release publish each already run their whole sequence in one invocation -- do not run one gate by hand and stop.
  • Asking the user to run release:prepare (or any Layer 3 step) by hand -- it is a step gaia release publish invokes internally, not a command the human runs. Surfacing it as a manual step is the same failure mode (a step someone must remember) wearing a new script.
  • Publishing without a green gaia release publish --dry-run / gaia release check -- the plugin surface (gate 3) breaks silently if skipped. Preview or run the full Layer 2 gate before any tag.
  • Pre-flight that is a subset of CI -- skipping pre-publish:validate locally means the version drift surfaces after publish. gaia release check reproduces CI; do not approximate it by hand-picking gates.
  • Bumping version sources one at a time -- desyncs a source by hand; pre-publish:validate rejects the tree and a forgotten file ships if the check is skipped. Always go through gaia release publish (which invokes release:prepare), never a hand-edit.
  • Rebase to reconcile a diverged remote -- forces a tag move, which rewrites published history and would need a signature. Merge instead.
  • Single-surface testing -- a change can pass the npm sandbox and break the plugin mount or the OpenCode wiring, or the other way round. Layer 2 runs all three surfaces for a reason.
  • Publishing a stable from a feature branch -- the preflight refuses it; merge to main first, or cut an rc (x.y.z-rc.N) from the branch.
  • Promoting a stable that only a dev install ever ran -- the preflight refuses it without a published, live-smoked RC. Tests, CI and gaia dev all run the dev path; only the published channels prove what users get.
  • Removing the package before gaia uninstall -- npm >= 7 skips preuninstall, so nothing reverts the install folder and the gaia that could have done it is gone.
  • Stale root manifests -- editing build/gaia.manifest.json or a hook entry without regenerating means the tarball ships a stale hooks/hooks.json. prepack regenerates at every npm pack, and release:prepare regenerates via generate:plugin-root; the dry-run packs fresh, so a stale manifest surfaces there.
  • Skipping the pickup step after gaia dev -- on npm and opencode the harness pins hook commands at session start, so a reinstalled fix is inert until the host restarts; on plugin it is inert until /reload-plugins. Do not report the change as live before that step (a slash-command change takes a full restart on any channel -- see "Reloading a change").
  • Running tests in the install-local loop -- Layer 1 (gaia dev) is deliberately cheap and runs NO tests; the L1 suite belongs to gaia release check / gaia release publish and CI. Adding a test gate to the fast loop defeats its purpose.
  • Assuming a postinstall ran -- there is none. The DB is bootstrapped lazily on first gaia CLI use; under pnpm a lifecycle hook would never fire anyway. If the DB is missing, run any gaia command (or gaia update), not "re-run postinstall".
  • Local npm publish -- bypasses the pipeline's pack + validate + sandbox gate.
  • Treating a green publish as a live local install -- a publish updates the registry; it does NOT touch an install folder installed from a local file: tarball. Those install folders keep running the pre-release dev-pack code until step (h) reinstalls them (gaia dev --channel <channel> --workspace <TARGET>); gaia doctor's Install provenance check surfaces which ones are stale. Confusing "published" with "running locally" is exactly how a fixed release keeps exhibiting the old bug in a local install folder.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.