diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..32719ad --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,22 @@ +{ + "name": "pos-marketplace", + "owner": { + "name": "javiAI" + }, + "metadata": { + "description": "Marketplace for the pos Claude Code plugin.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "pos", + "source": { + "source": "github", + "repo": "javiAI/project-operating-system", + "ref": "v0.1.0" + }, + "description": "Generador y sistema operativo determinista para proyectos Claude Code: cuestionario → profile → repo generado con hooks, skills, policy, tests, CI/CD.", + "version": "0.1.0" + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bc09f77..5c6fe50 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://claude.com/schemas/plugin.json", "name": "pos", "displayName": "project-operating-system", - "version": "0.0.1", + "version": "0.1.0", "description": "Generador y sistema operativo determinista para proyectos Claude Code: cuestionario → profile → repo generado con hooks, skills, policy, tests, CI/CD.", "author": { "name": "javiAI", diff --git a/.claude/rules/ci-cd.md b/.claude/rules/ci-cd.md index 5d9f437..7ca4326 100644 --- a/.claude/rules/ci-cd.md +++ b/.claude/rules/ci-cd.md @@ -30,10 +30,10 @@ El hook `pre-push.sh` corre la suite local; GitHub Actions corre la misma suite - Valida `policy.yaml` vs `.claude/logs/` (skills que deberían haber corrido). - Escanea dependencias con advisory database (`npm audit`, `pip-audit`). -3. **`.github/workflows/release.yml`** — en tag `v*`: +3. **`.github/workflows/release.yml`** — en tag `v*` (entregado en F4): - Valida versión en `plugin.json` = tag. - - Publica release en GitHub con assets (plugin bundle). - - Actualiza `javiAI/pos-marketplace` vía PR automático (cuando exista). + - Publica release en GitHub con bundle curated plugin-only. + - Actualiza `javiAI/pos-marketplace` vía PR automático cuando `vars.POS_MARKETPLACE_REPO` está configurado (skippea silenciosamente si no — no bloquea release). ### Job `selftest` (entregado en F3) @@ -55,6 +55,27 @@ Job dedicado a integración end-to-end del propio plugin `pos`. Corre en `ubuntu **Drift sintético ↔ meta-repo**: el `policy.yaml` que emite la generación cli-tool todavía tiene el shape pre-D5b (template no migrado). Cada escenario reescribe la sección que necesita (`pre_write` / `pre_pr` / `post_merge` / `skills_allowed`) directamente en `synthetic/policy.yaml`. Esto desacopla la cobertura de D5b de la migración del template (rama propia post-F3). +### Job `release` (entregado en F4) + +Workflow dedicado [.github/workflows/release.yml](../../.github/workflows/release.yml). Trigger `push.tags: ['v*']`. Permissions `contents: write` para `gh release create`. Cinco jobs: + +- **`version-match`** — primer gate. Asserta `plugin.json.version == ${tag#v}`. Sin esto, el resto no corre. +- **`selftest`** — reusa el contrato F3 (`pytest bin/tests -q`) sobre el repo en el ref del tag. Cubre los 19 nuevos contract tests de F4 (marketplace + release.yml shape + plugin version pin) más los 9 de F3. +- **`build-bundle`** — empaqueta `pos-v${version}.tar.gz` con scope plugin-only curated (`.claude-plugin/`, `.claude/skills/`, `.claude/rules/`, `hooks/`, `agents/`, `policy.yaml`, `bin/pos-selftest.sh`, `bin/_selftest.py`, `docs/RELEASE.md`). Sube como artifact con retention 30d. +- **`publish-release`** — `needs: [version-match, selftest, build-bundle]`. Descarga el artifact, llama `gh release create v${version} --generate-notes `. **No** crea CHANGELOG.md enforced; usa autogenerated notes (decisión F4 — reabrir si patrón sale corto). +- **`mirror-marketplace`** — `if: vars.POS_MARKETPLACE_REPO != ''`. Clona el repo público, sincroniza `marketplace.json`, abre PR vía `gh`. Skippea silenciosamente cuando la variable está vacía (caso por defecto hoy, ya que `javiAI/pos-marketplace` no existe). Activación per-runbook [docs/RELEASE.md § Mirror al marketplace público](../../docs/RELEASE.md). + +**Bundle curated, no repo entero**: el consumer del marketplace instala el plugin para usarlo. `generator/`, `tools/`, `templates/`, `questionnaire/`, `bin/tests/`, `.github/`, fixtures quedan fuera (ortogonales al runtime del plugin). + +**Source of truth de versión**: `plugin.json.version`. Tag espeja (`v${version}`). `marketplace.json.plugins[0].source.ref` mirror-ea. Los tests `test_marketplace_json_schema.py` + `test_plugin_json_version_bump.py` cruzan los tres. + +**Out of scope F4 (diferidos)**: + +- `audit.yml` nightly (declarado en `policy.yaml.ci_cd.workflows` desde Fase A; sin consumer activo todavía). +- Skills `/pos:pr-description` y `/pos:release` (sin repetición demostrada — regla #7 CLAUDE.md). +- `CHANGELOG.md` enforced (autogenerated suffices hasta que falle). +- Creación efectiva del repo `javiAI/pos-marketplace` (manual cuando se decida ir live). + ## Workflows generados (proyecto destino) El generador emite workflows según `project_profile.yaml.git_host`. Soportados: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..252eb56 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,193 @@ +name: release + +on: + push: + tags: + - "v*" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + version-match: + name: version-match (plugin.json == tag) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Assert plugin.json.version == tag without v + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + expected="${tag#v}" + actual=$(python3 -c "import json; print(json.load(open('.claude-plugin/plugin.json'))['version'])") + if [ "$expected" != "$actual" ]; then + echo "::error ::tag $tag (without v: $expected) != plugin.json.version $actual" + exit 1 + fi + echo "ok: tag=$tag matches plugin.json.version=$actual" + + selftest: + name: selftest (ubuntu, py 3.11) + runs-on: ubuntu-latest + needs: [version-match] + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Setup Node + uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install Node deps + run: npm ci + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install dev deps + run: python -m pip install --upgrade pip && pip install -r requirements-dev.txt + + - name: Pytest selftest (smoke + scenarios) + run: pytest bin/tests -q + + build-bundle: + name: build-bundle (curated plugin-only) + runs-on: ubuntu-latest + needs: [version-match] + outputs: + bundle_path: ${{ steps.pack.outputs.bundle_path }} + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Pack curated bundle + id: pack + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + bundle="pos-${tag}.tar.gz" + tar -czf "$bundle" \ + .claude-plugin \ + .claude/skills \ + .claude/rules \ + hooks \ + agents \ + policy.yaml \ + bin/pos-selftest.sh \ + bin/_selftest.py \ + docs/RELEASE.md + echo "bundle_path=$bundle" >> "$GITHUB_OUTPUT" + echo "ok: built $bundle" + ls -lh "$bundle" + + - name: Upload bundle artifact + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45581b # v4.6.0 + with: + name: pos-bundle + path: ${{ steps.pack.outputs.bundle_path }} + if-no-files-found: error + retention-days: 30 + + publish-release: + name: publish-release + runs-on: ubuntu-latest + needs: [version-match, selftest, build-bundle] + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Download bundle artifact + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: pos-bundle + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + bundle="pos-${tag}.tar.gz" + gh release create "$tag" \ + --title "$tag" \ + --generate-notes \ + "$bundle" + + mirror-marketplace: + name: mirror-marketplace (skippable) + runs-on: ubuntu-latest + needs: [publish-release] + if: ${{ vars.POS_MARKETPLACE_REPO != '' }} + steps: + - name: Checkout + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + + - name: Open PR against marketplace repo + env: + GH_TOKEN: ${{ secrets.POS_MARKETPLACE_TOKEN }} + MARKETPLACE_REPO: ${{ vars.POS_MARKETPLACE_REPO }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + version="${tag#v}" + if [ -z "${MARKETPLACE_REPO:-}" ]; then + echo "POS_MARKETPLACE_REPO unset; skip mirror" + exit 0 + fi + workdir=$(mktemp -d) + cd "$workdir" + gh repo clone "$MARKETPLACE_REPO" mp -- --depth=1 + cd mp + branch="bump/pos-${version}" + git checkout -b "$branch" + python3 - <.hooks_required`, `audit.required_logs`) vs `.claude/logs/` reales; reporta drift candidates por bucket sin auto-fix. Policy: `skills_allowed` 13→14. Fase F abierta (3/4 ramas). - Fuente de verdad ejecutable: [MASTER_PLAN.md](MASTER_PLAN.md). @@ -134,14 +135,13 @@ Hasta F1 el plugin reusaba subagents built-in; desde F2 los críticos son propio ## 9. Próxima rama -**F4 — `feat/f4-marketplace-public-repo`** (tras merge de F3 — última rama de Fase F: marketplace público + release flow). +Fase F **cerrada** tras F4. Carry-overs abiertos: -Scope: - -- Crear repo `javiAI/pos-marketplace` con `marketplace.json` + release flow. -- Docs en `docs/RELEASE.md`. -- Workflow `.github/workflows/release.yml` (en tag `v*`): valida versión `plugin.json` = tag, publica release con assets, actualiza `pos-marketplace` vía PR automático. -- Decisión Fase -1: ¿shape de `marketplace.json`?, ¿cómo se versiona el plugin (`plugin.json` vs git tag)?, ¿qué assets entran en el release bundle?, ¿`audit.yml` nightly entra aquí o se difiere a una rama post-F4? +- **`refactor/template-policy-d5b-migration`** — rama propia para migrar `templates/policy.yaml.hbs` + renderer + snapshots al shape post-D5b (drift documentado desde D5b/F3; F3 lo evade vía overlays por escenario en `bin/_selftest.py`). No bloquea Fase G. +- **`feat/fx-knowledge-plane-plan`** (Fase G entry-point, opcional): docs-only — abre Fase G en MASTER_PLAN.md como capa knowledge plane opt-in. Sin fecha — el usuario decide cuándo arrancar. +- **Activación del marketplace público**: cuando se decida crear `javiAI/pos-marketplace`, seguir el runbook de [docs/RELEASE.md § Mirror al marketplace público](docs/RELEASE.md) (3 pasos: crear repo + `gh variable set POS_MARKETPLACE_REPO` + `gh secret set POS_MARKETPLACE_TOKEN`). El próximo release abre PR automático contra el repo público. +- **Skills `/pos:pr-description` + `/pos:release`**: diferidas por regla #7 CLAUDE.md (≥2 repeticiones documentadas). F4 entrega flow manual; cuando se observe el patrón ≥2 veces, extraer. +- **`audit.yml` nightly**: declarado en `policy.yaml.ci_cd.workflows` desde Fase A; sin consumer activo. Reabrir cuando `npm audit` + `pip-audit` + `/pos:audit-plugin --self` justifiquen ejecución periódica. ## 10. Estado E2b (✅ merged PR #22) @@ -466,3 +466,53 @@ Entregables completados: **Resultado**: **829 passed + 1 skipped** (vs baseline F2 819 + 1 skip = +10 nuevos: 4 smoke + 5 D-scenarios + 1 GREEN smoke ya merged). Sin regresión D1..D6 + E1a..E3b + F1 + F2. Selftest end-to-end local ~1.2s. **Detalle + deferrals + ajustes**: ver [ROADMAP.md § feat/f3-selftest-end-to-end](ROADMAP.md), [MASTER_PLAN.md § Rama F3](MASTER_PLAN.md), [.claude/rules/ci-cd.md § Job selftest (entregado en F3)](.claude/rules/ci-cd.md), [docs/ARCHITECTURE.md § 10 Selftest end-to-end](docs/ARCHITECTURE.md). + +## 22. Estado F4 (cerrada en rama, docs-sync en curso) + +`feat/f4-marketplace-public-repo` — **cuarta y última rama de Fase F**. Entrega la **infraestructura local de marketplace + release flow** del plugin `pos`. Cierra Fase F con el plugin distribuible: manifest oficial, workflow de release reproducible, runbook ejecutable, bump al primer release público (`0.1.0`). Repo público `javiAI/pos-marketplace` deliberadamente **diferido** — F4 deja la infra lista; la creación manual del repo público es decisión separada. + +**Entregables**: + +- `.claude-plugin/marketplace.json` (NEW) — manifest oficial del marketplace primitive de Claude Code. Top-level `{name, owner, plugins, metadata}`. `owner.name = "javiAI"`. `plugins[0]`: `{name: "pos", source: {source: "github", repo: "javiAI/project-operating-system", ref: "v0.1.0"}, version: "0.1.0", description}`. Single source of truth de cómo `/plugin install pos` resolverá repo + ref tras `marketplace add`. +- `.claude-plugin/plugin.json` — bump `version` 0.0.1 → 0.1.0 (primer release público; pre-1.0). Source of truth de la versión del plugin: tag git = `v${version}` y `marketplace.json.plugins[0].source.ref` lo espejan. Triángulo testado. +- `.github/workflows/release.yml` (NEW) — workflow trigger `push.tags: ['v*']`. Cinco jobs en orden estricto: (1) `version-match` asserta `plugin.json.version == ${tag#v}` (primer gate); (2) `selftest` reusa contrato F3 (`pytest bin/tests -q`) sobre el ref del tag; (3) `build-bundle` empaqueta `pos-v${version}.tar.gz` con scope plugin-only curated (`.claude-plugin/`, `.claude/skills/`, `.claude/rules/`, `hooks/`, `agents/`, `policy.yaml`, `bin/pos-selftest.sh`, `bin/_selftest.py`, `docs/RELEASE.md`); (4) `publish-release` `needs: [version-match, selftest, build-bundle]` + `gh release create v${version} --generate-notes `; (5) `mirror-marketplace` condicional `if: vars.POS_MARKETPLACE_REPO != ''` — skippea silenciosamente si la variable está vacía (caso por defecto hoy). Actions pinneadas por SHA (regla `ci-cd.md#2`). `permissions.contents: write` para `gh release create`. +- `docs/RELEASE.md` (NEW) — runbook completo: contrato de versionado (single source `plugin.json.version`), bundle scope (incluye/excluye), flujo en 5 pasos (preparar bump → tag → workflow → verificar → recovery), activación del mirror cuando exista repo público (`gh variable set POS_MARKETPLACE_REPO` + `gh secret set POS_MARKETPLACE_TOKEN`), instalación user-facing (`/plugin marketplace add javiAI/pos-marketplace` + `/plugin install pos`), branch protection del repo público (manual via Settings), diferidos. +- `.claude/rules/ci-cd.md` — bullet `release.yml` promovido de "Diferidos" a "Aterrizado" (entregado en F4). H3 `### Job release (entregado en F4)` documenta los 5 jobs + bundle curated + source of truth de versión + out-of-scope. +- `docs/ARCHITECTURE.md § 13` — sub-sección expandida "Marketplace + Release flow" cubriendo manifest, source of truth, jobs del workflow, bundle scope curated, deferral del repo público, determinismo del flujo, instalación user-facing, deferrals. +- Tests (3 archivos nuevos, 21 tests): + - `bin/tests/test_marketplace_json_schema.py` — 12 tests en 3 clases (`TestMarketplaceTopLevel`, `TestMarketplaceOwner`, `TestMarketplacePluginEntry`). Valida shape oficial: top-level keys, `owner.name`, `plugins[0].name == plugin.json.name`, `source.source == "github"`, `source.repo`, `source.ref == "v" + plugin.json.version`, `plugins[0].version == plugin.json.version`. + - `bin/tests/test_release_workflow_smoke.py` — 6 tests en 3 clases (`TestReleaseWorkflowShape`, `TestReleaseTrigger`, `TestReleaseJobs`). Valida: file exists + parses; trigger `push.tags: ['v*']` (handles PyYAML quirk de parsear `on:` como Python `bool True`); 5 jobs presentes; `publish-release.needs ⊇ {version-match, selftest, build-bundle}`; `mirror-marketplace.if` referencia `vars.POS_MARKETPLACE_REPO`. + - `bin/tests/test_plugin_json_version_bump.py` — 3 tests con `EXPECTED_VERSION = "0.1.0"` pin literal. Bumpear requiere actualizar el test en el mismo commit (auto-documenta milestone). + +**Contrato fijado por la suite** (extiende E1..F3 sin reabrirlos): + +- `marketplace.json` shape oficial: top-level `{name, owner, plugins}` con `owner.name` + `plugins[].name` + `plugins[].source.{source, repo, ref}` requeridos. Cualquier divergencia rompe en CI antes del tag. +- `plugin.json.version` ↔ `marketplace.json.plugins[0].source.ref` ↔ `marketplace.json.plugins[0].version`: triángulo testado. Bumpear uno sin los otros falla en CI. +- `plugin.json.version` ↔ `EXPECTED_VERSION`: pin literal — bumpear requiere PR explícito que actualice el test. +- `release.yml` shape: trigger `push.tags ['v*']`; 5 jobs específicos; `publish-release.needs ⊇ {version-match, selftest, build-bundle}`; `mirror-marketplace` condicional vía `vars.POS_MARKETPLACE_REPO` (skippea, no falla). +- Bundle scope curated: include explícito (8 paths), no "todo el repo". Si una rama futura añade un path al runtime del plugin, debe extender el `tar` step de `build-bundle`. + +**Decisiones cerradas en Fase -1 (8 ajustes ratificados por el usuario)**: + +- (A1.b) **Repo público diferido**: F4 entrega solo infra local. Mirror gated por `vars.POS_MARKETPLACE_REPO`; release no falla si la variable está vacía. +- (A2) **`marketplace.json` schema oficial**: top-level `{name, owner, plugins}` (no `plugins` directamente). `owner.name` requerido. `plugins[i].source.source = "github"` (string literal). +- (A3) **Versionado**: bump 0.0.1 → 0.1.0 (no 1.0.0; pre-1.0 explícito). `plugin.json.version` source of truth; tag = `v${version}`; `marketplace.json.plugins[0].source.ref` espeja. +- (A4) **Bundle curated plugin-only**: include set explícito (8 paths); excluye `generator/`, `templates/`, `questionnaire/`, `tools/`, `bin/tests/`, `.github/`, fixtures. +- (A5) **release.yml jobs**: 5 jobs en orden estricto (version-match → selftest + build-bundle → publish-release; mirror-marketplace condicional opt-in). Test que `publish-release.needs` los liste. +- (A6) **Diferidos NO en F4**: `audit.yml` nightly, `/pos:pr-description` + `/pos:release` skills, `CHANGELOG.md` enforced, `refactor/template-policy-d5b-migration`, Fase G. +- (A7) **Tests RED-first**: 3 archivos nuevos con 21 tests totales. +- (A8) **Docs scope**: `docs/RELEASE.md` (NEW) + `.claude/rules/ci-cd.md` + `docs/ARCHITECTURE.md § 13` + `ROADMAP.md` + `HANDOFF.md` + `MASTER_PLAN.md § Rama F4`. NO tocar: `policy.yaml`, `hooks/**`, `.claude/skills/**`, `agents/**`, `generator/**`, `templates/**`, `.claude/rules/skills-map.md`. + +**Ajuste durante GREEN** (gotcha persistente): PyYAML 1.1 parsea `on:` (clave canónica de triggers en GitHub Actions) como Python `bool True`. El test acepta ambos (`data.get("on") or data.get(True)`) — patrón aplicable a cualquier test futuro de workflow YAML. + +**Resultado**: **665 passed + 1 skipped** (vs baseline F3 644 + 1 skip = +21 nuevos tests F4). Sin regresión D1..D6 / E1a..E3b / F1..F3. `stop-policy-check.py` sigue en enforcement live con `ALLOWED_SKILLS = 14` (F4 no añade skills, solo manifest + workflow + tests). El skip es el D5 intencional `TestIntegrationDiffUnavailable` por subprocess-no-cover. + +**Carry-overs post-F4** (cierre de Fase F): + +- `refactor/template-policy-d5b-migration` — drift template post-D5b. Rama propia. +- Repo público `javiAI/pos-marketplace` — creación manual; activación 3-pasos en runbook. +- Skills `/pos:pr-description` + `/pos:release` — regla #7 CLAUDE.md (≥2 repeticiones). +- `audit.yml` nightly — sin consumer activo. +- `CHANGELOG.md` enforced — `--generate-notes` suffices hasta que falle. + +**Detalle + deferrals + ajustes**: ver [ROADMAP.md § feat/f4-marketplace-public-repo](ROADMAP.md), [MASTER_PLAN.md § Rama F4](MASTER_PLAN.md), [.claude/rules/ci-cd.md § Job release (entregado en F4)](.claude/rules/ci-cd.md), [docs/ARCHITECTURE.md § 13 Marketplace + Release flow](docs/ARCHITECTURE.md), [docs/RELEASE.md](docs/RELEASE.md). diff --git a/MASTER_PLAN.md b/MASTER_PLAN.md index c8a71f3..77d473e 100644 --- a/MASTER_PLAN.md +++ b/MASTER_PLAN.md @@ -816,9 +816,51 @@ Esperar aprobación explícita del usuario. Con OK → crear marker + rama. **Razón para no entregarlo en F3**: F3 es selftest. Mezclar migración del template inflaría el scope, retrasaría la cobertura D-gates, y los overlays por escenario son una solución limpia y auto-contenida que **prueba** la independencia hook/loader respecto al template. Documentar el drift como abierto + abrir stub explícito (este §) es la decisión correcta. -### Rama F4 — `feat/f4-marketplace-public-repo` +### Rama F4 — `feat/f4-marketplace-public-repo` ✅ PR pendiente -**Scope**: crear repo `javiAI/pos-marketplace` con `marketplace.json` + release flow. Docs en `docs/RELEASE.md`. +**Scope realizado**: aterrizar la **infra local** del marketplace + release flow del plugin `pos` sin depender de que `javiAI/pos-marketplace` exista todavía. Cierra el bloque audit + selftest + marketplace (última rama de Fase F) — primer artefacto público del plugin (manifest + release workflow + runbook). + +**Archivos entregados**: + +- `.claude-plugin/marketplace.json` (NEW) — schema oficial Claude Code marketplace. Top-level `{name, owner: {name}, plugins: [...]}`. `plugins[0]: {name: "pos", source: {source: "github", repo: "javiAI/project-operating-system", ref: "v0.1.0"}, version: "0.1.0", description}`. `metadata.{description, version}` para humans. Source of truth de qué publica este repo. +- `.claude-plugin/plugin.json` — bump `version: "0.0.1"` → `"0.1.0"` (primer release público; pre-1.0). Single source of truth de versión; tag git = `v${version}`. +- `.github/workflows/release.yml` (NEW) — trigger `push.tags: ['v*']`. Permissions `contents: write`. Cinco jobs encadenados: + - `version-match` — primer gate. Asserta `plugin.json.version == ${tag#v}`. + - `selftest` — reusa contrato F3 (`pytest bin/tests -q`) sobre el repo en el ref del tag. + - `build-bundle` — empaqueta `pos-v${version}.tar.gz` con scope curated plugin-only. + - `publish-release` — `needs: [version-match, selftest, build-bundle]`. `gh release create` con `--generate-notes` + bundle. + - `mirror-marketplace` — condicional `if: vars.POS_MARKETPLACE_REPO != ''`. Abre PR en repo público vía `gh`. Skippea silenciosamente si la variable está vacía (caso por defecto hoy). + - Actions pinneadas por SHA (ci-cd.md regla #2). +- `docs/RELEASE.md` (NEW) — runbook user-facing: contrato de versionado, scope del bundle, flujo de release paso a paso, recovery (pre/post `publish-release`), activación del mirror cuando `javiAI/pos-marketplace` exista, branch protection, lista de diferidos. +- `.claude/rules/ci-cd.md` — bullet `release.yml` actualizado de "(cuando exista)" a "(entregado en F4)" + nuevo H3 `### Job release (entregado en F4)` con scope completo. +- `docs/ARCHITECTURE.md § 13` — reescrita de placeholder de 6 líneas a sub-sección completa (manifest, source of truth de versión, workflow, bundle scope, repo público, determinismo, instalación user-facing, diferidos). +- Tests (RED → GREEN): + - `bin/tests/test_marketplace_json_schema.py` (NEW, 12 tests) — top-level keys, `owner.name`, plugin entry, sync `marketplace.json` ↔ `plugin.json` (name/version/ref). + - `bin/tests/test_release_workflow_smoke.py` (NEW, 6 tests) — shape, trigger `v*`, jobs presentes, `publish-release.needs ⊇ {version-match, selftest, build-bundle}`, `mirror-marketplace` conditional/skippable. + - `bin/tests/test_plugin_json_version_bump.py` (NEW, 3 tests) — pin `version == "0.1.0"`. Bumpear requiere actualizar el test (auto-documenta milestone). + +**Decisiones cerradas en Fase -1 (ratificadas por el usuario con 8 ajustes obligatorios sobre el plan v1)**: + +- **A1.b — repo público diferido**. F4 aterriza infra local + workflow listo; no crea ni depende de `javiAI/pos-marketplace`. Mirror gated por `vars.POS_MARKETPLACE_REPO`. No fallar release si el mirror no está configurado. Repo público se crea manualmente cuando se decida ir live. +- **A2 — schema oficial Claude Code marketplace**. Top-level `{name, owner, plugins}` (no inventar). `owner.name` requerido. Cada plugin requiere mínimo `{name, source}`. `source.{source: "github", repo, ref}`. Tests asertan los 7 cruces (top-level, owner, plugin name match, source.source, source.repo, source.ref, plugin.version sync). +- **A3 — version bump 0.0.1 → 0.1.0**. Pre-1.0 explícito. Contrato: `plugin.json.version` source of truth; tag = `v${version}`; `marketplace.json.source.ref` mirror-ea. +- **A4 — bundle curated plugin-only**. Incluye: `.claude-plugin/`, `.claude/skills/`, `.claude/rules/`, `hooks/`, `agents/`, `policy.yaml`, `bin/pos-selftest.sh`, `bin/_selftest.py`, `docs/RELEASE.md`. Excluye: `generator/`, `templates/`, `questionnaire/`, `tools/` (meta-repo, no runtime del plugin instalado). +- **A5 — `release.yml` jobs**. Cinco jobs (`version-match`, `selftest`, `build-bundle`, `publish-release`, `mirror-marketplace`). `publish-release.needs` cubre los tres gates anteriores. Test asserta el grafo de dependencias. +- **A6 — diferidos**. NO en F4: `audit.yml` nightly, skills `/pos:pr-description` + `/pos:release`, `CHANGELOG.md` enforced, `refactor/template-policy-d5b-migration`, Fase G. +- **A7 — tests RED-first**. 3 archivos `bin/tests/`. RED state: 19 failed + 12 passed (12 = F3 baseline 9 + 3 plugin.json existe/parses). GREEN state: 21 passed sobre los nuevos. +- **A8 — docs-sync**. Tocar: `docs/RELEASE.md` (NEW), `.claude/rules/ci-cd.md`, `docs/ARCHITECTURE.md § 13`, `ROADMAP.md`, `HANDOFF.md`, `MASTER_PLAN.md § F4` (este bloque). NO tocar: `policy.yaml`, `hooks/**`, `skills/**`, `agents/**`, `generator/**`, `templates/**`, `skills-map.md` (sin skills nuevas). + +**Contexto leído en Fase -1** (rangos): `MASTER_PLAN.md § F1/F2/F3` (precedentes), `HANDOFF.md §1/§9/§6b/§7`, `ROADMAP.md fila F4 + § Progreso Fase F`, `.claude/rules/ci-cd.md` (release.yml ya declarado planeado), `.claude-plugin/plugin.json` (shape canonical Claude Code), `policy.yaml § ci_cd.workflows + audit.session_audit`. + +**Criterio de salida**: **665 passed + 1 skipped** sobre pytest (baseline F3 645 + 20 netos: 12 marketplace + 6 release.yml + 3 plugin.json - 1 que ya pasaba pre-RED por existencia de plugin.json). Sin regresión D1..D6 + E1a..E3b + F1..F3. El skip sigue siendo el D5 intencional `TestIntegrationDiffUnavailable`. Vitest TS suite intacta (F4 no toca `generator/` ni `tools/`). Docs-sync dentro del PR (ROADMAP § F4 + HANDOFF §1/§9/§22 + MASTER_PLAN § Rama F4 expandida + `.claude/rules/ci-cd.md` release job promovido + `docs/ARCHITECTURE.md § 13` reescrita + `docs/RELEASE.md` nuevo). `pre-pr-gate.py` aprueba este mismo PR — required `ROADMAP.md` + `HANDOFF.md` satisfecho; conditional triggers no aplican (`bin/**`, `.github/**`, `.claude-plugin/**`, `docs/**` no están en `docs_sync_conditional` actual de policy.yaml). + +**Carry-overs post-F4 (cierre Fase F)**: + +- `audit.yml` nightly — declarado en `policy.yaml.ci_cd.workflows` desde Fase A; sin consumer activo. Reabrir en rama dedicada cuando `npm audit` + `pip-audit` + `/pos:audit-plugin --self` consuman cadencia automatizada. +- `/pos:pr-description` + `/pos:release` skills — listadas en `skills-map.md § Audit + Release` como "entregado en F"; F4 cierra el flow manual sin extraer skills (regla #7 — sin repetición demostrada). Reabrir cuando el manual repita patrón. +- Repo público `javiAI/pos-marketplace` — la creación es **manual**, no parte de F4. Activación del job `mirror-marketplace` requiere: (1) crear el repo, (2) `gh variable set POS_MARKETPLACE_REPO`, (3) `gh secret set POS_MARKETPLACE_TOKEN`. Runbook en `docs/RELEASE.md`. +- `refactor/template-policy-d5b-migration` — drift independiente, no bloquea F4 ni Fase G. +- Fase G (Knowledge Plane) — opcional, sin fecha; no afecta a F4. --- diff --git a/ROADMAP.md b/ROADMAP.md index 1ff6e14..27517cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ Estado vivo. Cada fila refleja una rama de [MASTER_PLAN.md](MASTER_PLAN.md). | `feat/f2-agents-subagents` | 2 plugin subagents en `agents/` con namespace `pos-*`: `pos-code-reviewer` (consumido por `pre-commit-review`), `pos-architect` (consumido por `compound`); `auditor` diferido (sin consumer real); `agents_allowed` diferido (sin enforcement consumer) | ✅ | — (PR pendiente) | | `feat/f3-selftest-end-to-end` | `bin/pos-selftest.sh` + orquestador Python + 5 escenarios funcionales-críticos (D1/D3/D4/D5/D6) sobre proyecto sintético | ✅ | #27 | | `refactor/template-policy-d5b-migration` | Migrar `templates/policy.yaml.hbs` + renderer + snapshots al shape post-D5b; cerrar drift documentado en D5b/F3 | ⏳ | — | -| `feat/f4-marketplace-public-repo` | `javiAI/pos-marketplace` + release flow | ⏳ | — | +| `feat/f4-marketplace-public-repo` | `marketplace.json` + `release.yml` (5 jobs: version-match, selftest, build-bundle, publish-release, mirror-marketplace condicional) + `docs/RELEASE.md` runbook + bump 0.0.1→0.1.0; repo público diferido | ✅ | — (PR pendiente) | | `feat/fx-knowledge-plane-plan` | Docs-only: abre FASE G en MASTER_PLAN (capa opcional knowledge plane) | ⏳ | — | | `feat/g1-knowledge-plane-contract` | Contrato tool-agnostic (raw/wiki/schema) + opt-in questionnaire | ⏳ | — | | `feat/g2-adapter-obsidian-reference` | Primer reference adapter: esqueleto `vault/` + Obsidian Web Clipper | ⏳ | — | @@ -694,6 +694,55 @@ Contrato fijado por la suite (extiende E1..F2 sin reabrirlos): **Criterio de salida**: 829 verdes + 1 skip. Sin regresión sobre F2. Docs-sync dentro del PR (ROADMAP § F3 + HANDOFF §1/§9/§21 + MASTER_PLAN § Rama F3 expandida + `.claude/rules/ci-cd.md` selftest job promovido + `docs/ARCHITECTURE.md § 10 Selftest end-to-end`). `pre-pr-gate.py` aprueba este mismo PR — los conditional triggers no aplican (`bin/**` no está en `docs_sync_conditional`, `.github/**` no está bajo `generator|hooks|skills|patterns`); required `ROADMAP.md` + `HANDOFF.md` satisfecho. +### `feat/f4-marketplace-public-repo` — ✅ (PR pendiente) + +Cuarta y última rama de Fase F — entrega la **infraestructura local de marketplace + release flow** del plugin `pos`. Cierra el ciclo "el plugin tiene un manifest distribuible y un workflow que publica releases reproducibles" sin asumir todavía la existencia del repo público `javiAI/pos-marketplace` (creación manual diferida). + +Entregables: + +- `.claude-plugin/marketplace.json` (NEW) — manifest oficial del marketplace primitive de Claude Code: top-level `{name, owner, plugins, metadata}`. `owner.name = "javiAI"`. `plugins[0]`: `{name: "pos", source: {source: "github", repo: "javiAI/project-operating-system", ref: "v0.1.0"}, version: "0.1.0", description}`. Single source of truth de cómo `/plugin install pos` resolverá el repo + ref tras `marketplace add`. +- `.claude-plugin/plugin.json` — bump version `0.0.1 → 0.1.0` (primer release público; pre-1.0 — semver puede romper entre minors hasta `1.0.0`). Single source of truth de la versión: tag git = `v${version}` y `marketplace.json.plugins[0].source.ref` lo espejan. +- `.github/workflows/release.yml` (NEW) — workflow trigger `push.tags: ['v*']`. Cinco jobs: (1) `version-match` asserta `plugin.json.version == ${tag#v}` (primer gate, sin esto el resto no corre); (2) `selftest` reusa contrato F3 (`pytest bin/tests -q`) sobre el ref del tag; (3) `build-bundle` empaqueta `pos-v${version}.tar.gz` con scope **plugin-only curated** (`.claude-plugin/`, `.claude/skills/`, `.claude/rules/`, `hooks/`, `agents/`, `policy.yaml`, `bin/pos-selftest.sh`, `bin/_selftest.py`, `docs/RELEASE.md`); (4) `publish-release` `needs: [version-match, selftest, build-bundle]` + `gh release create v${version} --generate-notes `; (5) `mirror-marketplace` condicional `if: vars.POS_MARKETPLACE_REPO != ''` — si la variable está vacía skippea silenciosamente sin romper el release. Actions pinneadas por SHA (regla `ci-cd.md#2`). `permissions.contents: write` para `gh release create`. +- `docs/RELEASE.md` (NEW) — runbook completo: contrato de versionado (single source `plugin.json.version`), bundle scope (incluye/excluye), flujo en 5 pasos (preparar bump → tag → workflow → verificar → recovery), activación del mirror cuando exista repo público (`gh variable set POS_MARKETPLACE_REPO` + `gh secret set POS_MARKETPLACE_TOKEN`), instalación user-facing (`/plugin marketplace add javiAI/pos-marketplace` + `/plugin install pos`), branch protection del repo público (manual via Settings), diferidos. +- `.claude/rules/ci-cd.md` — bullet `release.yml` promovido de "Diferidos" a "Aterrizado" (entregado en F4). H3 `### Job release (entregado en F4)` documenta los 5 jobs + bundle curated + source of truth de versión + out-of-scope. +- `docs/ARCHITECTURE.md § 13` — sub-sección expandida "Marketplace + Release flow" cubriendo manifest, source of truth, jobs del workflow, bundle scope curated, deferral del repo público, determinismo del flujo, instalación user-facing, deferrals. +- `bin/tests/test_marketplace_json_schema.py` (NEW, 12 tests) — 3 clases: `TestMarketplaceTopLevel` (5 tests: keys top-level, name, owner, plugins lista no vacía), `TestMarketplaceOwner` (1 test: `owner.name`), `TestMarketplacePluginEntry` (6 tests: name match `plugin.json`, source.source==github, source.repo, source.ref==`v${version}`, plugin.version sync con `plugin.json`). +- `bin/tests/test_release_workflow_smoke.py` (NEW, 6 tests) — 3 clases: `TestReleaseWorkflowShape` (file exists + parses), `TestReleaseTrigger` (trigger `push.tags ['v*']`; maneja PyYAML quirk de parsear `on:` como Python `True`), `TestReleaseJobs` (5 jobs presentes, `publish-release.needs ⊇ {version-match, selftest, build-bundle}`, `mirror-marketplace.if` referencia `vars.POS_MARKETPLACE_REPO`). +- `bin/tests/test_plugin_json_version_bump.py` (NEW, 3 tests) — `EXPECTED_VERSION = "0.1.0"` pin literal; bumpear requiere actualizar el test en el mismo commit (auto-documenta el milestone). + +**Decisiones cerradas en Fase -1 (8 ajustes ratificados por el usuario)**: + +- (A1.b) **Repo público diferido**: F4 entrega solo infra local. `javiAI/pos-marketplace` se crea manualmente cuando se decida ir live. Mirror gated por `vars.POS_MARKETPLACE_REPO`; release no falla si la variable está vacía. +- (A2) **`marketplace.json` schema oficial**: top-level `{name, owner, plugins}` (no `plugins` directamente). `owner.name` requerido. `plugins[i].source.source = "github"` (string literal, no enum custom). +- (A3) **Versionado**: bump 0.0.1 → 0.1.0 (no 1.0.0; pre-1.0 explícito). `plugin.json.version` source of truth; tag = `v${version}`; `marketplace.json.plugins[0].source.ref` espeja. +- (A4) **Bundle curated plugin-only**: include set explícito (8 paths); excluye `generator/`, `templates/`, `questionnaire/`, `tools/`, `bin/tests/`, `.github/`, fixtures. Razón: el consumer del marketplace instala el plugin para usarlo, no para regenerar proyectos. +- (A5) **release.yml jobs**: 5 jobs en orden estricto (version-match → selftest + build-bundle → publish-release; mirror-marketplace condicional opt-in). Test que `publish-release.needs` los liste. +- (A6) **Diferidos NO en F4**: `audit.yml` nightly (sin consumer activo), `/pos:pr-description` + `/pos:release` skills (sin ≥2 repeticiones — regla #7), `CHANGELOG.md` enforced (autogenerated suffices), `refactor/template-policy-d5b-migration` (rama propia post-F4), Fase G entera. +- (A7) **Tests RED-first**: 3 archivos nuevos con 21 tests totales. RED commit verde-only sobre tests del 0.1.0 cae cuando `plugin.json` aún tiene 0.0.1 — flippa en GREEN. +- (A8) **Docs scope**: actualizar `docs/RELEASE.md` (NEW), `.claude/rules/ci-cd.md`, `docs/ARCHITECTURE.md § 13`, `ROADMAP.md`, `HANDOFF.md`, `MASTER_PLAN.md § Rama F4`. NO tocar: `policy.yaml`, `hooks/**`, `.claude/skills/**`, `agents/**`, `generator/**`, `templates/**`, `.claude/rules/skills-map.md`. + +**Ajuste durante GREEN** (gotcha persistente, no debug noise): PyYAML 1.1 parsea `on:` (clave canónica de triggers en GitHub Actions) como Python `bool True`. El test de trigger acepta ambos (`data.get("on") or data.get(True)`) — patrón aplicable a cualquier test futuro de workflow YAML. + +Suite global post-F4: **665 passed + 1 skipped** (vs baseline F3 644 + 1 skip = +21 nuevos tests F4: 12 marketplace schema + 6 release workflow + 3 plugin version pin). Sin regresión D1..D6 / E1a..E3b / F1..F3. + +Contrato fijado por la suite (extiende E1..F3 sin reabrirlos): + +- `marketplace.json` shape oficial: top-level `{name, owner, plugins}` con `owner.name` + `plugins[].name` + `plugins[].source.{source,repo,ref}` requeridos. Cualquier divergencia rompe en CI antes del tag. +- `plugin.json.version` ↔ `marketplace.json.plugins[0].source.ref` ↔ `marketplace.json.plugins[0].version`: triángulo testado. Bumpear uno sin los otros falla. +- `plugin.json.version` ↔ `EXPECTED_VERSION` en `test_plugin_json_version_bump.py`: pin literal — bumpear requiere PR explícito que actualice el test. +- `release.yml` shape: trigger `push.tags ['v*']`; 5 jobs específicos; `publish-release.needs ⊇ {version-match, selftest, build-bundle}`; `mirror-marketplace` condicional vía `vars.POS_MARKETPLACE_REPO` (skippea, no falla). +- Bundle scope curated: include explícito, no "todo el repo". Si una rama futura añade un path al runtime del plugin, debe extender el `tar` step. + +**Carry-overs post-F4** (cierre Fase F): + +- `audit.yml` nightly — declarado en `policy.yaml.ci_cd.workflows[name=audit.yml]` desde Fase A; sin consumer activo. Reabrir cuando `npm audit` + `pip-audit` + `/pos:audit-plugin --self` justifiquen ejecución periódica. +- `/pos:pr-description` y `/pos:release` skills — diferidas por regla #7 CLAUDE.md (≥2 repeticiones documentadas). F4 entrega flow manual; cuando se observe el patrón ≥2 veces, extraer skill. +- `CHANGELOG.md` enforced — F4 usa `--generate-notes` de `gh release create` (autogenerado de commits + PRs). Reabrir si el patrón sale corto. +- Repo público `javiAI/pos-marketplace` — creación manual cuando se decida ir live. Activación por-runbook `docs/RELEASE.md § Mirror al marketplace público` (3 pasos: crear repo + `gh variable set` + `gh secret set`). +- `refactor/template-policy-d5b-migration` — rama propia post-F4 para migrar `templates/policy.yaml.hbs` al shape post-D5b (drift abierto desde F3). + +**Criterio de salida**: 665 verdes + 1 skip. Sin regresión sobre F3. Docs-sync dentro del PR (ROADMAP § F4 + HANDOFF §1/§9/§22 + MASTER_PLAN § Rama F4 expandida + `.claude/rules/ci-cd.md` release job promovido + `docs/ARCHITECTURE.md § 13 Marketplace + Release flow` reescrita + `docs/RELEASE.md` nuevo runbook). `pre-pr-gate.py` aprueba este mismo PR — required `ROADMAP.md` + `HANDOFF.md` satisfecho; conditional `.github/**` no está bajo `generator|hooks|skills|patterns` (no requirement adicional). + ## Convenciones de este archivo - Una fila por rama. `⏳` pendiente, `🔄` en vuelo, `✅` completada, `❌` abandonada, `🚫` bloqueada. diff --git a/bin/tests/test_marketplace_json_schema.py b/bin/tests/test_marketplace_json_schema.py new file mode 100644 index 0000000..02f1164 --- /dev/null +++ b/bin/tests/test_marketplace_json_schema.py @@ -0,0 +1,106 @@ +"""F4 RED — locks down .claude-plugin/marketplace.json contract. + +Fails until: +- .claude-plugin/marketplace.json exists and parses as JSON +- Top-level keys present: name, owner, plugins +- owner.name present +- plugins[0].name matches plugin.json.name ("pos") +- plugins[0].source.source == "github" +- plugins[0].source.repo == "javiAI/project-operating-system" +- plugins[0].source.ref == "v" + plugin.json.version +- if plugins[0].version present, equals plugin.json.version +""" + +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +MARKETPLACE_JSON = REPO_ROOT / ".claude-plugin" / "marketplace.json" +PLUGIN_JSON = REPO_ROOT / ".claude-plugin" / "plugin.json" + + +def _load_marketplace(): + assert MARKETPLACE_JSON.is_file(), f"missing: {MARKETPLACE_JSON}" + return json.loads(MARKETPLACE_JSON.read_text()) + + +def _load_plugin(): + assert PLUGIN_JSON.is_file(), f"missing: {PLUGIN_JSON}" + return json.loads(PLUGIN_JSON.read_text()) + + +class TestMarketplaceTopLevel: + def test_marketplace_json_exists(self): + assert MARKETPLACE_JSON.is_file(), f"missing: {MARKETPLACE_JSON}" + + def test_marketplace_json_parses(self): + data = _load_marketplace() + assert isinstance(data, dict), "marketplace.json must be a JSON object" + + def test_top_level_name_present(self): + data = _load_marketplace() + assert "name" in data, "top-level 'name' required" + assert isinstance(data["name"], str) and data["name"].strip(), "'name' must be non-empty string" + + def test_top_level_owner_present(self): + data = _load_marketplace() + assert "owner" in data, "top-level 'owner' required" + assert isinstance(data["owner"], dict), "'owner' must be an object" + + def test_top_level_plugins_present(self): + data = _load_marketplace() + assert "plugins" in data, "top-level 'plugins' required" + assert isinstance(data["plugins"], list) and data["plugins"], "'plugins' must be non-empty list" + + +class TestMarketplaceOwner: + def test_owner_name_present(self): + data = _load_marketplace() + assert "name" in data["owner"], "owner.name required" + assert isinstance(data["owner"]["name"], str) and data["owner"]["name"].strip() + + +class TestMarketplacePluginEntry: + def test_plugin_has_required_keys(self): + data = _load_marketplace() + plugin = data["plugins"][0] + assert "name" in plugin, "plugins[0].name required" + assert "source" in plugin, "plugins[0].source required" + + def test_plugin_name_matches_plugin_json(self): + data = _load_marketplace() + plugin_meta = _load_plugin() + assert data["plugins"][0]["name"] == plugin_meta["name"], ( + f"marketplace plugin name {data['plugins'][0]['name']!r} != plugin.json name {plugin_meta['name']!r}" + ) + + def test_plugin_source_is_github(self): + data = _load_marketplace() + source = data["plugins"][0]["source"] + assert isinstance(source, dict), "plugins[0].source must be an object" + assert source.get("source") == "github", f"source.source must be 'github', got {source.get('source')!r}" + + def test_plugin_source_repo_pinned(self): + data = _load_marketplace() + source = data["plugins"][0]["source"] + assert source.get("repo") == "javiAI/project-operating-system", ( + f"source.repo must be 'javiAI/project-operating-system', got {source.get('repo')!r}" + ) + + def test_plugin_source_ref_matches_plugin_version(self): + data = _load_marketplace() + plugin_meta = _load_plugin() + source = data["plugins"][0]["source"] + expected_ref = "v" + plugin_meta["version"] + assert source.get("ref") == expected_ref, ( + f"source.ref must be {expected_ref!r}, got {source.get('ref')!r}" + ) + + def test_plugin_version_matches_plugin_json_when_present(self): + data = _load_marketplace() + plugin_meta = _load_plugin() + plugin = data["plugins"][0] + if "version" in plugin: + assert plugin["version"] == plugin_meta["version"], ( + f"marketplace plugin version {plugin['version']!r} != plugin.json version {plugin_meta['version']!r}" + ) diff --git a/bin/tests/test_plugin_json_version_bump.py b/bin/tests/test_plugin_json_version_bump.py new file mode 100644 index 0000000..b3057a1 --- /dev/null +++ b/bin/tests/test_plugin_json_version_bump.py @@ -0,0 +1,34 @@ +"""F4 RED — pins .claude-plugin/plugin.json.version to F4 release. + +Fails until plugin.json.version is bumped 0.0.1 -> 0.1.0. + +Contract: plugin.json.version is the single source of truth for plugin +version. Git tag mirrors it as 'v' + version. Marketplace +plugins[0].source.ref must equal 'v' + version (covered by +test_marketplace_json_schema.py). Bumping version requires updating this +pin. +""" + +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +PLUGIN_JSON = REPO_ROOT / ".claude-plugin" / "plugin.json" + +EXPECTED_VERSION = "0.1.0" + + +class TestPluginVersionPin: + def test_plugin_json_exists(self): + assert PLUGIN_JSON.is_file(), f"missing: {PLUGIN_JSON}" + + def test_plugin_json_parses(self): + data = json.loads(PLUGIN_JSON.read_text()) + assert isinstance(data, dict) + + def test_version_is_pinned_to_f4_release(self): + data = json.loads(PLUGIN_JSON.read_text()) + assert data.get("version") == EXPECTED_VERSION, ( + f"plugin.json.version must be {EXPECTED_VERSION!r} (F4 first public release pin); " + f"got {data.get('version')!r}" + ) diff --git a/bin/tests/test_release_workflow_smoke.py b/bin/tests/test_release_workflow_smoke.py new file mode 100644 index 0000000..f15e05b --- /dev/null +++ b/bin/tests/test_release_workflow_smoke.py @@ -0,0 +1,86 @@ +"""F4 RED — locks down .github/workflows/release.yml contract. + +Fails until: +- .github/workflows/release.yml exists and parses as YAML +- on.push.tags includes 'v*' +- jobs present: version-match, selftest, build-bundle, publish-release, mirror-marketplace +- publish-release.needs includes version-match, selftest, build-bundle +- mirror-marketplace is conditional/skippable (has 'if:' guard or env-gated step) +""" + +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[2] +RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml" + +EXPECTED_JOBS = { + "version-match", + "selftest", + "build-bundle", + "publish-release", + "mirror-marketplace", +} + +PUBLISH_RELEASE_REQUIRED_NEEDS = {"version-match", "selftest", "build-bundle"} + + +def _load_workflow(): + assert RELEASE_WORKFLOW.is_file(), f"missing: {RELEASE_WORKFLOW}" + return yaml.safe_load(RELEASE_WORKFLOW.read_text()) + + +class TestReleaseWorkflowShape: + def test_release_yml_exists(self): + assert RELEASE_WORKFLOW.is_file(), f"missing: {RELEASE_WORKFLOW}" + + def test_release_yml_parses(self): + data = _load_workflow() + assert isinstance(data, dict), "release.yml must parse to a YAML mapping" + + +class TestReleaseTrigger: + def test_trigger_on_tag_v_star(self): + data = _load_workflow() + # PyYAML parses 'on:' as Python boolean True; accept either key. + on_block = data.get("on") if "on" in data else data.get(True) + assert on_block is not None, "workflow must declare 'on:' triggers" + push = on_block.get("push") if isinstance(on_block, dict) else None + assert push is not None, "workflow must trigger on push" + tags = push.get("tags") if isinstance(push, dict) else None + assert tags is not None, "workflow must trigger on push.tags" + assert "v*" in tags, f"push.tags must include 'v*', got {tags!r}" + + +class TestReleaseJobs: + def test_all_expected_jobs_present(self): + data = _load_workflow() + jobs = data.get("jobs", {}) + missing = EXPECTED_JOBS - set(jobs.keys()) + assert not missing, f"missing jobs: {sorted(missing)}; got {sorted(jobs.keys())}" + + def test_publish_release_depends_on_required_jobs(self): + data = _load_workflow() + publish = data.get("jobs", {}).get("publish-release", {}) + needs = publish.get("needs", []) + if isinstance(needs, str): + needs = [needs] + missing = PUBLISH_RELEASE_REQUIRED_NEEDS - set(needs) + assert not missing, ( + f"publish-release.needs must include {sorted(PUBLISH_RELEASE_REQUIRED_NEEDS)}, " + f"got {needs!r}; missing {sorted(missing)}" + ) + + def test_mirror_marketplace_is_conditional(self): + data = _load_workflow() + mirror = data.get("jobs", {}).get("mirror-marketplace", {}) + # Either job-level `if:` guard or every step gated by `if:` is acceptable; + # a job-level guard is the simplest, most auditable form. + job_if = mirror.get("if") + steps = mirror.get("steps", []) + any_step_if = any(isinstance(s, dict) and s.get("if") for s in steps) + assert job_if or any_step_if, ( + "mirror-marketplace must be conditional/skippable (job-level 'if:' or step-level 'if:'); " + "the public marketplace repo may not exist yet" + ) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b1cf463..d576461 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -671,14 +671,41 @@ Ver `docs/SAFETY_POLICY.md`. Resumen: - Denylist con razón documentada. - Starter opt-in: sólo MemPalace y PleasePrompto/notebooklm-mcp (Fase B). -## 13. Marketplace público (Fase F4) +## 13. Marketplace público + release flow (entregado en F4) -Repo `javiAI/pos-marketplace`: -- `marketplace.json` — lista de plugins publicados con versión + SHA. -- Release flow: tag `v*` en `project-operating-system` → workflow publica + abre PR en `pos-marketplace`. -- Users instalan con `claude plugin add javiAI/pos-marketplace::pos@X.Y.Z`. +Plugin `pos` se publica vía: -Local sigue funcionando con `--plugin-dir `. +1. **Manifest local** [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json) — schema oficial Claude Code marketplace. Top-level `{name, owner, plugins[]}`. Cada plugin: `{name, source: {source, repo, ref}, version, description}`. Source of truth de qué publica este repo. +2. **Versión canonical** [`.claude-plugin/plugin.json`](../.claude-plugin/plugin.json) `version`. Tag git = `v${version}`. `marketplace.json.plugins[0].source.ref` mirror-ea el tag. +3. **Workflow** [`.github/workflows/release.yml`](../.github/workflows/release.yml) — trigger `push.tags: ['v*']`. Cinco jobs encadenados: + - `version-match` (gate inicial: `plugin.json.version == ${tag#v}`). + - `selftest` (reusa contrato F3). + - `build-bundle` (curated plugin-only `tar.gz`). + - `publish-release` (`needs: [version-match, selftest, build-bundle]`; `gh release create` con bundle). + - `mirror-marketplace` (condicional `if: vars.POS_MARKETPLACE_REPO != ''`; abre PR en repo público; skippea silenciosamente si no configurado). +4. **Bundle scope** — plugin-only curated: `.claude-plugin/`, `.claude/skills/`, `.claude/rules/`, `hooks/`, `agents/`, `policy.yaml`, `bin/pos-selftest.sh`, `bin/_selftest.py`, `docs/RELEASE.md`. Excluye `generator/`, `tools/`, `templates/`, `questionnaire/`, `bin/tests/`, `.github/` (meta-repo, no runtime del plugin instalado). +5. **Repo público** — `javiAI/pos-marketplace` no existe a fecha de F4. La infra local (`marketplace.json` + `release.yml` con mirror gated) está lista; el repo se crea manualmente cuando se decida ir live (runbook en [docs/RELEASE.md § Mirror al marketplace público](RELEASE.md)). + +**Determinismo del release**: + +- Drift `marketplace.json` ↔ `plugin.json` se rompe en CI vía `bin/tests/test_marketplace_json_schema.py` (cruza `name`, `version`, `source.ref`). +- Drift `plugin.json.version` ↔ tag git se rompe en `version-match` (primer job; sin esto el resto no corre). +- Bundle determinista: lista de paths fija; reordering en `release.yml` rompe contract test `test_release_workflow_smoke.py`. +- Pre-1.0 (`0.x`): API puede romper entre minors. F4 pinea `0.1.0`. `1.0.0` requiere decisión explícita. + +**Instalación user-facing** (cuando el repo público exista): + +```text +/plugin marketplace add javiAI/pos-marketplace +/plugin install pos +``` + +**Diferidos en F4** (regla #7 CLAUDE.md): + +- `audit.yml` nightly (declarado en `policy.yaml.ci_cd.workflows` desde Fase A; sin consumer activo). +- Skills `/pos:pr-description` y `/pos:release` (sin repetición demostrada del flow manual). +- `CHANGELOG.md` enforced (autogenerated notes de `gh release create` cubren hasta nueva señal). +- Migración del `templates/policy.yaml.hbs` (drift abierto post-D5b en `refactor/template-policy-d5b-migration`; ortogonal a F4). ## 14. Anti-legacy / evolución diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..dcc577f --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,144 @@ +# RELEASE — runbook del plugin `pos` + +> Cómo se versiona, empaqueta, publica y mirror-ea el plugin `pos` al marketplace público. Entregado en F4. + +## Contrato de versionado + +Una sola fuente de verdad: [`.claude-plugin/plugin.json`](../.claude-plugin/plugin.json) `version`. + +- Git tag canonical = `v${version}` (ej. `v0.1.0`). +- `.claude-plugin/marketplace.json` `plugins[0].source.ref` = mismo tag. +- `.claude-plugin/marketplace.json` `plugins[0].version` (si presente) = mismo `${version}`. +- El test [bin/tests/test_plugin_json_version_bump.py](../bin/tests/test_plugin_json_version_bump.py) pin-ea el valor literal — bumpear requiere actualizar el test en el mismo commit (auto-documenta el milestone). +- El test [bin/tests/test_marketplace_json_schema.py](../bin/tests/test_marketplace_json_schema.py) cruza `marketplace.json` ↔ `plugin.json` — el sync se rompe en CI antes del tag. + +Pre-1.0: API puede romper entre minors. F4 pinea `0.1.0` como primer release público; `1.0.0` queda para cuando Fase G aterrice o se decida estabilizar API explícitamente. + +## Bundle del release + +Asset adjunto a cada release: `pos-v${version}.tar.gz` con scope **plugin-only curated**. + +Incluye: + +- `.claude-plugin/` — `plugin.json`, `marketplace.json`. +- `.claude/skills/` — todas las skills publicadas. +- `.claude/rules/` — path-scoped rules. +- `hooks/` — hooks runtime. +- `agents/` — plugin subagents (`pos-code-reviewer`, `pos-architect`). +- `policy.yaml`. +- `bin/pos-selftest.sh` + `bin/_selftest.py` — selftest end-to-end del plugin instalado. +- `docs/RELEASE.md` — este runbook. + +No incluye: + +- `generator/`, `templates/`, `questionnaire/`, `tools/` — son meta-repo (cómo se generan proyectos), no plugin runtime. +- `bin/tests/` — test scaffolding del meta-repo. +- `.github/`, `docs/` (excepto `RELEASE.md`). + +Razón: el consumer del marketplace instala el plugin para usarlo (skills + hooks + agents + policy + selftest). El generador del meta-repo es ortogonal. + +## Flujo de release + +### 1. Preparar el bump + +En la rama de release (ej. `chore/release-0.2.0`): + +1. Editar `.claude-plugin/plugin.json` → bump `version`. +2. Editar `.claude-plugin/marketplace.json` → sincronizar `plugins[0].source.ref` (`v${new}`) y `plugins[0].version`. +3. Editar [bin/tests/test_plugin_json_version_bump.py](../bin/tests/test_plugin_json_version_bump.py) → actualizar `EXPECTED_VERSION`. +4. Correr local: `pytest bin/tests -q` debe pasar. +5. Abrir PR. Pre-pr-gate exige docs-sync (ROADMAP + HANDOFF en el diff de la rama). +6. Merge a `main`. + +### 2. Disparar el release + +Tras merge a `main`: + +```bash +git checkout main +git pull --ff-only +version=$(python3 -c "import json; print(json.load(open('.claude-plugin/plugin.json'))['version'])") +git tag -a "v${version}" -m "v${version}" +git push origin "v${version}" +``` + +GitHub Actions ejecuta [.github/workflows/release.yml](../.github/workflows/release.yml) automáticamente al detectar el tag. + +### 3. Jobs del workflow + +| Job | Qué hace | Falla si | +|---|---|---| +| `version-match` | Asserta `plugin.json.version == ${tag#v}`. | Drift entre tag y manifest. | +| `selftest` | `pytest bin/tests -q` (reusa el job F3 sobre proyecto sintético generado al vuelo). | Cualquier escenario D1/D3/D4/D5/D6 falla; cualquier contract test del marketplace/release/plugin.json rompe. | +| `build-bundle` | Empaqueta `pos-v${version}.tar.gz` con scope curated. Sube como artifact. | `tar` falla; algún path obligatorio falta. | +| `publish-release` | `needs: [version-match, selftest, build-bundle]`. Descarga el bundle, llama `gh release create v${version} --generate-notes `. | Token inválido; tag duplicado; alguno de los `needs` rojo. | +| `mirror-marketplace` | Condicional `if: vars.POS_MARKETPLACE_REPO != ''`. Clona el repo público, actualiza `marketplace.json`, abre PR. | El repo público no existe / token mal configurado. **No bloquea el release** si la variable está vacía. | + +### 4. Verificar el release + +```bash +gh release view v${version} +gh release download v${version} +tar -tzf pos-v${version}.tar.gz | head -20 +``` + +### 5. Recuperar de un release fallido + +**Antes de `publish-release`** (`version-match`, `selftest` o `build-bundle` rojos): + +```bash +git tag -d "v${version}" +git push origin ":refs/tags/v${version}" +``` + +Arregla el problema en `main` (PR nuevo) y re-tag. + +**Después de `publish-release`** (release ya creado en GitHub): + +```bash +gh release delete "v${version}" --yes +git tag -d "v${version}" +git push origin ":refs/tags/v${version}" +``` + +Ojo: si terceros ya descargaron el asset, eliminar no rebobina sus instalaciones. Considerar si en lugar de borrar conviene publicar `v${version}.1` con el fix. + +## Mirror al marketplace público + +El job `mirror-marketplace` espeja el `marketplace.json` local hacia `javiAI/pos-marketplace` (o el repo configurado vía `vars.POS_MARKETPLACE_REPO`). Está **condicional** porque ese repo público no existe todavía a fecha de F4. + +Activarlo cuando exista: + +1. Crear el repo público con un `marketplace.json` inicial (puede ser una copia de `.claude-plugin/marketplace.json`). +2. Configurar repo variable: `gh variable set POS_MARKETPLACE_REPO -b "javiAI/pos-marketplace"`. +3. Configurar secret con permiso `repo`: `gh secret set POS_MARKETPLACE_TOKEN -b ""`. +4. El próximo release abre PR en el repo público automáticamente. + +Hasta entonces, `mirror-marketplace` skippea silenciosamente (`if:` evalúa false → job marca skipped, release sale verde igual). + +## Instalación user-facing (cuando el marketplace público exista) + +```bash +# En Claude Code: +/plugin marketplace add javiAI/pos-marketplace +/plugin install pos +``` + +Claude Code resolverá `marketplace.json` → `plugins[0].source` → clonará `javiAI/project-operating-system` en el ref `v${version}` → activará skills + hooks + agents + policy del bundle instalado. + +## Branch protection en el repo público + +`docs/BRANCH_PROTECTION.md` aplica al **repo generado por `pos`**, no al meta-repo ni al marketplace público. Para el repo público recomendado: + +- `main` protegido contra force-push. +- 1 reviewer requerido para PRs (incluido el bot del mirror). +- Status check `version-match` requerido si el repo público corre su propio CI. + +Configurar manualmente vía GitHub Settings → Branches. + +## Diferidos (no entregado en F4) + +- `audit.yml` nightly — declarado en `policy.yaml.ci_cd.workflows[name=audit.yml]` desde Fase A; no aterrizado. Reabrir en rama propia post-F4 cuando consuma `npm audit` + `pip-audit` + `/pos:audit-plugin --self`. +- `/pos:pr-description` y `/pos:release` skills — listadas en [.claude/rules/skills-map.md](../.claude/rules/skills-map.md) como "entregado en F"; F4 entrega el flow manual antes de extraer skill (regla #7 CLAUDE.md: ≥2 repeticiones). +- `CHANGELOG.md` enforced — F4 usa `--generate-notes` de `gh release create` (autogenerado de commits + PRs). Reabrir si el patrón sale corto. +- Repo público `javiAI/pos-marketplace` — F4 deja la infraestructura local lista; la creación del repo público es manual cuando se decida ir live.