Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/workflows/drift-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# THE GENERATOR SIDE OF THE CORPUS CONTRACT — this repository's own drift gate,
# in CI.
#
# The opencharly/marketplace repo's deploy workflow owns the CORPUS side: it
# regenerates the corpus with its PINNED toolchain and fails when the committed
# tree does not match. Nothing owned the GENERATOR side. This repository's
# `task skills:drift` recipe existed with no CI that ran it, so a red gate
# stayed red until a human happened to run it by hand — the decay
# opencharly/plugin-marketplace#42 names. This job runs that recipe on every
# merge to `main`.
#
# THE BASELINE is the corpus commit THIS repository declares — `var:
# MARKETPLACE_REF` in candy/plugin-marketplace/charly.yml — READ FROM THAT FILE,
# never written here. A second literal would be a second pin, and a second pin
# drifts (R2). The declared commit is immutable and reproducible, so the
# comparison is stable: it proves "the generator in THIS tree reproduces the
# corpus commit this plugin declares".
#
# WHY IT RUNS ON `main` AND NOT ON `pull_request`. A generator change that
# alters the emitted output makes the declared corpus commit stale, and the
# corpus can only be regenerated with a RELEASED generator — so a pull-request
# gate would be RED BY CONSTRUCTION for exactly the change it should inform,
# and it would DEADLOCK the chain that repairs it (generator merged and tagged
# -> the corpus repo regenerates with that tag -> the pin bumped here). It
# would also be a de-facto required check: the org pr-validator's `arm` job
# waits for EVERY check on the head and arms native auto-merge only when all of
# them are green, so one red run of this job on a pull request would stop the
# merge that the repair chain needs first. So this job runs where the claim it
# makes is decidable — on the merged tree — and its red is a signal with a
# mechanical remedy, not a wall:
#
# 1. the tree's generator changed the output -> let the corpus repo
# regenerate with the released tag, then bump MARKETPLACE_REF here (and
# the literal that duplicates it in the candy's plan — the duplication is
# by design, and `task marketplace:pin` asserts both against the corpus's
# live `main` head);
# 2. the corpus advanced -> bump MARKETPLACE_REF to the corpus's new `main`
# head.
#
# The toolchain is built from SOURCE at a pinned tag, the same way the corpus's
# own gate builds it: a released binary would be a different toolchain than the
# one the corpus was regenerated with, and the two halves of the contract have
# to be comparable.
name: drift-gate

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

# Never let two runs of the same ref race; the later one wins.
concurrency:
group: drift-gate-${{ github.ref }}
cancel-in-progress: true

jobs:
drift:
name: drift
runs-on: ubuntu-latest
# The drift resolves EVERY corpus source (the refs list names ~1200 repos at
# pinned versions) before it compares a single byte: measured 11m48s in the
# marketplace repo's own deploy workflow, ~12m locally from cold. The bound
# is generous, not a budget — a step killed for slowness is a false red.
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0

- name: Read the declared corpus commit
id: corpus
run: |
# The pin is found by its ANCHOR, not by position: the candy's file carries
# a third 40-hex string (the sha256 of the SKILL.md it copies), so a
# positional "first/last 40-hex match" is not the pin.
sha="$(grep -m 1 -oE 'MARKETPLACE_REF: [0-9a-f]{40}' candy/plugin-marketplace/charly.yml | grep -m 1 -oE '[0-9a-f]{40}')"
test -n "$sha" || { echo "no MARKETPLACE_REF found in candy/plugin-marketplace/charly.yml" >&2; exit 1; }
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "corpus baseline: $sha"

- name: Check out the corpus at the declared commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0
with:
repository: opencharly/marketplace
ref: ${{ steps.corpus.outputs.sha }}
path: .ci/marketplace
persist-credentials: false

- name: Check out the pinned charly toolchain
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.0
with:
repository: opencharly/charly
ref: v2026.296.2116
path: .ci/charly
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: .ci/charly/charly/go.mod
cache-dependency-path: .ci/charly/charly/go.sum

- name: Build the charly host
run: |
mkdir -p "$GITHUB_WORKSPACE/.ci/charly/bin"
cd "$GITHUB_WORKSPACE/.ci/charly/charly"
go build -buildvcs=false -ldflags "-X main.BuildCalVer=drift-gate-ci" -o "$GITHUB_WORKSPACE/.ci/charly/bin/charly" .

