diff --git a/.github/workflows/orca-plugin-ref.yml b/.github/workflows/orca-plugin-ref.yml new file mode 100644 index 000000000..9dc392e84 --- /dev/null +++ b/.github/workflows/orca-plugin-ref.yml @@ -0,0 +1,117 @@ +name: Orca Plugin Ref + +# Republish `plugins/genie` as a standalone git ref that Orca can install. +# +# Orca installs a plugin from a git URL + ref (or a local folder) whose ROOT +# holds `orca-plugin.json`. This repository root can never be that tree: +# +# * the loader rejects any tree containing a symlink ("unsafe file path or +# symlink"), and `docs -> .docs-vendor/genie` is one; +# * it caps an install at 2000 files / 50 MB, and a dev checkout is ~14k files; +# * the manifest is nested at `plugins/genie/orca-plugin.json`, invisible to a +# git source, which only looks at the root. +# +# `plugins/genie` on its own is symlink-free, ~132 files / ~1.3 MB, and holds the +# manifest at its root — so this workflow publishes exactly that subtree: +# +# main -> refs/heads/orca-plugin (what orca-marketplace.json points at) +# dev -> refs/heads/orca-plugin-dev (pre-release channel) +# +# SECURITY: `contents: write` is scoped to this one job, which runs no package +# script, dependency installer, hook, or any executable from the checkout. Every +# command below is trusted workflow control; the only inputs are a git tree hash +# derived from the pushed commit and the repository's own ref state. +# +# The published ref is TREE-ONLY BY DESIGN: each publish is a fresh parentless +# root commit carrying just the subtree, force-pushed over the previous one. It +# has no history, shares no ancestry with main/dev, and must never be merged +# back. Orca pins the commit it fetched, so a rewritten ref cannot retroactively +# change an existing install. + +on: + push: + branches: [main, dev] + paths: + - 'plugins/genie/**' + - '.github/workflows/orca-plugin-ref.yml' + # Manual repair path: republish the current branch tip's subtree. + workflow_dispatch: + +permissions: + contents: read + +# Serialize per source branch. Never cancel: a cancelled force-push could leave +# the plugin ref pointing at an older subtree than the branch it mirrors. +concurrency: + group: orca-plugin-ref-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Publish plugins/genie subtree + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write # force-push the tree-only orca-plugin[-dev] ref + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + # Only the pushed commit's tree is needed; the publish never walks + # history. Credentials are bound below with `gh auth setup-git` once + # the target ref has been resolved. + fetch-depth: 1 + persist-credentials: false + + - name: Publish the subtree ref + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + GIT_AUTHOR_NAME: 'github-actions[bot]' + GIT_AUTHOR_EMAIL: 'github-actions[bot]@users.noreply.github.com' + GIT_COMMITTER_NAME: 'github-actions[bot]' + GIT_COMMITTER_EMAIL: 'github-actions[bot]@users.noreply.github.com' + shell: bash + run: | + set -euo pipefail + + case "${BRANCH}" in + main) TARGET_REF=orca-plugin ;; + dev) TARGET_REF=orca-plugin-dev ;; + *) + echo "::error ::orca-plugin-ref.unexpected-branch ${BRANCH} has no plugin ref mapping" + exit 1 + ;; + esac + + SOURCE_SHA="$(git rev-parse HEAD)" + SUBTREE="$(git rev-parse "HEAD:plugins/genie")" + echo "source ${BRANCH}@${SOURCE_SHA} subtree ${SUBTREE} -> refs/heads/${TARGET_REF}" + + # Idempotent: skip when the published ref already carries this exact + # tree. A missing ref (first publish) is the only tolerated lookup + # failure — anything else must abort rather than force-push blind. + PUBLISHED_TREE="" + REF_STATUS=0 + REF_SHA="$(gh api "repos/${REPO}/git/ref/heads/${TARGET_REF}" --jq '.object.sha' 2>/dev/null)" || REF_STATUS=$? + if [ "${REF_STATUS}" -eq 0 ] && [ -n "${REF_SHA}" ]; then + PUBLISHED_TREE="$(gh api "repos/${REPO}/git/commits/${REF_SHA}" --jq '.tree.sha')" + else + echo "refs/heads/${TARGET_REF} not published yet" + fi + + if [ "${PUBLISHED_TREE}" = "${SUBTREE}" ]; then + echo "::notice ::orca-plugin-ref.unchanged refs/heads/${TARGET_REF} already carries subtree ${SUBTREE}" + exit 0 + fi + + # Parentless root commit whose ROOT IS `plugins/genie`. `commit-tree` + # writes no working tree and copies no history. + COMMIT="$(git commit-tree "${SUBTREE}" \ + -m "chore(orca-plugin): plugins/genie subtree of ${BRANCH} ${SOURCE_SHA}")" + + gh auth setup-git + # Force is structural, not a race override: every publish is a fresh + # root commit, so the ref can only ever be replaced, never advanced. + git push --force origin "${COMMIT}:refs/heads/${TARGET_REF}" + echo "::notice ::orca-plugin-ref.published refs/heads/${TARGET_REF} = ${COMMIT} (subtree ${SUBTREE})" diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 3e7fc0e82..cc0450bb5 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -185,7 +185,7 @@ jobs: # SECURITY: every command in this step is trusted workflow control loaded # from main. Do not run package scripts, dependency installers, hooks, or # any executable from the dev checkout in this write-capable workflow. - - name: Synchronize the exact ten version fields + - name: Synchronize the exact nine version fields if: steps.context.outputs.should_bump == 'true' env: VERSION: ${{ steps.version.outputs.version }} @@ -193,7 +193,6 @@ jobs: set -euo pipefail JSON_FILES=( package.json - orca-plugin.json plugins/genie/.claude-plugin/plugin.json plugins/genie/.codex-plugin/plugin.json plugins/genie/.kimi-plugin/plugin.json @@ -270,7 +269,7 @@ jobs: EXPECTED="$(printf '%s\n' "${VERSION_PATHS[@]}" | LC_ALL=C sort)" ACTUAL="$(git diff --cached --name-only -- | LC_ALL=C sort)" if [[ "$ACTUAL" != "$EXPECTED" ]]; then - echo "::error ::release-version.delta_untrusted expected exactly ten version files" + echo "::error ::release-version.delta_untrusted expected exactly nine version files" printf 'expected:\n%s\nactual:\n%s\n' "$EXPECTED" "$ACTUAL" >&2 exit 1 fi diff --git a/CLAUDE.md b/CLAUDE.md index 2a81f33e0..eaf8b8ef4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,7 +183,7 @@ Biome's `noExcessiveCognitiveComplexity` is set to `maxAllowedComplexity: 25` (w - **Hook dispatch is provider-aware and fail-closed** — the plugin-local Codex launcher selects only the canonical `$GENIE_HOME/bin/genie`, preserves stdin/stdout/signals, bounds child time/output, validates event-specific JSON, and converts launch/timeout/schema failures into a reasoned deny. Claude dispatch remains a short-lived in-process `genie hook dispatch` fork. Neither path is a daemon. - **`AskUserQuestion` is the one PreToolUse carve-out** — it is in `NON_INTERCEPTABLE_PRE_TOOL_USE_TOOLS` and MUST get an EMPTY response, not the neutral `{ decision: 'block' }` block form. Empirically CC consumes any additionalContext as the synthesized answer, so a fail-closed block would corrupt the inline picker. The fail-closed envelope special-cases this tool. - **The product MCP server and its launchers are retired** — `genie mcp` is a stub that writes a stable diagnostic to stderr and exits 1; no plugin ships an MCP declaration or launcher, and `genie init` only retires proven Genie-owned historical routes (in `.mcp.json` it retires only the dead `genie mcp` entry — a genie binary with args exactly `["mcp"]` — backing the file up first and preserving every other server byte-for-byte; `genie doctor` warns while that entry is still present). The UI-owned `genie ui-bridge` is retired too — there is no Genie UI any more, the Orca integration is the only UI surface — so it is the same shape of stub (stable stderr diagnostic, exit 1), and its private transport (`mcp-server.ts`), tool registry (`mcp-tools.ts`), and change watcher (`bridge-watcher.ts`) are deleted. -- **The two repo-root Orca files are source-only, and never ship in a tarball** — Orca accepts exactly a marketplace source (git repo whose ROOT holds `orca-marketplace.json`) or a plugin source (git repo whose ROOT holds `orca-plugin.json`, or a local folder containing one), so the nested `plugins/genie/orca-plugin.json` alone makes the repo un-installable from git. Root `/orca-plugin.json` is the payload manifest with `main` re-rooted to `plugins/genie/orca-entrypoint.min.js` and nothing else changed; root `/orca-marketplace.json` publishes the single `automagik.genie` entry at ref `main`. `scripts/build-binary.sh` copies neither, so `release-payload-version.ts` must NOT stamp or gate them — their version currency comes from `scripts/version.ts` and the `version.yml` JSON_FILES list (now ten version files), and `release-guard.sh` treats the root manifest as an OPTIONAL version-only child member exactly like the payload manifest. `scripts/orca-manifest-parity.test.ts` is the drift guard. `genie setup --orchestration-mode orca` selects authority only; it never registers the plugin with Orca. +- **The Orca plugin ships as a tree-only subtree ref, never from the repo root** — Orca installs a plugin from a git URL+ref (or a local folder) whose ROOT holds `orca-plugin.json`, and its loader rejects any tree containing a symlink ("unsafe file path or symlink") and caps an install at 2000 files / 50 MB. The genie root can therefore never be the install tree (`docs -> .docs-vendor/genie`; ~14k files in a dev checkout), and a re-rooted root `orca-plugin.json` does NOT fix that — do not reintroduce one, and do not add it to `scripts/version.ts`, the `version.yml` JSON_FILES list (still nine version files), or `release-guard.sh`. `plugins/genie` alone is symlink-free, ~132 files, ~1.3 MB, and holds the manifest at its root, so `.github/workflows/orca-plugin-ref.yml` force-pushes `git commit-tree HEAD:plugins/genie` (a parentless, history-free commit) to `refs/heads/orca-plugin` from main and `refs/heads/orca-plugin-dev` from dev, skipping when the tree hash already matches. Those refs are never merged back. The only repo-root Orca file is `orca-marketplace.json` — a source-only, versionless index pointing `automagik.genie` at ref `orca-plugin`, copied into no tarball. `scripts/orca-manifest-parity.test.ts` is the drift guard and also asserts `plugins/genie` stays symlink-free and inside the file cap. `genie setup --orchestration-mode orca` selects authority only; it never registers the plugin with Orca. - **Lifecycle authority is an explicit mode, never inferred** — `standalone` is the default (including when `orchestration.mode` is absent); installing or opening Orca changes nothing. `genie setup --orchestration-mode orca` probes the shipped plugin payload and a compatible Orca runtime before atomically switching, after which Genie refuses local `genie.db` lifecycle reads/writes and roadmap writes/syncs/exports. Switching back with `--orchestration-mode standalone` imports no Orca state. There is no fallback database in either direction. - **Two `genie.db` files, never cross-import** — per-repo `.genie/genie.db` (`genie-db.ts`, task/board/wish) and global `~/.genie/genie.db` (`global-db.ts`, omni queue + inbox) are independent databases with their own schemas and `user_version`. `global-db.ts` shares only `sqlite-open.ts` with the per-repo one — do not reach across for path constants. - **Codex integration health is native state, not OTel** — the old Genie exporter at `127.0.0.1:14318` has no relay and is removed by an exact-match, backup-first migration. Preserve unrelated OTel settings and `disable_paste_burst`. diff --git a/README.md b/README.md index 04cab0d55..88e8323e7 100644 --- a/README.md +++ b/README.md @@ -71,17 +71,26 @@ plugin with Orca — that is a separate, Orca-side install. Orca accepts exactly - a **marketplace source**: a git repo whose *root* holds `orca-marketplace.json`; - a **plugin source**: a git repo whose *root* holds `orca-plugin.json`, or a local folder containing `orca-plugin.json`. -This repository root carries both, so all three routes work: +**The genie repository root can never be the plugin tree.** Orca's loader rejects any install tree containing a symlink +("unsafe file path or symlink") and caps an install at 2000 files / 50 MB. This repo has `docs -> .docs-vendor/genie`, +runs to roughly 14,000 files in a dev checkout, and keeps its manifest nested at `plugins/genie/orca-plugin.json`, which +a git plugin source never looks at. So the plugin is published as a **tree-only git ref whose root *is* +`plugins/genie`** — symlink-free, ~132 files, ~1.3 MB: | Route | What to give Orca | |-------|-------------------| -| Marketplace source | `https://github.com/automagik-dev/genie.git`, ref `main` — lists `automagik.genie` | -| Plugin git source | `https://github.com/automagik-dev/genie.git`, ref `main` | -| Local folder | `~/.genie/plugins/genie` (what `genie update` ships), or this repo's checkout root for contributors | - -The root `orca-plugin.json` is the same manifest as the shipped `plugins/genie/orca-plugin.json`, differing only in -`main`, which is re-rooted to `plugins/genie/orca-entrypoint.min.js`. It is source-only: no release tarball contains it, -and `scripts/orca-manifest-parity.test.ts` fails the build if the two manifests, or the marketplace entry, ever drift. +| Marketplace source | `https://github.com/automagik-dev/genie.git`, ref `main` — the index; the plugin itself resolves to ref `orca-plugin` | +| Plugin git source | `https://github.com/automagik-dev/genie.git`, ref `orca-plugin` (stable) or `orca-plugin-dev` (pre-release) | +| Local folder | `~/.genie/plugins/genie` (what `genie install`/`genie update` ships) | + +`.github/workflows/orca-plugin-ref.yml` republishes those refs: every push to `main` that touches `plugins/genie` +force-pushes a parentless commit carrying that subtree to `refs/heads/orca-plugin`, and every such push to `dev` does the +same to `refs/heads/orca-plugin-dev`. They are tree-only by design — no history, no shared ancestry with `main`, never +merged back. Orca pins the commit it fetched, so a republish cannot retroactively change an existing install. + +The repo root carries only `orca-marketplace.json`, a source-only index no release tarball contains. +`scripts/orca-manifest-parity.test.ts` fails the build if the index drifts from the plugin's identity, or if +`plugins/genie` ever grows a symlink or crosses Orca's file cap. ### Install, update, rollback, and uninstall diff --git a/orca-marketplace.json b/orca-marketplace.json index 4bf261851..0e28ffc89 100644 --- a/orca-marketplace.json +++ b/orca-marketplace.json @@ -7,7 +7,7 @@ "source": { "kind": "git", "url": "https://github.com/automagik-dev/genie.git", - "ref": "main" + "ref": "orca-plugin" }, "description": "Genie workflows backed by Orca as the sole lifecycle authority.", "categories": ["workflows"] diff --git a/orca-plugin.json b/orca-plugin.json deleted file mode 100644 index 9ac16c416..000000000 --- a/orca-plugin.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "manifestVersion": 1, - "id": "genie", - "publisher": "automagik", - "name": "Genie", - "version": "5.260830.9", - "description": "Genie workflows backed by Orca as the sole lifecycle authority.", - "author": { - "name": "Namastex Labs", - "url": "https://github.com/automagik-dev" - }, - "repository": "https://github.com/automagik-dev/genie", - "engines": { - "orca": ">=1.4.192" - }, - "pluginApi": 1, - "main": "plugins/genie/orca-entrypoint.min.js", - "contributes": { - "commands": [ - { - "id": "genie.orca.run-list", - "title": "Genie: List Orca Runs" - } - ] - }, - "capabilities": [] -} diff --git a/plugins/genie/references/orca-orchestration.md b/plugins/genie/references/orca-orchestration.md index c498ddcd7..6981d7520 100644 --- a/plugins/genie/references/orca-orchestration.md +++ b/plugins/genie/references/orca-orchestration.md @@ -84,26 +84,53 @@ acts. Genie never registers itself with Orca; the operator adds a source. Orca a 1. a **marketplace source** — a git repo whose ROOT holds `orca-marketplace.json`; 2. a **plugin source** — a git repo whose ROOT holds `orca-plugin.json`, or a local folder containing `orca-plugin.json`. -Genie's shipped manifest lives at `plugins/genie/orca-plugin.json`, which satisfies neither git-source rule on its own — -a nested manifest is invisible to both. The repository root therefore carries two additional source-only files: - -- `orca-plugin.json` — byte-identical to the payload manifest except for `main`, which is re-rooted to - `plugins/genie/orca-entrypoint.min.js`. Same `id`, `publisher`, `version`, `engines`, and contribution set: it is the - same plugin, not a second one. -- `orca-marketplace.json` — a single-entry index publishing `automagik.genie` from - `https://github.com/automagik-dev/genie.git` at ref `main`. The ref is the stable branch, not a tag; Orca pins the - fetched commit itself. - -Operator routes, all equivalent: marketplace source `https://github.com/automagik-dev/genie.git` (ref `main`); plugin git -source, same URL and ref; or a local folder — `~/.genie/plugins/genie` for an installed Genie, or this repo's checkout -root for contributors (the latter exercises the root manifest's nested `main` resolution). - -Maintenance contract: neither root file is copied into any release tarball, so `release-payload-version.ts` deliberately -does not stamp or gate them. Their version currency comes from `scripts/version.ts` and the `version.yml` bump list -instead, and `release-guard.sh` treats the root manifest as an OPTIONAL version-only member of the auto-version child -delta for the same reason the payload manifest is optional: `workflow_run` executes main's `version.yml`, so a field -added on dev is inert until promotion. Drift between the two manifests, or between the marketplace entry and the -manifest identity, fails `scripts/orca-manifest-parity.test.ts`. +### Why the repo root is not the plugin tree + +Orca's loader imposes three constraints that this repository root cannot satisfy: + +- **No symlinks.** Any symlink anywhere in the tree fails the whole install with "unsafe file path or symlink". `docs` + is a symlink into the `.docs-vendor` submodule. +- **2000 files / 50 MB.** A dev checkout is roughly 14,000 files. +- **The manifest must be at the ROOT.** Genie's manifest is nested at `plugins/genie/orca-plugin.json`, and a git plugin + source only ever looks at the root of the fetched tree. + +Adding a second, re-rooted `orca-plugin.json` at the repo root does not help: it fixes only the third constraint and +leaves the first two fatal. `plugins/genie` on its own satisfies all three — symlink-free, ~132 files, ~1.3 MB, manifest +at its root — so that subtree is what gets published. + +### The published refs + +`.github/workflows/orca-plugin-ref.yml` mirrors `plugins/genie` into two refs of this same repository: + +| Source branch | Published ref | Channel | +|---------------|---------------|---------| +| `main` | `refs/heads/orca-plugin` | stable — what `orca-marketplace.json` points at | +| `dev` | `refs/heads/orca-plugin-dev` | pre-release | + +Each publish is a parentless commit created with `git commit-tree` over `HEAD:plugins/genie` and force-pushed. The refs +are **tree-only by design**: no history, no shared ancestry with `main` or `dev`, and never merged back. The workflow is +idempotent — it compares the branch's subtree hash against the published ref's tree and exits without pushing when they +match. Orca pins the commit it fetched, so a republish cannot retroactively change an existing install. + +### Operator routes + +- Marketplace source: `https://github.com/automagik-dev/genie.git` at ref `main` (the index lives at the repo root); the + entry it lists resolves the plugin from the same URL at ref `orca-plugin`. +- Plugin git source: `https://github.com/automagik-dev/genie.git` at ref `orca-plugin`, or `orca-plugin-dev` for the + pre-release channel. Never `main` or `dev` — those roots are not installable trees. +- Local folder: `~/.genie/plugins/genie`, the payload `genie install` / `genie update` ships. A contributor can point + Orca at the `plugins/genie` directory of a checkout for the same reason; the checkout ROOT is not a valid folder + source. + +### Maintenance contract + +`orca-marketplace.json` is source-only: `build-binary.sh` copies it into no tarball, so `release-payload-version.ts` +deliberately does not stamp or gate it. It carries no version field — it names an identity and a ref, both stable across +releases — so it is absent from `scripts/version.ts` and the `version.yml` bump list, and the repo root holds no +`orca-plugin.json` for either to stamp. `scripts/orca-manifest-parity.test.ts` is the drift guard: it fails when the +marketplace entry stops matching `publisher.id` or the description of `plugins/genie/orca-plugin.json`, when the source +ref stops being `orca-plugin`, when a root `orca-plugin.json` reappears, or when `plugins/genie` grows a symlink or +crosses Orca's 2000-file cap and stops being installable. ## Lifecycle and release maintenance diff --git a/scripts/orca-manifest-parity.test.ts b/scripts/orca-manifest-parity.test.ts index ce759c267..96d5d96a3 100644 --- a/scripts/orca-manifest-parity.test.ts +++ b/scripts/orca-manifest-parity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { existsSync, readFileSync } from 'node:fs'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; /** * Orca accepts exactly two source kinds: @@ -9,19 +9,29 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'; * 2. a PLUGIN source — a git repo whose ROOT holds `orca-plugin.json`, or a * local folder containing `orca-plugin.json`. * - * Genie's shipped manifest lives at `plugins/genie/orca-plugin.json`, which is - * the only copy Orca ever loads from an installed payload (or from a local - * folder install of `~/.genie/plugins/genie`). Neither root file is copied into - * the release tarball — they exist so the PUBLIC REPO ROOT is itself a valid - * Orca marketplace source and a valid Orca git plugin source. They are - * source-only, and this test is the contract that keeps them from drifting away - * from the payload manifest. + * Genie's only manifest lives at `plugins/genie/orca-plugin.json`. The genie + * repository ROOT can never be the plugin install tree: Orca's loader rejects + * any tree containing a symlink, and caps an install at 2000 files / 50 MB — + * this repo has `docs -> .docs-vendor/genie` and a five-figure file count. So + * the plugin is published as a TREE-ONLY ref (`orca-plugin` from main, + * `orca-plugin-dev` from dev) whose root IS `plugins/genie`, republished by + * `.github/workflows/orca-plugin-ref.yml`. + * + * The repo root therefore carries exactly one Orca file: `orca-marketplace.json`, + * a source-only index that points Orca at that ref. This test is the contract + * that keeps the index pointing at the real plugin identity, and keeps + * `plugins/genie` inside the limits that make the published subtree installable. */ const REPO_ROOT = resolve(dirname(import.meta.path), '..'); -const ROOT_MANIFEST = join(REPO_ROOT, 'orca-plugin.json'); -const PAYLOAD_MANIFEST = join(REPO_ROOT, 'plugins/genie/orca-plugin.json'); +const PAYLOAD_DIR = join(REPO_ROOT, 'plugins/genie'); +const PAYLOAD_MANIFEST = join(PAYLOAD_DIR, 'orca-plugin.json'); const ROOT_MARKETPLACE = join(REPO_ROOT, 'orca-marketplace.json'); +const PLUGIN_REF = 'orca-plugin'; + +// Orca's bundled loader limits, mirrored here so the published subtree can never +// silently grow past what Orca will install. +const MAX_FILES = 2000; function readJsonObject(path: string): Record { const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); @@ -31,44 +41,33 @@ function readJsonObject(path: string): Record { return parsed as Record; } -describe('root Orca manifest and marketplace parity', () => { - test('both root files parse as JSON objects', () => { - expect(() => readJsonObject(ROOT_MANIFEST)).not.toThrow(); - expect(() => readJsonObject(ROOT_MARKETPLACE)).not.toThrow(); - }); - - test('the root manifest matches the payload manifest on every key except main', () => { - const root = readJsonObject(ROOT_MANIFEST); - const payload = readJsonObject(PAYLOAD_MANIFEST); - - expect(Object.keys(root).sort()).toEqual(Object.keys(payload).sort()); - for (const key of Object.keys(payload)) { - if (key === 'main') continue; - expect({ key, value: root[key] }).toEqual({ key, value: payload[key] }); - } - // Identical contribution sets: the root manifest is the same plugin, only - // re-rooted. It must never grow skills or commands the payload lacks. - expect(root.contributes).toEqual(payload.contributes); - }); - - test('the root manifest main is repo-root relative and resolves to a real file', () => { - const root = readJsonObject(ROOT_MANIFEST); - const payload = readJsonObject(PAYLOAD_MANIFEST); - expect(typeof root.main).toBe('string'); - expect(typeof payload.main).toBe('string'); - const rootMain = root.main as string; - const payloadMain = payload.main as string; +/** + * Enumerate the committed `plugins/genie` tree — the exact bytes the workflow + * republishes as the plugin ref — reporting every path and every symlink + * (git mode 120000) in it. + */ +function payloadTree(): { paths: string[]; symlinks: string[] } { + const listed = Bun.spawnSync(['git', '-C', REPO_ROOT, 'ls-files', '-s', '--', 'plugins/genie']); + if (listed.exitCode !== 0) throw new Error(`git ls-files failed: ${listed.stderr.toString()}`); + const paths: string[] = []; + const symlinks: string[] = []; + for (const line of listed.stdout.toString().split('\n')) { + if (line.length === 0) continue; + const [meta, path] = line.split('\t'); + paths.push(path); + if (meta.startsWith('120000 ')) symlinks.push(path); + } + return { paths, symlinks }; +} - expect(isAbsolute(rootMain)).toBe(false); - expect(rootMain.startsWith('../')).toBe(false); - // The root entry point is the payload entry point, re-rooted at the repo root. - expect(rootMain).toBe(join('plugins/genie', payloadMain)); - expect(existsSync(join(REPO_ROOT, rootMain))).toBe(true); - expect(existsSync(join(REPO_ROOT, 'plugins/genie', payloadMain))).toBe(true); +describe('Orca marketplace index and published plugin subtree', () => { + test('both Orca JSON documents parse as JSON objects', () => { + expect(() => readJsonObject(ROOT_MARKETPLACE)).not.toThrow(); + expect(() => readJsonObject(PAYLOAD_MANIFEST)).not.toThrow(); }); test('the root marketplace lists exactly the genie plugin at publisher.id', () => { - const manifest = readJsonObject(ROOT_MANIFEST); + const manifest = readJsonObject(PAYLOAD_MANIFEST); const marketplace = readJsonObject(ROOT_MARKETPLACE); const plugins = marketplace.plugins; if (!Array.isArray(plugins)) throw new Error('root marketplace has no plugins array'); @@ -78,35 +77,65 @@ describe('root Orca manifest and marketplace parity', () => { expect(entry.id).toBe(`${manifest.publisher as string}.${manifest.id as string}`); expect(entry.description).toBe(manifest.description); expect(entry.categories).toEqual(['workflows']); + }); - // A git source pinned to the stable branch: Orca resolves and pins the - // fetched commit itself, so `main` (not a tag) is the intended ref. + test('the marketplace source points at the tree-only plugin ref, not at a branch of this repo', () => { + const manifest = readJsonObject(PAYLOAD_MANIFEST); + const marketplace = readJsonObject(ROOT_MARKETPLACE); + const entry = (marketplace.plugins as Record[])[0]; const source = entry.source as Record; + expect(source.kind).toBe('git'); expect(source.url).toBe(`${manifest.repository as string}.git`); - expect(source.ref).toBe('main'); + // `main`/`dev` are NOT installable: their root is the repo root, which holds + // no orca-plugin.json, contains a symlink, and blows past Orca's file cap. + expect(source.ref).toBe(PLUGIN_REF); }); - test('the root manifest is stamped like every other version file', () => { - const rootVersion = readJsonObject(ROOT_MANIFEST).version; - expect(rootVersion).toBe(readJsonObject(join(REPO_ROOT, 'package.json')).version); - expect(rootVersion).toBe(readJsonObject(PAYLOAD_MANIFEST).version); + test('the plugin ref is republished from plugins/genie by a workflow, not by hand', () => { + const workflow = readFileSync(join(REPO_ROOT, '.github/workflows/orca-plugin-ref.yml'), 'utf8'); + expect(workflow).toContain('HEAD:plugins/genie'); + expect(workflow).toContain('git commit-tree'); + // main publishes the ref the marketplace points at; dev publishes the + // pre-release channel. + expect(workflow).toContain(`main) TARGET_REF=${PLUGIN_REF} ;;`); + expect(workflow).toContain('dev) TARGET_REF=orca-plugin-dev ;;'); + expect(workflow).toContain('refs/heads/${TARGET_REF}'); + }); - // version.ts and the CI bump list must both carry the root manifest. - expect(readFileSync(join(REPO_ROOT, 'scripts/version.ts'), 'utf8')).toContain("join(rootDir, 'orca-plugin.json')"); - expect(readFileSync(join(REPO_ROOT, '.github/workflows/version.yml'), 'utf8')).toMatch( + test('plugins/genie is installable as an Orca plugin root: a manifest, no symlinks, inside the file cap', () => { + const { paths, symlinks } = payloadTree(); + // Orca rejects the whole tree on the first symlink ("unsafe file path or symlink"). + expect(symlinks).toEqual([]); + expect(paths.length).toBeLessThanOrEqual(MAX_FILES); + expect(paths).toContain('plugins/genie/orca-plugin.json'); + }); + + test('the payload manifest main is a plain relative path that resolves inside the subtree', () => { + const manifest = readJsonObject(PAYLOAD_MANIFEST); + expect(typeof manifest.main).toBe('string'); + const main = manifest.main as string; + expect(main.startsWith('/')).toBe(false); + expect(main.startsWith('../')).toBe(false); + // Resolved against the SUBTREE root (which is what Orca sees), not the repo root. + expect(statSync(join(PAYLOAD_DIR, main)).isFile()).toBe(true); + }); + + test('the repo root carries no orca-plugin.json — the root is not an installable plugin tree', () => { + expect(readdirSync(REPO_ROOT)).not.toContain('orca-plugin.json'); + // Nothing may stamp or gate a root manifest that must not exist. + expect(readFileSync(join(REPO_ROOT, 'scripts/version.ts'), 'utf8')).not.toContain( + "join(rootDir, 'orca-plugin.json')", + ); + expect(readFileSync(join(REPO_ROOT, '.github/workflows/version.yml'), 'utf8')).not.toMatch( /JSON_FILES=\(\n\s+package\.json\n\s+orca-plugin\.json\n/, ); }); - test('the root manifest is NOT part of the shipped release payload', () => { + test('the marketplace index is source-only and never ships in the release payload', () => { const build = readFileSync(join(REPO_ROOT, 'scripts/build-binary.sh'), 'utf8'); - // Nothing copies the repo-root manifest into the staged tarball, so payload - // stamping (release-payload-version.ts) must not gate it either. - expect(build).not.toContain('"${REPO_ROOT}/orca-plugin.json"'); expect(build).not.toContain('orca-marketplace.json'); const payloadStamp = readFileSync(join(REPO_ROOT, 'scripts/release-payload-version.ts'), 'utf8'); - expect(payloadStamp).not.toMatch(/^\s+'orca-plugin\.json',$/m); expect(payloadStamp).not.toContain("'orca-marketplace.json'"); }); }); diff --git a/scripts/release-docs.test.ts b/scripts/release-docs.test.ts index 12c2f958c..366d14dd3 100644 --- a/scripts/release-docs.test.ts +++ b/scripts/release-docs.test.ts @@ -71,11 +71,10 @@ describe('Group E release and documentation contracts', () => { ]) { expect(workflow).toContain(path); } - // The repo-root Orca manifest joined the bump list; it is a bare root path, - // so assert its exact position in JSON_FILES rather than a substring that - // 'plugins/genie/orca-plugin.json' would satisfy on its own. - expect(workflow).toMatch(/JSON_FILES=\(\n\s+package\.json\n\s+orca-plugin\.json\n/); - expect(workflow).toContain('expected exactly ten version files'); + // There is no repo-root Orca manifest to stamp: the Orca plugin ships as the + // tree-only `plugins/genie` subtree ref, not from the repo root. + expect(workflow).not.toMatch(/JSON_FILES=\(\n\s+package\.json\n\s+orca-plugin\.json\n/); + expect(workflow).toContain('expected exactly nine version files'); expect(workflow).toContain('git diff --cached --name-only'); expect(workflow).toContain('git commit --no-verify'); expect(workflow).toContain('git push --atomic origin "HEAD:refs/heads/dev"'); @@ -841,14 +840,19 @@ describe('Group E release and documentation contracts', () => { expect(operator).toContain('The legacy Genie MCP server is retired'); expect(operator).toContain('pre-A7 signed release'); expect(operator).toContain('plugins/genie/references/orca-orchestration.md'); - // Registering the plugin with Orca is a separate, operator-side act, and - // both root source files must be documented on both surfaces. + // Registering the plugin with Orca is a separate, operator-side act, and the + // route is the tree-only subtree ref — never a branch of this repo, whose + // root Orca's loader rejects (symlink + file cap). for (const topic of [ 'Installing the plugin in Orca', 'orca-marketplace.json', 'https://github.com/automagik-dev/genie.git', '~/.genie/plugins/genie', 'scripts/orca-manifest-parity.test.ts', + 'orca-plugin-dev', + '.github/workflows/orca-plugin-ref.yml', + 'symlink', + '2000 files', ]) { expect(operator).toContain(topic); expect(contributor).toContain(topic); diff --git a/scripts/release-guard.sh b/scripts/release-guard.sh index a2b23ec85..b2914ca2c 100755 --- a/scripts/release-guard.sh +++ b/scripts/release-guard.sh @@ -151,13 +151,11 @@ check_ci_run_record() { # normalized documents prevents an allowlisted package.json from smuggling a # script or dependency change past the parent commit's successful CI run. # -# Both native Orca manifests are OPTIONAL members of that delta: `workflow_run` +# The native Orca manifest is an OPTIONAL member of that delta: `workflow_run` # executes the `version.yml` on main, so a bump field added on dev is inert -# until promotion. A child that omits either is still the deterministic bump of -# the workflow that produced it; when present each must be version-only like -# every other member. The payload manifest's shipped copy is stamped inside the -# payload anyway, and the repo-root manifest (`orca-plugin.json`, which makes -# the public repo root an Orca git plugin source) ships in no tarball at all. +# until promotion. A child that omits it is still the deterministic bump of +# the workflow that produced it; when present it must be version-only like +# every other member. Its shipped copy is stamped inside the payload anyway. version_child_matches_parent() { local parent_sha="$1" child_sha="$2" version="$3" path changed expected child_yaml local -a json_paths=( @@ -169,9 +167,6 @@ version_child_matches_parent() { plugins/pi-genie/package.json ) changed="$(git diff --name-only "$parent_sha" "$child_sha" -- | LC_ALL=C sort)" || return 1 - if grep -qx 'orca-plugin.json' <<<"$changed"; then - json_paths+=(orca-plugin.json) - fi if grep -qx 'plugins/genie/orca-plugin.json' <<<"$changed"; then json_paths+=(plugins/genie/orca-plugin.json) fi diff --git a/scripts/release-guard.test.ts b/scripts/release-guard.test.ts index c9c20c622..16c19c2c4 100644 --- a/scripts/release-guard.test.ts +++ b/scripts/release-guard.test.ts @@ -305,7 +305,6 @@ function writeVersionTree(root: string, version: string, packageScript?: string) 'package.json', { name: '@automagik/genie', version, ...(packageScript ? { scripts: { postinstall: packageScript } } : {}) }, ], - ['orca-plugin.json', { id: 'genie', version, main: 'plugins/genie/orca-entrypoint.min.js' }], ['plugins/genie/.claude-plugin/plugin.json', { name: 'genie', version }], ['plugins/genie/.codex-plugin/plugin.json', { name: 'genie', version }], ['plugins/genie/.kimi-plugin/plugin.json', { name: 'genie', version }], @@ -366,41 +365,6 @@ describe('check-version-child (parent CI inheritance)', () => { expect(result.exitCode).toBe(0); }); - // Same optional-member posture for the repo-root Orca manifest, which makes - // the public repo root a valid Orca marketplace / git plugin source and ships - // in no tarball. - test('accepts a version-only child that omits the repo-root Orca manifest', () => { - const fixture = versionRepo(); - git(fixture.root, 'checkout', '-q', '--detach', fixture.parent); - writeVersionTree(fixture.root, '5.260714.2'); - git(fixture.root, 'checkout', '-q', fixture.parent, '--', 'orca-plugin.json'); - git(fixture.root, 'add', '.'); - git(fixture.root, 'commit', '-qm', 'chore(version): bump to 5.260714.2 [auto-version]'); - const child = git(fixture.root, 'rev-parse', 'HEAD'); - expect(git(fixture.root, 'diff', '--name-only', fixture.parent, child).split('\n')).not.toContain( - 'orca-plugin.json', - ); - - const result = guard('check-version-child', {}, [fixture.parent, child, '5.260714.2'], fixture.root); - expect(result.exitCode).toBe(0); - }); - - test('still rejects a child whose repo-root Orca manifest bump is not version-only', () => { - const fixture = versionRepo(); - git(fixture.root, 'checkout', '-q', '--detach', fixture.parent); - writeVersionTree(fixture.root, '5.260714.2'); - writeFileSync( - join(fixture.root, 'orca-plugin.json'), - `${JSON.stringify({ id: 'genie', version: '5.260714.2', main: 'evil.js' }, null, 2)}\n`, - ); - git(fixture.root, 'add', '.'); - git(fixture.root, 'commit', '-qm', 'chore(version): bump to 5.260714.2 [auto-version]'); - const child = git(fixture.root, 'rev-parse', 'HEAD'); - - const result = guard('check-version-child', {}, [fixture.parent, child, '5.260714.2'], fixture.root); - expect(result.exitCode).toBe(3); - }); - test('still rejects a child whose Orca manifest bump is not version-only', () => { const fixture = versionRepo(); git(fixture.root, 'checkout', '-q', '--detach', fixture.parent); diff --git a/scripts/release-payload-version.test.ts b/scripts/release-payload-version.test.ts index fe0351289..699d5b534 100644 --- a/scripts/release-payload-version.test.ts +++ b/scripts/release-payload-version.test.ts @@ -135,30 +135,6 @@ describe('release payload version contract', () => { expect(JSON.parse(readFileSync(join(root, 'plugins/genie/orca-plugin.json'), 'utf8')).version).toBe('5.260711.10'); }); - // The repo-root `orca-plugin.json` exists so the public repo root is itself a - // valid Orca git plugin source (and `orca-marketplace.json` a valid - // marketplace source). build-binary.sh copies neither into the tarball, so - // payload stamping must ignore both: they are source-only, kept current by - // scripts/version.ts and the version.yml bump list instead. - test('the repo-root Orca manifest and marketplace are outside the release payload', () => { - const root = fixture(); - writeJson(root, 'orca-plugin.json', { - id: 'genie', - version: '5.000000.0-lagging', - main: 'plugins/genie/orca-entrypoint.min.js', - }); - expect(verifyCommittedReleaseVersions(root)).toBe('5.000000.0'); - - stampReleasePayloadVersion(root, '5.260711.10'); - verifyReleasePayloadVersion(root, '5.260711.10'); - expect(JSON.parse(readFileSync(join(root, 'orca-plugin.json'), 'utf8')).version).toBe('5.000000.0-lagging'); - - // A payload with no root manifest at all still stamps and verifies. - const bare = fixture(); - stampReleasePayloadVersion(bare, '5.260711.11'); - expect(() => verifyReleasePayloadVersion(bare, '5.260711.11')).not.toThrow(); - }); - test('fails closed on missing metadata, malformed versions, and an invalid Codex marketplace target', () => { const root = fixture(); rmSync(join(root, 'plugins/genie/package.json')); diff --git a/scripts/version-ci-staging.test.ts b/scripts/version-ci-staging.test.ts index 1ab40f558..3135e8f7c 100644 --- a/scripts/version-ci-staging.test.ts +++ b/scripts/version-ci-staging.test.ts @@ -31,7 +31,6 @@ describe('synchronizeVersionFiles CI staging', () => { }; for (const path of [ 'package.json', - 'orca-plugin.json', 'plugins/genie/.claude-plugin/plugin.json', 'plugins/genie/.codex-plugin/plugin.json', 'plugins/genie/.kimi-plugin/plugin.json', @@ -74,9 +73,6 @@ describe('synchronizeVersionFiles CI staging', () => { expect(staged).toContain('plugins/hermes-genie/plugin.yaml'); expect(staged).toContain('plugins/pi-genie/package.json'); expect(staged).toContain('plugins/genie/orca-plugin.json'); - // The repo-root Orca manifest ships in no tarball, so this staging list is - // the only thing that keeps its committed version current. - expect(staged).toContain('orca-plugin.json'); expect(staged).toContain('package.json'); expect(staged).toContain('.claude-plugin/marketplace.json'); // The rewritten value is actually on disk (staging did not mask a no-op). diff --git a/scripts/version-format.test.ts b/scripts/version-format.test.ts index bab798727..3fdb75a95 100644 --- a/scripts/version-format.test.ts +++ b/scripts/version-format.test.ts @@ -46,7 +46,6 @@ describe('manifest version formatting', () => { }; for (const path of [ 'package.json', - 'orca-plugin.json', 'plugins/genie/.claude-plugin/plugin.json', 'plugins/genie/.codex-plugin/plugin.json', 'plugins/genie/.kimi-plugin/plugin.json', @@ -123,8 +122,6 @@ describe('manifest version formatting', () => { // The pi plugin manifest carries the same release version. expect(JSON.parse(readFileSync(join(root, 'plugins/pi-genie/package.json'), 'utf8')).version).toBe('5.260711.3'); expect(JSON.parse(readFileSync(join(root, 'plugins/genie/orca-plugin.json'), 'utf8')).version).toBe('5.260711.3'); - // Repo-root Orca manifest (marketplace/git-source entry point) too. - expect(JSON.parse(readFileSync(join(root, 'orca-plugin.json'), 'utf8')).version).toBe('5.260711.3'); rmSync(join(root, 'plugins/genie/.codex-plugin/plugin.json')); await expect(synchronizeVersionFiles(root, '5.260711.4')).rejects.toThrow( diff --git a/scripts/version.ts b/scripts/version.ts index cf781f42f..e15f6659f 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -12,10 +12,6 @@ * * Syncs versions across: * - package.json (root) - * - orca-plugin.json (root Orca plugin manifest — source-only; it makes the - * public repo root a valid Orca git plugin source. It is NOT copied into the - * release tarball, so payload stamping does not cover it and this list is the - * only thing that keeps it current.) * - plugins/genie/.claude-plugin/plugin.json (Claude Code) * - plugins/genie/.codex-plugin/plugin.json (Codex) * - plugins/genie/orca-plugin.json (Orca) @@ -201,7 +197,6 @@ function stageRewrittenFilesInCi(rootDir: string, paths: string[]): void { export async function synchronizeVersionFiles(rootDir: string, version: string): Promise { const paths = [ join(rootDir, 'package.json'), - join(rootDir, 'orca-plugin.json'), join(rootDir, 'plugins/genie/.claude-plugin/plugin.json'), join(rootDir, 'plugins/genie/.codex-plugin/plugin.json'), join(rootDir, 'plugins/genie/.kimi-plugin/plugin.json'),