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"
Pro scans all 2 files and shows the line behind each finding
Scanned 10/7/2026
npx -y skills add metraton/gaia --skill gaia-release --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Gaia Release?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/metraton-gaia-release)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: gaia-release
description: 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"
---
# 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/`.
| Channel | Artifact | How an rc reaches it | How a stable reaches it | Updating after a publish |
|---------|----------|----------------------|-------------------------|--------------------------|
| Package (Claude Code) | `@jaguilar87/gaia` tarball on npm | `gaia release publish x.y.z-rc.N` from the accumulating branch -> `publish.yml` publishes it under the `rc` dist-tag | `gaia release publish x.y.z` from `main` -> `publish.yml` publishes it under `latest` | `npm install @jaguilar87/gaia@latest` (or `@rc`, or `pnpm add ...`), then `npx gaia update` in the install folder, then restart Claude Code |
| Claude Code plugin | the 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 it | the same publish pushes tag `vx.y.z` to `main` | `claude 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 |
| OpenCode | the same npm tarball | the `rc` dist-tag, as for the package | the `latest` dist-tag, as for the package | install 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:
| `--channel` | What it changes in the install folder | How the change is picked up |
|-------------|----------------------------------|-----------------------------|
| `npm` | installs the tarball into `node_modules` (npm or pnpm, auto-detected) and runs the fresh copy's own `gaia install` | restart Claude Code |
| `plugin` | extracts 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 |
| `opencode` | installs the tarball and wires OpenCode | restart 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 six gates, always, and reports a complete PASS/FAIL/SKIP picture (never stopping at the first red light):
```
gaia release check --gh ghx # --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.
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
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 ghx
python3 <worktree>/bin/gaia release publish <version> --gh ghx
```
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 five definite blockers 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 ghx` 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, such as `ghx`, 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 and `gh auth login` adds an account that is missing; neither of those two mutates which account is active. `gh auth switch` and `gh auth logout` do, and both classify T3 for exactly that reason -- see the `gh` entry in `COMMAND_PATH_MUTATIVE_UPGRADES` (`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.
| Step | Action | Notes |
|------|--------|-------|
| **(a)** | Determine the version | Default 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-2 | Step 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-4 | `pre-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 5 | `git 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 6 | A *new* annotated tag (`v<version>`); never moves an existing one. If the remote diverged, reconcile with **merge, not rebase** (rebase forces a tag move, hard-denied locally). See `reference.md` -> "Reconciling a diverged remote". |
| **(f)** | Push -- step 7, **Tier 3** | `git 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 3** | Triggers `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 channel** | Watch 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
| Workflow | File | Triggers |
|----------|------|----------|
| CI | `.github/workflows/ci.yml` | Push / 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.yml` | Daily schedule and manual dispatch -- runs the exhaustive opencode alias matrix that CI deselects |
| Publish | `.github/workflows/publish.yml` | GitHub 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`), hard-denied by local hooks (`git_destructive` in `blocked_commands.py`, exit 2, not approvable). 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, hard-denied locally. 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.
- **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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!