- name: Put the built charly on PATH
# The recipe's command is the bare word `charly`, NOT the ${CHARLY_BIN}
# placeholder: a standalone task has no build or deploy context, so that
# placeholder resolves to nothing and the step is SKIPPED — a gate that
# reports a skip instead of a result decays silently. The binary the
# host builds the plugin with must therefore be the one on PATH.
run: echo "$GITHUB_WORKSPACE/.ci/charly/bin" >> "$GITHUB_PATH"

- name: The drift gate
run: |
charly task skills:drift --param CORPUS="$GITHUB_WORKSPACE/.ci/marketplace"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ output/
*.swp
*~

# CI toolchain and corpus checkouts (the drift-gate workflow checks both out
# under .ci/); not authored artifacts, never versioned.
.ci/

# Migration rollback backups (charly migrate writes <file>.bak.<unix-ts>)
*.bak
*.bak.*
62 changes: 50 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The marketplace-corpus generator for
[opencharly/charly](https://github.com/opencharly/charly) — it serves
`charly marketplace …`, the command that regenerates the ENTIRE `charly-plugins`
marketplace + harness surface from candy config.
marketplace corpus from candy config.

It reads every candy's `skill:` / `hook:` / `marketplace:` kind entities (the
candywalk discovery), aggregates them by marketplace family, and emits the
Expand All @@ -13,7 +13,7 @@ standalone `opencharly/marketplace` repo's corpus: `<family>/skills/*/SKILL.md`
Claude Code / Cursor `.claude-plugin/marketplace.json`, the Codex / AGENTS
`.agents/plugins/marketplace.json`, `kimi.plugin.json` (the whole marketplace is
one plugin), `package.json` (pi — the whole marketplace is one pi package),
`profiles.json`, and the `setup` launcher.
`profiles.json`.

## What it provides

Expand All @@ -23,13 +23,16 @@ one plugin), `package.json` (pi — the whole marketplace is one pi package),

## The commands

- **`charly marketplace generate --out <dir>`** — regenerates the corpus under
`--out`, pruning generated trees by the DO-NOT-EDIT header first, so a deleted
source entity disappears instead of lingering.
- **`charly marketplace drift --out <dir>`** — the fail-closed no-op gate: the
same pipeline in memory, compared byte-for-byte with the artifacts ON DISK. Git
is never consulted, so drift proves "regeneration is a no-op", never "the tree
is committed". A stale mirror is a hard failure (exit 1).
- **`charly marketplace generate --root <corpus> --out <corpus>`** — regenerates
the corpus under `--out`, pruning generated trees by the DO-NOT-EDIT header
first, so a deleted source entity disappears instead of lingering. `--root` is
the repo root that holds the ONE generation input (the refs list,
`candy/charly-marketplace/charly.yml`); for the corpus that is the corpus
checkout itself, so both flags name it.
- **`charly marketplace drift --root <corpus> --out <corpus>`** — the fail-closed
no-op gate: the same pipeline in memory, compared byte-for-byte with the
artifacts ON DISK. Git is never consulted, so drift proves "regeneration is a
no-op", never "the tree is committed". A stale mirror is a hard failure (exit 1).

## Placement

Expand All @@ -46,7 +49,7 @@ The root `charly.yml` declares the `marketplace-app` box and the
`plugin-marketplace` candy, whose `plan:` clones the PUBLISHED
`opencharly/marketplace` repo at the pinned immutable `MARKETPLACE_REF` commit
and gates its shape (catalogs, no-version manifests, baked skill headers, the
executable `setup` launcher, the pinned-commit check).
pinned-commit check).

## How to use it

Expand All @@ -55,10 +58,45 @@ its pinned ref and connects the provider by word, so no project-side candy
composition is needed:

```bash
charly marketplace generate --out /path/to/marketplace
charly marketplace drift --out /path/to/marketplace
CORPUS=/path/to/marketplace
charly marketplace generate --root "$CORPUS" --out "$CORPUS"
charly marketplace drift --root "$CORPUS" --out "$CORPUS"
```

## The drift gate

`charly task skills:drift` is this repository's own gate: it runs the drift
comparison against a corpus checkout **and** the half `drift` cannot do — that the
corpus tree is committed (a generated-but-untracked artifact passes the byte
comparison and is lost on the next clone).

```bash
charly task skills:drift --param CORPUS=/path/to/marketplace
```

The `CORPUS` parameter is UPPER-CASE on purpose: the task runner's expansion
grammar matches upper-case names only, so a lower-case parameter never reaches the
command.

`.github/workflows/drift-gate.yml` runs this recipe on every merge to `main`,
against the corpus commit this repository **declares** — `var: MARKETPLACE_REF`
in `candy/plugin-marketplace/charly.yml` — which the job reads from that file
rather than repeating as a literal. The comparison proves that the generator in
this tree reproduces the corpus commit this plugin declares.

The job runs on `main` and not on pull requests, and that is a design
constraint, not a shortcut. A generator change that alters the emitted output
makes the declared corpus commit stale, and the corpus can only be regenerated
with a *released* generator — so a pull-request gate would be red by
construction for exactly the change it should inform, and it would deadlock the
chain that repairs it (the corpus is regenerated with the released tag, then
`MARKETPLACE_REF` is bumped here). A pull-request gate would also be a de-facto
required check: the org validator's `arm` job arms native auto-merge only when
every check on the head is green. A red run on `main` is a signal with a
mechanical remedy: let the corpus repo regenerate with the released tag, then
bump `MARKETPLACE_REF` (and the literal that duplicates it in the candy's
plan).

## Layout

- `candy/plugin-marketplace/` — the plugin module: `plugin.go`, `command.go`,
Expand Down
34 changes: 23 additions & 11 deletions candy/plugin-marketplace/charly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,32 @@ plugin-marketplace:
candy:
description: |-
RUNTIME (out-of-process) charly plugin serving `charly marketplace …` — the generator
that regenerates the ENTIRE `charly-plugins` marketplace + harness surface from candy
that regenerates the ENTIRE `charly-plugins` marketplace corpus from candy
config. It reads every candy's `skill:` / `hook:` / `marketplace:` kind entities (the
candywalk discovery), aggregates them by marketplace family, and emits — into --out,
the STANDALONE opencharly/marketplace repo's root (the corpus): <family>/skills/*/
SKILL.md + references/*.md, <family>/agents/*.md, per-family .claude-plugin/plugin.json
+ .codex-plugin/plugin.json + .mcp.json, .claude-plugin/marketplace.json (Claude Code /
Cursor), .agents/plugins/marketplace.json (Codex / the AGENTS framework), kimi.plugin.json
(Kimi — the whole marketplace is one plugin), package.json (pi — the whole marketplace
is one pi package), profiles.json, and the setup launcher — and — into --root, the
charly-local harness surface: the .claude/settings.json plugin-owned keys, .claude/hooks/*
(from hook entities) and the generated R0 dispatcher section in CLAUDE.md/AGENTS.md.
is one pi package), profiles.json. --root is NOT a second
destination: it is the repo root that holds the ONE generation input (the refs list,
`candy/charly-marketplace/charly.yml`), so both flags name the corpus checkout.
`charly marketplace drift` is the fail-closed no-op gate: it regenerates in memory and
compares with the artifacts ON DISK (the corpus under --out, the surface under --root),
compares with the artifacts ON DISK (the corpus under --out),
never with git, so it proves "regeneration is a no-op" and says nothing about whether
the tree is committed — this repository's `task skills:drift` recipe adds that `git
status` half, and the marketplace repo's deploy workflow runs the same gate on a fresh
checkout at its pinned charly. A stale mirror is a hard failure, but drift runs in no
CI workflow of this repo: the marketplace repo's own gate owns the corpus.
status` half, and this repository's own `drift-gate` workflow runs that recipe on
every merge to main, against the corpus commit MARKETPLACE_REF below (read from this
file, never repeated as a second literal), so the gate cannot wait for a human. The
marketplace repo's deploy
workflow runs the same command on a fresh checkout at its pinned charly: that job owns
the CORPUS side (the committed corpus matches its pinned toolchain), and this one owns
the GENERATOR side (this generator reproduces the corpus commit this plugin declares).
A stale mirror is a hard failure in both.

PLACEMENT — deliberately NOT listed in charly/charly.yml compiled_plugins. This is a
DEV-TIME generator, run on a contributor's machine to regenerate the harness surface
DEV-TIME generator, run on a contributor's machine to regenerate the corpus
and nowhere else, so it has no business inside every shipped static charly binary. The
generator is self-contained — it reads files and writes files, never reaching the host
reverse channel — which is what makes the out-of-process placement free here (the same
Expand All @@ -42,7 +47,14 @@ plugin-marketplace:
# clone layer's cache key moves only when this string moves. It must equal the
# marketplace repo's current `main` head; `task marketplace:pin` checks that, and the
# pinned-commit check below asserts it from inside the built image.
MARKETPLACE_REF: e802da680687c7016d1c9baf505880712d505076
#
# It is ALSO the baseline this repository's `drift-gate` workflow compares against:
# the job READS this value out of this file rather than repeating it, so the gate's
# baseline and this pin can never be two pins that drift apart. The corpus commit
# named here must be one this repository's generator reproduces — the gate proves
# exactly that, and the proof is the reason a bump to a newer corpus commit lands
# with the generator that reproduces it.
MARKETPLACE_REF: cc10955e4e0ac4f3f4bcc464a04433e653d71400
distro:
fedora:
package:
Expand Down Expand Up @@ -75,7 +87,7 @@ plugin-marketplace:
id: marketplace-pinned-commit
exit_status: 0
stdout:
- contains: e802da680687c7016d1c9baf505880712d505076
- contains: cc10955e4e0ac4f3f4bcc464a04433e653d71400
context:
- build
- runtime
Expand Down
15 changes: 8 additions & 7 deletions candy/plugin-marketplace/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,22 @@ type marketplaceCLI struct {

// generateCmd renders every generated artifact: the marketplace corpus into --out (the
// standalone opencharly/marketplace repo root — skills, agents, the per-family manifests, the
// Claude/Codex/kimi/pi catalogs, profiles.json, the setup launcher) and the charly-local harness
// surface into --root (.claude/ hooks + settings, the dispatcher section). It rewrites the
// Claude/Codex/kimi/pi catalogs, profiles.json). --root is the repo root
// that holds the ONE generation input (the refs list, candy/charly-marketplace/charly.yml); for
// the corpus that is the corpus checkout itself, so both flags name it. It rewrites the
// generated trees wholesale after pruning by the DO-NOT-EDIT header, so a deleted source entity
// disappears instead of lingering.
type generateCmd struct {
Root string `name:"root" help:"Repo root holding charly.yml, candy/, box/, .claude/ (default: cwd)"`
Root string `name:"root" help:"Repo root holding the generation input (the refs list) -- the corpus checkout (default: cwd)"`
Out string `name:"out" required:"" help:"Marketplace corpus root to write into (the opencharly/marketplace checkout)"`
}

// driftCmd is a fail-closed no-op check: the same pipeline in memory, compared byte-for-byte with
// the on-disk generated artifacts (the corpus under --out, the harness surface under --root).
// Any difference is a hard failure (exit 1) with a diff summary. It runs in no CI workflow — it
// is red only for whoever runs it, via `task skills:drift` or by hand.
// the on-disk generated artifacts (the corpus under --out). Any difference is a hard failure
// (exit 1) with a diff summary. The repository's own drift-gate workflow runs it on every push
// (via `task skills:drift`), so a stale mirror is red in CI, not only for whoever runs it.
type driftCmd struct {
Root string `name:"root" help:"Repo root (default: cwd)"`
Root string `name:"root" help:"Repo root holding the generation input (the refs list) -- the corpus checkout (default: cwd)"`
Out string `name:"out" required:"" help:"Marketplace corpus root to compare against (the opencharly/marketplace checkout)"`
}

Expand Down
3 changes: 2 additions & 1 deletion candy/plugin-marketplace/emit_marketplace.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import (
// emit_marketplace.go — the marketplace ROOT manifests: .claude-plugin/marketplace.json
// (the `charly-plugins` catalog the harness's extraKnownMarketplaces + the docs generator read),
// .agents/plugins/marketplace.json (the Codex CLI / AGENTS-framework catalog), and
// profiles.json (the developer/user/container_families membership the setup installer used).
// profiles.json (the developer/user/container_families membership, derived from each family's
// declared `profiles:` meta).
// All derive entirely from the marketplace entity — the single source.

// marketplaceManifest is the Claude Code catalog shape. `description` and `version` are
Expand Down
6 changes: 3 additions & 3 deletions candy/plugin-marketplace/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ func buildEmissions(ks *kindSet, families []family) emissions {
}

// drift runs the same pipeline in memory and compares with the on-disk generated artifacts (the
// corpus under out, the harness surface under root). Any difference is a hard failure (exit 1)
// with a diff summary. It never writes, and it runs in no CI workflow — it is red only for
// whoever runs it, via `task skills:drift` or by hand.
// corpus under out). Any difference is a hard failure (exit 1) with a diff summary. It never
// writes, and this repository's drift-gate workflow runs it on every push (via
// `task skills:drift`), so a stale mirror is red in CI, not only for whoever runs it by hand.
func drift(root, out string) error {
ks, err := readKinds(root)
if err != nil {
Expand Down
Loading