From 3e05b2fbe153ea4a4ba6438c3dfb1f84350d2d52 Mon Sep 17 00:00:00 2001 From: Matthew Maynes Date: Sat, 27 Jun 2026 15:51:34 -0400 Subject: [PATCH 1/2] feat(templates): optional templates + a plugin-release template Implements spec/plan 0005. Trellis can now ship opt-in templates beyond its universal rules, and ships the first one: plugin-release. Optional templates: - /trellis-install --template adds a bundle from trellis/templates//; plain /trellis-update then maintains it via a docs/rules/.trellis-templates registry (no flag needed), exactly as it maintains rules. - A template splits into owned/ (refreshed on update) and seed/ (copied once, then the consumer's). The boundary is one-way - no consumer-editable content lives in an owned file - so a clobbering refresh is always safe. - Drops the old unconditional docs/templates/ seeding in both skills. plugin-release template (owned/ + seed/): - scripts/bump-version.sh: single-source VERSION mirrored into every manifest listed in a consumer-owned .version-manifests; --check fails CI on drift; semver-only (multiline-hardened); format-preserving; validates in memory then writes VERSION last. - .github/workflows/release.yml: standalone, triggered by workflow_run on the consumer's CI workflow success on main (portable - no job-name coupling); job-scoped contents: write; SHA-pinned checkout; idempotent gh release create from docs/releases/.md else --generate-notes. - docs/releases/README.md convention; seed VERSION + .version-manifests. Trellis self-adoption (dogfood): - Root VERSION 0.4.0; .version-manifests lists its 7 manifests (bumped 0.3.0 -> 0.4.0 via the new script); functional copies of the owned files. - New CI workflow runs the test suites, bump-version.sh --check, and a dogfood diff (installed copies must equal the template source); release.yml gates on it. - trellis/scripts/bump-version.test.sh (POSIX, dash-clean): 18 checks incl. negative drift, two-token guard, missing-manifest, multiline reject. Bakes in all five findings from Spectra's reviewed-but-closed PR #28. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/plugins/marketplace.json | 2 +- .claude-plugin/marketplace.json | 2 +- .cursor-plugin/marketplace.json | 2 +- .github/workflows/ci.yml | 39 ++++ .github/workflows/release.yml | 56 ++++++ .version-manifests | 8 + VERSION | 1 + docs/overview/architecture.md | 17 ++ docs/overview/features.md | 13 +- docs/overview/learnings.md | 12 ++ docs/plans/0005-plugin-release-template.md | 81 +++++++++ docs/releases/0.4.0.md | 22 +++ docs/releases/README.md | 25 +++ docs/rules/.trellis-owned-plugin-release | 3 + docs/rules/.trellis-templates | 1 + docs/specs/0005-plugin-release-template.md | 144 +++++++++++++++ scripts/bump-version.sh | 170 ++++++++++++++++++ trellis/.claude-plugin/plugin.json | 2 +- trellis/.codex-plugin/plugin.json | 2 +- trellis/.cursor-plugin/plugin.json | 2 +- trellis/gemini-extension.json | 2 +- trellis/scripts/bump-version.test.sh | 100 +++++++++++ trellis/skills/trellis-install/SKILL.md | 37 +++- trellis/skills/trellis-update/SKILL.md | 23 ++- trellis/templates/.gitkeep | 0 trellis/templates/README.md | 45 +++++ trellis/templates/plugin-release/README.md | 45 +++++ .../owned/.github/workflows/release.yml | 56 ++++++ .../owned/docs/releases/README.md | 25 +++ .../owned/scripts/bump-version.sh | 170 ++++++++++++++++++ .../plugin-release/seed/.version-manifests | 12 ++ trellis/templates/plugin-release/seed/VERSION | 1 + 32 files changed, 1100 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .version-manifests create mode 100644 VERSION create mode 100644 docs/plans/0005-plugin-release-template.md create mode 100644 docs/releases/0.4.0.md create mode 100644 docs/releases/README.md create mode 100644 docs/rules/.trellis-owned-plugin-release create mode 100644 docs/rules/.trellis-templates create mode 100644 docs/specs/0005-plugin-release-template.md create mode 100755 scripts/bump-version.sh create mode 100755 trellis/scripts/bump-version.test.sh delete mode 100644 trellis/templates/.gitkeep create mode 100644 trellis/templates/README.md create mode 100644 trellis/templates/plugin-release/README.md create mode 100644 trellis/templates/plugin-release/owned/.github/workflows/release.yml create mode 100644 trellis/templates/plugin-release/owned/docs/releases/README.md create mode 100755 trellis/templates/plugin-release/owned/scripts/bump-version.sh create mode 100644 trellis/templates/plugin-release/seed/.version-manifests create mode 100644 trellis/templates/plugin-release/seed/VERSION diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index c5b2a9a..c54505c 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -10,7 +10,7 @@ "name": "trellis", "source": "./trellis", "description": "Install and maintain rogueoak's shared AI-agent rules and templates in your repo.", - "version": "0.3.0", + "version": "0.4.0", "keywords": ["conventions", "rules", "agents", "templates", "rogueoak"] } ] diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c5b2a9a..c54505c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "trellis", "source": "./trellis", "description": "Install and maintain rogueoak's shared AI-agent rules and templates in your repo.", - "version": "0.3.0", + "version": "0.4.0", "keywords": ["conventions", "rules", "agents", "templates", "rogueoak"] } ] diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index c5b2a9a..c54505c 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "trellis", "source": "./trellis", "description": "Install and maintain rogueoak's shared AI-agent rules and templates in your repo.", - "version": "0.3.0", + "version": "0.4.0", "keywords": ["conventions", "rules", "agents", "templates", "rogueoak"] } ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dea5dce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +# Runs the dependency-free test suites and the version-sync + compliance guards on every push to +# main and every PR. The Release workflow gates on this workflow succeeding on main (workflow_run), +# so its name must stay "CI". +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on a PR branch, but never cancel a push run on main - the Release +# workflow waits on it, and a cancelled main run would drop that release. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + test: + name: tests + guards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Compliance checker tests + run: sh trellis/scripts/check-compliance.test.sh + - name: Version bump tests + run: sh trellis/scripts/bump-version.test.sh + - name: Version in sync across manifests + run: sh scripts/bump-version.sh --check + - name: Dogfood - installed template matches its source + run: | + base=trellis/templates/plugin-release/owned + for f in $(cd "$base" && find . -type f | sed 's#^\./##'); do + diff "$base/$f" "$f" || { echo "drift: installed $f differs from $base/$f"; exit 1; } + done + - name: Compliance pass (report) + run: sh trellis/scripts/check-compliance.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..17b85cd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +# Shipped by Trellis's "plugin-release" template and OWNED by Trellis - re-run trellis-update +# to refresh it. Do not hand-edit. +# +# Tags a bare-semver release and publishes a GitHub Release whenever the root VERSION moves. +# It runs AFTER your CI workflow succeeds on main (workflow_run), so it never publishes +# unvalidated code and needs no knowledge of your CI's job names. The one repo-specific knob is +# the workflow name below: it must match the `name:` of the CI workflow you want to gate on. +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +# Default to read-only; the release job elevates itself for the one write it needs. +permissions: + contents: read + +jobs: + release: + name: Tag + GitHub Release on version bump + runs-on: ubuntu-latest + # Only after the CI workflow concluded successfully, and only on main. A failed or + # non-main CI run never reaches the write token. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + # Pinned to a full SHA (v4.2.2) because this job carries a write token. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - name: Create the release when VERSION is new + # Idempotent: a run whose VERSION already has a release does nothing, so this only + # publishes on the commit that bumps VERSION. Prefer a hand-written notes file (its + # first non-heading line becomes the README headline via whats-new.yml); fall back to + # auto-generated notes from the conventional-commit titles since the previous tag. + run: | + v="$(cat VERSION)" + if gh release view "$v" >/dev/null 2>&1; then + echo "Release $v already exists; nothing to do." + exit 0 + fi + sha="$(git rev-parse HEAD)" + notes="docs/releases/$v.md" + if [ -f "$notes" ]; then + echo "Publishing release $v from $notes" + gh release create "$v" --target "$sha" --notes-file "$notes" + else + echo "Publishing release $v with auto-generated notes (no $notes)" + gh release create "$v" --target "$sha" --generate-notes + fi diff --git a/.version-manifests b/.version-manifests new file mode 100644 index 0000000..4965bd8 --- /dev/null +++ b/.version-manifests @@ -0,0 +1,8 @@ +# Manifests that must match the root VERSION. One path per line; '#' and blanks ignored. +.claude-plugin/marketplace.json +.cursor-plugin/marketplace.json +.agents/plugins/marketplace.json +trellis/.claude-plugin/plugin.json +trellis/.codex-plugin/plugin.json +trellis/.cursor-plugin/plugin.json +trellis/gemini-extension.json diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..1d0ba9e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.4.0 diff --git a/docs/overview/architecture.md b/docs/overview/architecture.md index 20eaf33..48476fb 100644 --- a/docs/overview/architecture.md +++ b/docs/overview/architecture.md @@ -40,5 +40,22 @@ another tool's vendored docs. Reuse by a consumer's own pre-commit/CI is deferred and will need a copy-into-repo step like `install-hooks.sh` has - today the script only ships in the plugin, so only this repo (where `trellis/` is committed) can invoke it directly. +- **Optional templates.** `trellis/templates//` bundles are opt-in, unlike `rules/`. Each + splits into `owned/` (the mechanism Trellis maintains) and `seed/` (the consumer's inputs), + both mirroring their target paths. Install (`--template `) merges `owned/` (clobber) and + `seed/` (`cp -Rn`) into the repo, records the name in `docs/rules/.trellis-templates`, and lists + the owned files in `docs/rules/.trellis-owned-`. Update needs no flag: it walks that + registry and re-syncs each template's owned files exactly as it re-syncs rules (refresh + prune), + never touching `seed/` targets. The boundary is one-way - no consumer-editable content lives in + an owned file - so a clobbering refresh is always safe. Reuses the `.trellis-owned` + ownership-manifest idea rather than inventing a parallel one. +- **plugin-release pipeline.** The first template. Repo-specific variation (the manifest list) + lives in a consumer-owned `.version-manifests`, so the owned `bump-version.sh` ships identical + everywhere and updates centrally. `release.yml` is standalone and triggered by `workflow_run` on + the consumer's `CI` workflow (not `needs:` on job names), so it is portable; it is the only job + with `contents: write` (job-scoped), gated to CI-success on `main`, SHA-pinned checkout, + idempotent via `gh release view`. Trellis self-adopts: a `CI` workflow runs the test suites + + `bump-version.sh --check` + a dogfood diff (installed copies must equal the template source), and + `release.yml` gates on it. - **Built under Spectra.** `docs/{specs,plans,feedback,overview}` track this repo's own development; the two systems compose - Spectra is the process, Trellis is the conventions. diff --git a/docs/overview/features.md b/docs/overview/features.md index be20e32..fe7d6db 100644 --- a/docs/overview/features.md +++ b/docs/overview/features.md @@ -24,4 +24,15 @@ `/trellis-update` run it non-blocking, passing `--fix` through when invoked as `/trellis-install --fix`. A developer-owned `docs/rules/.compliance-ignore` (gitignore-lite) skips content another tool vendors, e.g. `docs/spectra/`. -- **Templates.** `trellis/templates/` is reserved for shared templates; empty for now. +- **Optional templates.** Beyond the universal rules, Trellis ships **opt-in** templates under + `trellis/templates//` that only some repos want. A repo adds one with + `/trellis-install --template `; plain `/trellis-update` then keeps it current (registry at + `docs/rules/.trellis-templates`), no flag needed. Each template splits into `owned/` (Trellis + refreshes these on update) and `seed/` (copied once, then the consumer's), so an update never + clobbers consumer content. See `trellis/templates/README.md`. +- **plugin-release template.** For repos that are themselves published marketplace plugins: a root + `VERSION` as single source of truth, `scripts/bump-version.sh` to rewrite it and every manifest + in `.version-manifests` in lockstep (`--check` fails CI on drift, semver-only, format-preserving), + a standalone `.github/workflows/release.yml` that tags + publishes a GitHub Release once CI + succeeds on `main` (via `workflow_run`, composing with "What's new"), and the + `docs/releases/.md` notes convention. Trellis dogfoods it for its own seven manifests. diff --git a/docs/overview/learnings.md b/docs/overview/learnings.md index b484bd4..f606eba 100644 --- a/docs/overview/learnings.md +++ b/docs/overview/learnings.md @@ -45,3 +45,15 @@ in-place fix into arbitrary file overwrite. Skip symlinks (`[ -L ]`), create the temp with `mktemp`, and guard hostile filenames with `--`/`./` so a name like `-rf` is not read as an option. (spec 0004) +- To share tooling that has per-repo variation, ship the mechanism verbatim and push the variation + into a consumer-owned config file (plugin-release's `bump-version.sh` is identical everywhere; + each repo's manifest list lives in its own `.version-manifests`). The mechanism then updates + centrally and never needs hand-editing per repo - the same reason rules are copied, not forked. + (spec 0005) +- An optional, updatable component needs a registry of what is installed plus a strict owned-vs-seed + ownership split: update reads the registry and refreshes only owned files (so a clobber is always + safe), while consumer inputs live in seed files it never touches. Without the registry, update + cannot maintain a component the user opted into without being told again. (spec 0005) +- A portable CI-triggered release must not depend on another workflow's job names: gate it with + `workflow_run` on the upstream workflow's *name* (success + `main`), not `needs: [job]`, so the + same file drops into any repo. (spec 0005) diff --git a/docs/plans/0005-plugin-release-template.md b/docs/plans/0005-plugin-release-template.md new file mode 100644 index 0000000..219169e --- /dev/null +++ b/docs/plans/0005-plugin-release-template.md @@ -0,0 +1,81 @@ +# 0005 - Optional templates, and a plugin-release template (plan) + +Source: `docs/specs/0005-plugin-release-template.md`. + +## Layout to create + +``` +trellis/templates/README.md # the optional-template convention +trellis/templates/plugin-release/ + README.md # adoption guide + the one CI-name knob + owned/ # refreshed on update (Trellis-owned) + scripts/bump-version.sh + .github/workflows/release.yml + docs/releases/README.md + seed/ # copied once, never clobbered (consumer-owned) + VERSION + .version-manifests +``` + +Consumer install target (functional paths, mirrors the subtrees): +`scripts/bump-version.sh`, `.github/workflows/release.yml`, `docs/releases/README.md` (owned); +`VERSION`, `.version-manifests` (seed). Tracking: `docs/rules/.trellis-templates` (names) + +`docs/rules/.trellis-owned-` (owned files). + +## Steps +1. **Plan** (this) + reflect later. +2. **`bump-version.sh`** (owned, POSIX sh + python3): identical to Spectra #28's hardened script + except the manifest list comes from `.version-manifests` (one path per line, `#` comments and + blanks skipped) instead of a hardcoded list, and the root override env is neutral + (`BUMP_VERSION_ROOT`). Modes: no-arg prints VERSION; `--check`; `X.Y.Z`. Carry over: surgical + one-token substitution, validate-in-memory-then-write-VERSION-last, exactly-one-token guard, + `if check; then` (set -e safe). Harden the semver gate to reject a multiline arg (a newline in + the arg fails the test). Missing `.version-manifests` is a clear error. +3. **`release.yml`** (owned): `on: workflow_run: { workflows: ["CI"], types: [completed] }`. + Job `release`: `if` success + `head_branch == 'main'`; `runs-on: ubuntu-latest`; + `permissions: { contents: write }`; checkout pinned to the same SHA whats-new.yml uses, with + `ref: ${{ github.event.workflow_run.head_sha }}`; step reads `v=$(cat VERSION)`, idempotent + `gh release view` guard, `gh release create "$v" --target "$(git rev-parse HEAD)"` with + `--notes-file docs/releases/$v.md` when present else `--generate-notes`; `GH_TOKEN` ambient. +4. **`docs/releases/README.md`** (owned): per-version notes convention (first non-heading line is + the headline `whats-new.yml` extracts); note the generate-notes fallback caveat. +5. **`seed/VERSION`** = `0.0.0` sample; **`seed/.version-manifests`** = a commented sample listing + the common manifest paths, for the consumer to edit. +6. **`templates/README.md`** + **`plugin-release/README.md`**: the convention (owned vs seed, + `--template` add, auto-refresh on update) and adoption steps (set VERSION, fill + `.version-manifests`, ensure a CI workflow named to match `release.yml`'s `workflows:` list). +7. **trellis-install SKILL.md**: a new step - if invoked with `--template `, verify + `$SRC/templates/` exists, copy `owned/.` and `seed/.` to repo root (owned clobbers; seed + via `cp -Rn`), append `` to `docs/rules/.trellis-templates` (dedup), and write + `docs/rules/.trellis-owned-` (the owned file list, derived from `owned/`). Confirm. +8. **trellis-update SKILL.md**: a new step - for each name in `docs/rules/.trellis-templates`, + re-copy `$SRC/templates//owned/.` (clobber), rewrite `.trellis-owned-`, prune any + listed owned file no longer shipped; never touch `seed/` targets. No `--template` needed. +9. **Trellis self-adoption**: root `VERSION` = `0.4.0`; `.version-manifests` listing the 4 + manifests; copy the owned script/workflow/convention to functional paths; add minimal + `.github/workflows/ci.yml` (push + PR: run `trellis/scripts/check-compliance.test.sh`, + `trellis/scripts/bump-version.test.sh`, and `check-compliance.sh`); run + `scripts/bump-version.sh 0.4.0` to set all 4 manifests; confirm `--check` is clean. +10. **`trellis/scripts/bump-version.test.sh`** in the repo idiom (sandbox repos, `check`/`checkeq`): + `--check` on a synced sandbox, semver accept/reject (incl. multiline + empty), sandbox write + converges, **negative drift** (edit one manifest -> `--check` non-zero), **two-token guard** + (planted second token -> non-zero), missing-manifest error. Plus a dogfood-integrity check + that the repo's `scripts/bump-version.sh` matches the template's owned copy. +11. **Reflect**: `docs/overview/features.md` (optional templates + plugin-release), `architecture.md` + (the owned/seed model, registry, release pipeline), `learnings.md` if a lesson arises. + +## Verify +- `sh trellis/scripts/bump-version.test.sh` passes; `sh trellis/scripts/check-compliance.test.sh` + still passes. +- `scripts/bump-version.sh --check` exits 0 on the repo; all 4 manifests + VERSION read `0.4.0`. +- Every new shell/yaml file is ASCII-only (guidelines.md); `python3 -m json.tool` parses manifests. +- `release.yml` and `ci.yml` are valid YAML; `release.yml` gates on the `ci.yml` workflow name. +- Dry-run the install/update template logic in a scratch dir to confirm copy + registry + prune. + +## Review +Personas (engineer/tester/architect/security): +- **engineer** - the config-driven script, the skill copy/prune logic, release.yml shell. +- **tester** - bump-version.test.sh coverage + the install/update dry-run. +- **architect** - the optional-template model, owned/seed boundary, registry, release pipeline. +- **security** - release.yml privilege (job-scoped write, success+main gate, SHA-pinned checkout, + ambient token, no untrusted interpolation); the script's arg/env handling. diff --git a/docs/releases/0.4.0.md b/docs/releases/0.4.0.md new file mode 100644 index 0000000..31f272f --- /dev/null +++ b/docs/releases/0.4.0.md @@ -0,0 +1,22 @@ +Optional templates, and a plugin-release template for single-source versioning and automated releases. + +## What changed +Trellis can now ship **optional templates** - opt-in bundles a repo installs with +`/trellis-install --template `, kept current thereafter by plain `/trellis-update`. Unlike +rules (which every Trellis repo follows), a template applies only to repos that want it. + +The first template is **plugin-release**, for repos that are themselves published marketplace +plugins: + +- A root `VERSION` file is the single source of truth; `scripts/bump-version.sh` rewrites it and + every manifest listed in `.version-manifests` together, and `--check` fails CI on any drift. +- A standalone `.github/workflows/release.yml` tags the version and publishes a GitHub Release + once your CI workflow succeeds on `main` - composing with the existing "What's new" automation. + +Each template splits into `owned/` (Trellis refreshes these on update) and `seed/` (copied once, +then yours), so an update can never clobber your content. See `trellis/templates/README.md`. + +## Dogfood +Trellis adopts plugin-release for its own seven manifests (previously hand-synced at `0.3.0`), +adds a `CI` workflow that runs its test suites and the version-sync guard, and cuts this `0.4.0` +release through the new pattern. diff --git a/docs/releases/README.md b/docs/releases/README.md new file mode 100644 index 0000000..89a61a1 --- /dev/null +++ b/docs/releases/README.md @@ -0,0 +1,25 @@ +# Release notes + +One file per published version: `docs/releases/.md` (bare semver, no `v`). The +`Release` workflow uses it as the GitHub Release body when you bump `VERSION` to that number; if +the file is absent it falls back to auto-generated notes from your Conventional Commit titles. + +This file is shipped and owned by Trellis's plugin-release template (refreshed by +`trellis-update`). The per-version `.md` notes are yours. + +## Convention + +- **The first non-heading line is the headline.** The "What's new" automation extracts it + verbatim into the README, so make it a single plain sentence - no leading `#`, no list marker. + Everything after it is the Release body. +- Keep it ASCII and in the repo's voice. +- The auto-generated fallback (no `.md`) yields a raw commit bullet as the headline, so + write the file for any release whose headline matters. + +## Releasing + +1. `scripts/bump-version.sh X.Y.Z` - rewrites `VERSION` + every manifest in `.version-manifests`. +2. Write `docs/releases/X.Y.Z.md` (headline first, then details). +3. Open a PR and squash-merge to `main`. +4. Your CI workflow runs; on success the `Release` workflow tags `X.Y.Z` and publishes the + Release from your notes. The "What's new" block then refreshes from the headline. diff --git a/docs/rules/.trellis-owned-plugin-release b/docs/rules/.trellis-owned-plugin-release new file mode 100644 index 0000000..49b934b --- /dev/null +++ b/docs/rules/.trellis-owned-plugin-release @@ -0,0 +1,3 @@ +docs/releases/README.md +scripts/bump-version.sh +.github/workflows/release.yml diff --git a/docs/rules/.trellis-templates b/docs/rules/.trellis-templates new file mode 100644 index 0000000..f4e7dc1 --- /dev/null +++ b/docs/rules/.trellis-templates @@ -0,0 +1 @@ +plugin-release diff --git a/docs/specs/0005-plugin-release-template.md b/docs/specs/0005-plugin-release-template.md new file mode 100644 index 0000000..e60a4a1 --- /dev/null +++ b/docs/specs/0005-plugin-release-template.md @@ -0,0 +1,144 @@ +# 0005 - Optional templates, and a plugin-release template + +## Problem +Trellis ships *universal* conventions - rules every repo follows. But some conventions only +apply to a *kind* of repo. Every repo that is itself a published marketplace plugin (Spectra, +Trellis itself, future plugins) needs the same release machinery: a version held in one place and +mirrored into N host manifests, a way to bump them in sync, and an automated tag + GitHub +Release on merge. Today each plugin hand-rolls this. Spectra built it locally (its closed PR +#28, four-persona-reviewed); Trellis hasn't - its `0.3.0` is hardcoded across four manifests with +no source of truth, no `bump-version.sh`, and manual releases. The pattern is identical across +repos, so it should live in Trellis once, as an **opt-in** component - the first of several +project-type templates Trellis can share. + +This needs two things: (1) a general notion of **optional templates** Trellis can install +selectively and keep updated, and (2) the first template, **plugin-release**. + +## Outcome +- **Optional templates.** `trellis-install` gains a `--template ` flag to *add* an opt-in + bundle (`trellis/templates//`); no flag installs nothing extra (fully optional). Once a + template is installed, **`trellis-update` maintains it automatically** - it discovers installed + templates from Trellis-owned tracking metadata and re-syncs their owned files to the current + plugin version (refresh + prune), with **no need to re-pass `--template`**, exactly as it + already treats rules. Base install/update behaviour is unchanged when nothing is installed. +- **A strict one-way ownership boundary.** Trellis writes and owns the template *mechanism* - + `scripts/bump-version.sh`, `.github/workflows/release.yml`, the convention doc - plus the + tracking metadata; these are **refreshed (clobbered) on update**, so the consumer must never + edit them. The consumer owns their *inputs* - `VERSION`, `.version-manifests`, + `docs/releases/.md` - which live at their own functional paths and are **never** clobbered. + No consumer-editable content ever lives inside a Trellis-owned file or path, and the consumer + never writes into Trellis's files. The bundle encodes this split as **owned/** (refreshed) vs + **seed/** (copied once, never touched again). The owned files of each installed template are + tracked in `docs/rules/.trellis-owned-` (Trellis-written) so update refreshes/prunes + exactly them. +- **The plugin-release template.** Installs: + - `scripts/bump-version.sh` (owned) - reads the consumer's manifest list from a config file, + rewrites `VERSION` + every listed manifest via a format-preserving surgical substitution, + `--check` enforces they agree (CI guard), semver-only. + - `.github/workflows/release.yml` (owned) - standalone, triggered by `workflow_run` after the + consumer's CI workflow succeeds on `main`; idempotent `gh release create` from + `docs/releases/.md` when present, else `--generate-notes`. Job-scoped `contents: write`, + checkout pinned to a SHA. + - `docs/releases/README.md` (owned) - the per-version notes convention. + - `VERSION` (seed) - the consumer's current version, seeded once. + - `.version-manifests` (seed) - the consumer's manifest list (one path per line), seeded with a + sample the consumer edits. +- **Trellis dogfoods it.** Trellis adopts plugin-release for its own four manifests: a root + `VERSION` (`0.4.0`), a `.version-manifests` listing the four, the `release.yml`, and a minimal + `ci.yml` (push/PR) running the compliance tests so `release.yml` has a CI workflow to gate on. + This fixes Trellis's own drift risk and cuts its next release through the new pattern. +- **Hardened from the start.** The four-persona review of Spectra's #28 surfaced five fixes; all + are baked in here (see Approach). + +## Scope +- **In:** + - `trellis/templates/plugin-release/` - the bundle: `owned/{scripts/bump-version.sh, + .github/workflows/release.yml,docs/releases/README.md}`, `seed/{VERSION,.version-manifests}`, + and a `README.md` adoption guide. + - `trellis/templates/README.md` - documents the optional-template concept and the owned/seed + split (the convention future templates follow). + - `trellis-install` SKILL.md: `--template ` adds a template - copy `owned/` + `seed/` to + their functional paths, append `` to a `docs/rules/.trellis-templates` registry, and + write the per-template owned-list `docs/rules/.trellis-owned-`. + - `trellis-update` SKILL.md: with **no flag**, read `docs/rules/.trellis-templates`, and for + each installed template re-copy its `owned/` files (clobber), rewrite its owned-list, and + prune owned files Trellis no longer ships - never touching `seed/` files or consumer inputs. + Both reuse the existing source-resolution and owned-list idioms. + - `bump-version.sh` generalized: manifest list from `.version-manifests`, neutral root-override + env for tests, no plugin-specific names. + - Trellis self-adoption: root `VERSION`, `.version-manifests`, `.github/workflows/release.yml`, + a minimal `.github/workflows/ci.yml`, and the four manifests confirmed in sync. + - A `trellis/scripts/bump-version.test.sh` in the repo's test idiom (sandboxed, `check`/`checkeq` + helpers) - the `--check` invariant, semver accept/reject, sandbox write/converge, a negative + drift test, and the two-`version`-token guard. + - Docs: spec/plan (this), `docs/overview/` (features, architecture, learnings), README "What's + new" untouched (auto), and the template adoption README. +- **Out:** + - Spectra adopting the template - a follow-up in the Spectra repo via `/trellis-update + --template plugin-release` once this ships. + - Back-filling release notes for Trellis 0.1.x-0.3.0 (already published). + - Generalizing `whats-new.yml` itself (already in both repos; unchanged here). + - Any second template (changelog, etc.) - the concept lands; more templates come later. + +## Approach +- **Template = owned/ + seed/ + path-aware install.** The existing `cp -Rn` seeding drops flat + files into `docs/templates/` as untracked starters; a release template instead places live + files at specific paths and must keep the mechanism files current. So the bundle mirrors the + target layout under `owned/` (refreshed on update, clobber) and `seed/` (copied once, no + clobber). Install walks each subtree and copies to the repo root, preserving subpaths; the + installed template names go in `docs/rules/.trellis-templates` and each template's owned files + in `docs/rules/.trellis-owned-`. This extends the existing owned-list idea (rules) and + seed-once idea (templates) rather than inventing a new one. +- **Update maintains without re-asking.** Once `.trellis-templates` records a template, update + needs no flag: it iterates that registry and re-syncs each template's owned files just like it + re-syncs rules, so a consumer who bumps the Trellis plugin and runs `/trellis-update` gets the + latest `bump-version.sh`/`release.yml` automatically. The `--template` flag is *add-only*. +- **One-way ownership, enforced by layout.** Consumer inputs never sit inside a Trellis-owned + file: `VERSION` and `.version-manifests` are seeded once at the repo root and then belong to + the consumer; release notes are the consumer's `docs/releases/.md`. Everything Trellis + refreshes (the script, the workflow, the convention `README.md`, the tracking metadata) the + consumer is told not to edit. So a clobbering refresh can never destroy consumer content. +- **Config-driven bump-version.sh.** The only repo-specific input is the manifest list, so it + lives in a consumer-owned `.version-manifests` (one path per line) the script reads - the script + itself ships identical to every consumer and updates in place. Everything else (the surgical + one-token substitution, validate-in-memory-then-write, `VERSION` written last, the exactly-one- + token guard) carries over from #28 verbatim. +- **Standalone release.yml via workflow_run.** Keying on the consumer's CI *workflow name* + (default `CI`) instead of `needs:` on job names makes the workflow identical across repos. It + runs only when `conclusion == 'success'` and `head_branch == 'main'`, checks out + `workflow_run.head_sha`, and is idempotent. The one consumer-specific knob - the CI workflow + name to wait on - is documented in the template README. +- **Review fixes from #28, baked in:** + 1. Checkout pinned to a full SHA (the job carries `contents: write`). + 2. No cancel-on-push race: a standalone `workflow_run` workflow has its own run identity, so the + concurrency-cancel problem that affected #28's shared workflow does not arise; release runs + are not cancelled by a later push. + 3. `--generate-notes` fallback can feed a list-marker headline into "What's new" - the template + README makes `docs/releases/.md` the documented norm and notes the fallback caveat. + 4. Negative drift test + two-token guard test included in `bump-version.test.sh`. + 5. Multiline semver gate hardened (reject any argument containing a newline). +- **Minimal Trellis CI.** `release.yml` needs a CI workflow to gate on; Trellis has none. Add a + small `ci.yml` (push + PR) that runs `check-compliance` and the script tests - useful on its own + and the gate `release.yml` waits for. + +## Acceptance +- [ ] `trellis/templates/plugin-release/` exists with `owned/`, `seed/`, and a `README.md`; + `trellis/templates/README.md` documents the optional-template convention. +- [ ] `trellis-install --template plugin-release` copies owned + seed files to their functional + paths, appends `plugin-release` to `docs/rules/.trellis-templates`, and writes + `docs/rules/.trellis-owned-plugin-release`; a base install with no flag installs no template. +- [ ] `trellis-update` with **no flag** reads `.trellis-templates`, refreshes each installed + template's owned files, prunes any it no longer ships, and leaves + `VERSION`/`.version-manifests`/`docs/releases/.md` and consumer-authored content untouched. +- [ ] `bump-version.sh` reads `.version-manifests`; `X.Y.Z` rewrites `VERSION` + every listed + manifest; `--check` exits non-zero on drift, a missing manifest, or a two-token manifest; + semver-only (rejects `v1.2.3`, `1.2`, `1.2.3.4`, multiline, empty). +- [ ] `bump-version.test.sh` covers all of the above (incl. the negative drift and two-token + cases) and passes; wired so `ci.yml` runs it. +- [ ] Trellis self-adopts: root `VERSION` (`0.4.0`), `.version-manifests` lists its 4 manifests, + all 4 read `0.4.0`, `release.yml` + `ci.yml` present, `release.yml` gates on `ci.yml`. +- [ ] `release.yml`: `workflow_run` on the CI workflow, success+main guard, job-scoped + `contents: write`, SHA-pinned checkout, idempotent `gh release view` guard, notes-file else + generate-notes. +- [ ] `docs/overview/` (features, architecture, learnings) updated; the template README documents + adoption + the one CI-workflow-name knob. diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..23f1eb1 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,170 @@ +#!/bin/sh +# Plugin version bump - keeps a marketplace plugin's version honest across every manifest. +# +# Shipped by Trellis's "plugin-release" template and OWNED by Trellis: re-run +# `trellis-update` to refresh it. Do not hand-edit; your edits are overwritten by design. +# +# The single source of truth is the root VERSION file. The manifests that must agree are +# listed, one path per line, in the root `.version-manifests` (which you own and edit). Each +# manifest must contain EXACTLY ONE "version" token; this script keeps them identical via a +# format-preserving surgical substitution - no JSON re-serialize that would reflow the files. +# +# Dependency-light: POSIX sh + python3, so it runs identically in CI and a local shell. +# +# Usage: +# scripts/bump-version.sh print the current version (cat VERSION) +# scripts/bump-version.sh --check verify VERSION == every manifest's version (exit 1 on drift) +# scripts/bump-version.sh X.Y.Z write VERSION + all manifests to X.Y.Z (semver, no v) +# scripts/bump-version.sh -h|--help usage +set -eu + +# Root override (BUMP_VERSION_ROOT) lets a test suite run this against a sandbox tree. +ROOT="${BUMP_VERSION_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" + +manifests_file="$ROOT/.version-manifests" + +# Read the manifest list: one path per line, '#' comments and blank lines ignored. +read_manifests() { + [ -f "$manifests_file" ] || { + echo "bump-version: missing $manifests_file (list one manifest path per line)" >&2 + exit 1 + } + sed -e 's/#.*$//' -e 's/[[:space:]]*$//' "$manifests_file" | grep -v '^[[:space:]]*$' || true +} + +usage() { + echo "usage: $0 [--check | X.Y.Z]" + echo " (no arg) print the current version" + echo " --check verify VERSION matches every manifest's version (exit 1 on drift)" + echo " X.Y.Z write VERSION + every listed manifest to X.Y.Z (semver, no 'v' prefix)" +} + +# check -> compare VERSION against every manifest's lone version token; exit 1 on any +# drift/missing/malformed, else 0. +check() { + ROOT="$ROOT" MANIFESTS="$(read_manifests)" python3 - <<'PY' +import json, os, re, sys + +root = os.environ["ROOT"] +manifests = [m for m in os.environ["MANIFESTS"].split("\n") if m] +drift = False + +vfile = os.path.join(root, "VERSION") +try: + want = open(vfile).read().strip() +except OSError as e: + print(f"bump-version: cannot read VERSION: {e}", file=sys.stderr) + sys.exit(1) + +if not manifests: + print("bump-version: .version-manifests lists no manifests", file=sys.stderr) + sys.exit(1) + +for rel in manifests: + path = os.path.join(root, rel) + try: + raw = open(path).read() + except OSError: + print(f"bump-version: missing manifest: {rel}") + drift = True + continue + try: + json.loads(raw) + except Exception as e: + print(f"bump-version: {rel} does not parse as JSON: {e}") + drift = True + continue + found = re.findall(r'"version"\s*:\s*"([^"]*)"', raw) + if len(found) != 1: + print(f"bump-version: {rel} has {len(found)} version tokens (expected exactly 1)") + drift = True + continue + if found[0] != want: + print(f"bump-version: {rel} version {found[0]} != VERSION {want}") + drift = True + +sys.exit(1 if drift else 0) +PY +} + +# write NEW -> surgically substitute the single version token in each manifest, then VERSION. +write() { + new="$1" + # Validate every manifest substitution IN MEMORY first and write the files only once all + # pass; write VERSION LAST. So a guard failure (a stray second "version" token, a file that + # wouldn't parse) aborts with nothing changed on disk - never a half-bumped tree. + ROOT="$ROOT" MANIFESTS="$(read_manifests)" NEW="$new" python3 - <<'PY' +import json, os, re, sys + +root = os.environ["ROOT"] +manifests = [m for m in os.environ["MANIFESTS"].split("\n") if m] +new = os.environ["NEW"] + +if not manifests: + print("bump-version: .version-manifests lists no manifests", file=sys.stderr) + sys.exit(1) + +pending = {} +for rel in manifests: + path = os.path.join(root, rel) + try: + raw = open(path).read() + except OSError as e: + print(f"bump-version: missing manifest: {rel} ({e})", file=sys.stderr) + sys.exit(1) + updated, n = re.subn( + r'("version"\s*:\s*")[^"]*(")', + lambda m: m.group(1) + new + m.group(2), + raw, + ) + if n != 1: + print(f"bump-version: {rel} had {n} version tokens (expected exactly 1)", file=sys.stderr) + sys.exit(1) + try: + json.loads(updated) + except Exception as e: + print(f"bump-version: {rel} would not parse after substitution: {e}", file=sys.stderr) + sys.exit(1) + pending[path] = updated + +for path, content in pending.items(): + open(path, "w").write(content) +PY + printf '%s\n' "$new" > "$ROOT/VERSION" +} + +if [ "$#" -eq 0 ]; then + cat "$ROOT/VERSION" + exit 0 +fi + +case "$1" in + -h|--help) + usage + exit 0 + ;; + --check) + if check; then exit 0; else exit 1; fi + ;; + *) + # Reject anything outside digits and dots first - this also rejects a multiline argument + # (a newline is a non-[0-9.] char), closing the per-line match a bare regex would allow. + case "$1" in + *[!0-9.]*) + echo "bump-version: '$1' is not a semantic version (digits and dots only)." >&2 + echo " expected: X.Y.Z e.g. '1.2.3' (no 'v' prefix, exactly three numeric parts)" >&2 + exit 1 + ;; + esac + if ! printf '%s' "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "bump-version: '$1' is not a semantic version." >&2 + echo " expected: X.Y.Z e.g. '1.2.3' (no 'v' prefix, exactly three numeric parts)" >&2 + exit 1 + fi + write "$1" + if ! check; then + echo "bump-version: version did not converge after write." >&2 + exit 1 + fi + ;; +esac diff --git a/trellis/.claude-plugin/plugin.json b/trellis/.claude-plugin/plugin.json index 9f091ff..cf90a28 100644 --- a/trellis/.claude-plugin/plugin.json +++ b/trellis/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "trellis", "description": "Shared AI-agent conventions and templates for rogueoak projects. Adds /trellis-install and /trellis-update.", - "version": "0.3.0", + "version": "0.4.0", "author": { "name": "Matthew Maynes" }, diff --git a/trellis/.codex-plugin/plugin.json b/trellis/.codex-plugin/plugin.json index e7948e9..024e4fc 100644 --- a/trellis/.codex-plugin/plugin.json +++ b/trellis/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "trellis", - "version": "0.3.0", + "version": "0.4.0", "description": "Shared AI-agent conventions and templates for rogueoak projects. Adds trellis-install and trellis-update.", "author": { "name": "Matthew Maynes" }, "skills": "./skills/", diff --git a/trellis/.cursor-plugin/plugin.json b/trellis/.cursor-plugin/plugin.json index e7948e9..024e4fc 100644 --- a/trellis/.cursor-plugin/plugin.json +++ b/trellis/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "trellis", - "version": "0.3.0", + "version": "0.4.0", "description": "Shared AI-agent conventions and templates for rogueoak projects. Adds trellis-install and trellis-update.", "author": { "name": "Matthew Maynes" }, "skills": "./skills/", diff --git a/trellis/gemini-extension.json b/trellis/gemini-extension.json index 1bcd20f..d808f2f 100644 --- a/trellis/gemini-extension.json +++ b/trellis/gemini-extension.json @@ -1,5 +1,5 @@ { "name": "trellis", - "version": "0.3.0", + "version": "0.4.0", "description": "Shared AI-agent conventions and templates for rogueoak projects. Adds /trellis-install and /trellis-update." } diff --git a/trellis/scripts/bump-version.test.sh b/trellis/scripts/bump-version.test.sh new file mode 100755 index 0000000..435a395 --- /dev/null +++ b/trellis/scripts/bump-version.test.sh @@ -0,0 +1,100 @@ +#!/bin/sh +# Tests for the plugin-release template's bump-version.sh. Dependency-free; run under any POSIX +# shell: +# sh trellis/scripts/bump-version.test.sh +# Run it under dash too (dash trellis/scripts/bump-version.test.sh) to guard the POSIX contract. +set -eu + +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo=$(CDPATH= cd -- "$here/../.." && pwd) +SUT="$repo/trellis/templates/plugin-release/owned/scripts/bump-version.sh" + +pass=0 +fail=0 +check() { # check + if [ "$2" -eq "$3" ]; then pass=$((pass + 1)); else + fail=$((fail + 1)); echo "FAIL: $1 (expected exit $2, got $3)"; fi +} +checkeq() { # checkeq + if [ "$2" = "$3" ]; then pass=$((pass + 1)); else + fail=$((fail + 1)); echo "FAIL: $1"; echo " expected: $2"; echo " actual: $3"; fi +} + +# A throwaway plugin tree: VERSION, a manifest list, and two manifests with one version token +# each (one top-level, one nested under plugins[]). +sandbox() { + d=$(mktemp -d) + printf '%s\n' "${1:-1.0.0}" > "$d/VERSION" + mkdir -p "$d/a" "$d/b" + printf '{\n "name": "p",\n "version": "%s"\n}\n' "${1:-1.0.0}" > "$d/a/plugin.json" + printf '{\n "plugins": [\n { "name": "p", "version": "%s" }\n ]\n}\n' "${1:-1.0.0}" > "$d/b/market.json" + printf 'a/plugin.json\nb/market.json\n' > "$d/.version-manifests" + echo "$d" +} +ver() { grep -o '"version": *"[^"]*"' "$1" | head -1 | sed 's/.*"\([^"]*\)"$/\1/'; } + +# --- --check passes on a synced tree --------------------------------------- +d=$(sandbox 1.0.0) +BUMP_VERSION_ROOT="$d" sh "$SUT" --check >/dev/null 2>&1 && rc=0 || rc=$? +check "--check exits 0 when VERSION matches every manifest" 0 "$rc" +rm -rf "$d" + +# --- no-arg prints the current version ------------------------------------- +d=$(sandbox 2.5.1) +checkeq "no-arg prints VERSION" "2.5.1" "$(BUMP_VERSION_ROOT="$d" sh "$SUT")" +rm -rf "$d" + +# --- write propagates to VERSION + every listed manifest ------------------- +d=$(sandbox 1.0.0) +BUMP_VERSION_ROOT="$d" sh "$SUT" 9.9.9 >/dev/null 2>&1 && rc=0 || rc=$? +check "write X.Y.Z exits 0" 0 "$rc" +checkeq "VERSION updated" "9.9.9" "$(cat "$d/VERSION")" +checkeq "manifest a updated" "9.9.9" "$(ver "$d/a/plugin.json")" +checkeq "manifest b updated" "9.9.9" "$(ver "$d/b/market.json")" +BUMP_VERSION_ROOT="$d" sh "$SUT" --check >/dev/null 2>&1 && rc=0 || rc=$? +check "--check converges after write" 0 "$rc" +rm -rf "$d" + +# --- semver gate: rejects bad arguments ------------------------------------ +d=$(sandbox 1.0.0) +for bad in "v1.2.3" "1.2" "1.2.3.4" "nope" ""; do + BUMP_VERSION_ROOT="$d" sh "$SUT" "$bad" >/dev/null 2>&1 && rc=0 || rc=$? + check "rejects '$bad'" 1 "$rc" +done +# a multiline argument whose first line is valid must still be rejected +BUMP_VERSION_ROOT="$d" sh "$SUT" "$(printf '1.2.3\nx')" >/dev/null 2>&1 && rc=0 || rc=$? +check "rejects a multiline argument" 1 "$rc" +checkeq "rejected args leave VERSION untouched" "1.0.0" "$(cat "$d/VERSION")" +rm -rf "$d" + +# --- negative drift: a hand-edited manifest fails --check ------------------ +d=$(sandbox 1.0.0) +printf '{\n "name": "p",\n "version": "1.0.1"\n}\n' > "$d/a/plugin.json" # drift one manifest +BUMP_VERSION_ROOT="$d" sh "$SUT" --check >/dev/null 2>&1 && rc=0 || rc=$? +check "--check exits 1 when a manifest drifts" 1 "$rc" +rm -rf "$d" + +# --- two version tokens in one manifest trips the guard -------------------- +d=$(sandbox 1.0.0) +printf '{\n "version": "1.0.0",\n "deps": { "x": { "version": "1.0.0" } }\n}\n' > "$d/a/plugin.json" +BUMP_VERSION_ROOT="$d" sh "$SUT" --check >/dev/null 2>&1 && rc=0 || rc=$? +check "--check exits 1 on a two-token manifest" 1 "$rc" +rm -rf "$d" + +# --- a missing manifest is an error ---------------------------------------- +d=$(sandbox 1.0.0) +rm -f "$d/b/market.json" +BUMP_VERSION_ROOT="$d" sh "$SUT" --check >/dev/null 2>&1 && rc=0 || rc=$? +check "--check exits 1 when a listed manifest is missing" 1 "$rc" +rm -rf "$d" + +# --- a missing .version-manifests is an error ------------------------------ +d=$(sandbox 1.0.0) +rm -f "$d/.version-manifests" +BUMP_VERSION_ROOT="$d" sh "$SUT" --check >/dev/null 2>&1 && rc=0 || rc=$? +check "--check exits non-zero without .version-manifests" 1 "$rc" +rm -rf "$d" + +echo "" +echo "bump-version.test.sh: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/trellis/skills/trellis-install/SKILL.md b/trellis/skills/trellis-install/SKILL.md index 8f9f4a1..42dd666 100644 --- a/trellis/skills/trellis-install/SKILL.md +++ b/trellis/skills/trellis-install/SKILL.md @@ -21,19 +21,16 @@ If a previous install exists, prefer running `trellis-update` instead - it re-sy ``` 2. **Copy the rules and record what Trellis owns.** The owned-list (`docs/rules/.trellis-owned`) - lets `trellis-update` refresh and prune only Trellis's files, never your own. Templates are - seeded once and never clobbered: + lets `trellis-update` refresh and prune only Trellis's files, never your own: ```sh set -- "$SRC/rules/"*.md [ -e "$1" ] || { echo "no rules found at $SRC/rules - is TRELLIS_SRC right?"; exit 1; } cp "$@" docs/rules/ ( cd "$SRC/rules" && ls *.md ) > docs/rules/.trellis-owned - if [ -d "$SRC/templates" ] && find "$SRC/templates" -type f ! -name .gitkeep | grep -q .; then - mkdir -p docs/templates && cp -Rn "$SRC/templates/." docs/templates/ - fi ``` If a file you wrote yourself shares a name with a shipped rule, it will be overwritten - tell - the developer rather than silently clobbering it. + the developer rather than silently clobbering it. (Optional **templates** are opt-in and + handled in step 6, not seeded here.) 3. **Wire up the host file.** Pick the primary host file: `AGENTS.md` if it exists, else `CLAUDE.md` if it exists, else create `AGENTS.md`. Insert or replace the Trellis block (the @@ -90,7 +87,29 @@ If a previous install exists, prefer running `trellis-update` instead - it re-sy installed. Surface whatever the scanner reports, and when it is dirty, point the developer at `--fix`. -6. **Confirm.** Verify the install actually took, then report: +6. **Optional templates (only when asked).** A template is an opt-in bundle under + `$SRC/templates//` (see `$SRC/templates/README.md`). **Skip this step entirely unless the + developer ran `/trellis-install --template `.** A template splits into `owned/` (Trellis + refreshes these on every update - never hand-edit them) and `seed/` (copied once, then yours); + both mirror their target paths, so install just merges them into the repo root - `owned` + clobbering, `seed` only if absent. Record the install so `trellis-update` maintains it without + the flag: + ```sh + name= # e.g. plugin-release + tdir="$SRC/templates/$name" + [ -d "$tdir/owned" ] || { echo "no such template: $name (looked in $tdir)"; exit 1; } + cp -Rp "$tdir/owned/." . # owned -> functional paths (refresh/clobber) + [ -d "$tdir/seed" ] && cp -Rn "$tdir/seed/." . # seed -> once, never clobber existing + touch docs/rules/.trellis-templates + grep -qxF "$name" docs/rules/.trellis-templates || echo "$name" >> docs/rules/.trellis-templates + ( cd "$tdir/owned" && find . -type f | sed 's#^\./##' ) > "docs/rules/.trellis-owned-$name" + ``` + Then read the template's `README.md` and walk the developer through its setup (for + `plugin-release`: set `VERSION`, fill `.version-manifests`, match the CI workflow name in + `release.yml`). Stress that **owned files are overwritten on every update** - all customization + goes in the seed files. + +7. **Confirm.** Verify the install actually took, then report: ```sh ok=1 while IFS= read -r f; do [ -s "docs/rules/$f" ] || { echo "missing/empty docs/rules/$f"; ok=0; }; done < docs/rules/.trellis-owned @@ -99,5 +118,5 @@ If a previous install exists, prefer running `trellis-update` instead - it re-sy [ "$ok" = 1 ] && echo "Trellis installed - rules in docs/rules/, commit-msg hook active, block wired into the host file." ``` Tell the developer every change from here follows the rules in `docs/rules/`, that the - compliance pass flagged either a clean repo or a list to clean up (with `--fix`), and that - updates come later with `/trellis-update`. + compliance pass flagged either a clean repo or a list to clean up (with `--fix`), any template + they added and its setup, and that updates come later with `/trellis-update`. diff --git a/trellis/skills/trellis-update/SKILL.md b/trellis/skills/trellis-update/SKILL.md index bb11c13..457c5d3 100644 --- a/trellis/skills/trellis-update/SKILL.md +++ b/trellis/skills/trellis-update/SKILL.md @@ -21,7 +21,8 @@ alone. Do not hand-edit the owned rules - your edits are overwritten here by des [ -f docs/rules/.trellis-owned ] || { echo "no Trellis install found - run /trellis-install first"; exit 1; } ``` -2. **Refresh owned rules, prune orphans, rewrite the owned-list.** Templates are never clobbered: +2. **Refresh owned rules and templates, prune orphans, rewrite the owned-lists.** Your own files, + and any template `seed/` files, are never touched: ```sh set -- "$SRC/rules/"*.md [ -e "$1" ] || { echo "no rules found at $SRC/rules - is TRELLIS_SRC right?"; exit 1; } @@ -31,8 +32,24 @@ alone. Do not hand-edit the owned rules - your edits are overwritten here by des [ -n "$old" ] && [ ! -e "$SRC/rules/$old" ] && rm -f "docs/rules/$old" done < docs/rules/.trellis-owned ( cd "$SRC/rules" && ls *.md ) > docs/rules/.trellis-owned - if [ -d "$SRC/templates" ] && find "$SRC/templates" -type f ! -name .gitkeep | grep -q .; then - mkdir -p docs/templates && cp -Rn "$SRC/templates/." docs/templates/ + + # Refresh every installed template (recorded by --template at install). Owned files are + # re-copied to the current plugin version; seed files and your content are left alone. No + # --template flag needed - the registry says what to maintain. + if [ -f docs/rules/.trellis-templates ]; then + while IFS= read -r name; do + [ -n "$name" ] || continue + tdir="$SRC/templates/$name" + [ -d "$tdir/owned" ] || { echo "template '$name' no longer shipped - leaving its files in place"; continue; } + cp -Rp "$tdir/owned/." . + # prune owned files this template used to ship but no longer does + if [ -f "docs/rules/.trellis-owned-$name" ]; then + while IFS= read -r old; do + [ -n "$old" ] && [ ! -e "$tdir/owned/$old" ] && rm -f "$old" + done < "docs/rules/.trellis-owned-$name" + fi + ( cd "$tdir/owned" && find . -type f | sed 's#^\./##' ) > "docs/rules/.trellis-owned-$name" + done < docs/rules/.trellis-templates fi ``` diff --git a/trellis/templates/.gitkeep b/trellis/templates/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/trellis/templates/README.md b/trellis/templates/README.md new file mode 100644 index 0000000..41763f0 --- /dev/null +++ b/trellis/templates/README.md @@ -0,0 +1,45 @@ +# Trellis templates + +Templates are **optional** Trellis components that only some repos want - unlike `rules/`, which +every Trellis repo follows. Each template is a bundle under `templates//` that a repo opts +into at install: + +``` +/trellis-install --template +``` + +Once installed, plain `/trellis-update` keeps the template's owned files current - no need to +re-pass the flag. Installing nothing extra is the default; base install/update ignore templates +you did not ask for. + +## Anatomy of a template + +A template splits its files by ownership so an update can refresh the mechanism without ever +destroying your content: + +``` +templates// + README.md # what it is, how to adopt it + owned/ # Trellis owns these - refreshed (clobbered) on every update; do not edit + seed/ # copied once on install, never touched again - yours to edit +``` + +Both subtrees mirror their target layout: a file at `owned/scripts/foo.sh` installs to +`scripts/foo.sh` in the consumer repo, `seed/VERSION` to `VERSION`, and so on. + +## How install and update track a template + +- Install copies `owned/` (clobbering) and `seed/` (only if absent), appends `` to + `docs/rules/.trellis-templates`, and records the template's owned files in + `docs/rules/.trellis-owned-`. +- Update reads `docs/rules/.trellis-templates` and, for each installed template, re-copies its + `owned/` files, rewrites the owned-list, and prunes any owned file Trellis no longer ships. It + never touches `seed/` targets or anything you authored. + +The rule is one-way: **consumer-editable content never lives inside a Trellis-owned file**, so a +refresh can clobber freely. Put anything a repo customizes in `seed/`, never `owned/`. + +## Templates + +- **[`plugin-release/`](plugin-release/README.md)** - single-source version + automated tag and + GitHub Release on merge, for repos that are published marketplace plugins. diff --git a/trellis/templates/plugin-release/README.md b/trellis/templates/plugin-release/README.md new file mode 100644 index 0000000..bdfe180 --- /dev/null +++ b/trellis/templates/plugin-release/README.md @@ -0,0 +1,45 @@ +# plugin-release template + +Single-source plugin versioning + an automated tag and GitHub Release on merge. For repos that +are themselves published marketplace plugins (one version string duplicated across several host +manifests). + +Install with: + +``` +/trellis-install --template plugin-release +``` + +After that, plain `/trellis-update` keeps the owned files current - you never re-pass the flag. + +## What it installs + +Owned by Trellis (refreshed on update - do not edit): + +| File | Role | +|---|---| +| `scripts/bump-version.sh` | Rewrites `VERSION` + every listed manifest in lockstep; `--check` fails CI on drift. | +| `.github/workflows/release.yml` | After CI succeeds on `main`, tags the version and publishes a Release. | +| `docs/releases/README.md` | The per-version release-notes convention. | + +Yours (seeded once, never touched again): + +| File | Role | +|---|---| +| `VERSION` | The single source of truth for your current version. Set it to your current number. | +| `.version-manifests` | The manifests that must match `VERSION`, one path per line. Edit to list yours. | +| `docs/releases/.md` | Your hand-written notes for a release (optional per release). | + +## Setup after install + +1. Set `VERSION` to your plugin's current version (e.g. `echo 1.4.0 > VERSION`). +2. Edit `.version-manifests` to list every manifest that hardcodes the version (each must have + exactly one `"version"` token). Run `scripts/bump-version.sh --check` until it is clean. +3. Ensure you have a CI workflow and that its `name:` matches the `workflows:` list at the top of + `.github/workflows/release.yml` (default `CI`). The release waits for it to succeed on `main`. +4. Wire `scripts/bump-version.sh --check` into that CI so drift is caught. + +## Releasing + +`scripts/bump-version.sh X.Y.Z` -> write `docs/releases/X.Y.Z.md` -> PR -> squash-merge. CI runs; +on success the release is tagged and published from your notes. See `docs/releases/README.md`. diff --git a/trellis/templates/plugin-release/owned/.github/workflows/release.yml b/trellis/templates/plugin-release/owned/.github/workflows/release.yml new file mode 100644 index 0000000..17b85cd --- /dev/null +++ b/trellis/templates/plugin-release/owned/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +# Shipped by Trellis's "plugin-release" template and OWNED by Trellis - re-run trellis-update +# to refresh it. Do not hand-edit. +# +# Tags a bare-semver release and publishes a GitHub Release whenever the root VERSION moves. +# It runs AFTER your CI workflow succeeds on main (workflow_run), so it never publishes +# unvalidated code and needs no knowledge of your CI's job names. The one repo-specific knob is +# the workflow name below: it must match the `name:` of the CI workflow you want to gate on. +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +# Default to read-only; the release job elevates itself for the one write it needs. +permissions: + contents: read + +jobs: + release: + name: Tag + GitHub Release on version bump + runs-on: ubuntu-latest + # Only after the CI workflow concluded successfully, and only on main. A failed or + # non-main CI run never reaches the write token. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + # Pinned to a full SHA (v4.2.2) because this job carries a write token. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - name: Create the release when VERSION is new + # Idempotent: a run whose VERSION already has a release does nothing, so this only + # publishes on the commit that bumps VERSION. Prefer a hand-written notes file (its + # first non-heading line becomes the README headline via whats-new.yml); fall back to + # auto-generated notes from the conventional-commit titles since the previous tag. + run: | + v="$(cat VERSION)" + if gh release view "$v" >/dev/null 2>&1; then + echo "Release $v already exists; nothing to do." + exit 0 + fi + sha="$(git rev-parse HEAD)" + notes="docs/releases/$v.md" + if [ -f "$notes" ]; then + echo "Publishing release $v from $notes" + gh release create "$v" --target "$sha" --notes-file "$notes" + else + echo "Publishing release $v with auto-generated notes (no $notes)" + gh release create "$v" --target "$sha" --generate-notes + fi diff --git a/trellis/templates/plugin-release/owned/docs/releases/README.md b/trellis/templates/plugin-release/owned/docs/releases/README.md new file mode 100644 index 0000000..89a61a1 --- /dev/null +++ b/trellis/templates/plugin-release/owned/docs/releases/README.md @@ -0,0 +1,25 @@ +# Release notes + +One file per published version: `docs/releases/.md` (bare semver, no `v`). The +`Release` workflow uses it as the GitHub Release body when you bump `VERSION` to that number; if +the file is absent it falls back to auto-generated notes from your Conventional Commit titles. + +This file is shipped and owned by Trellis's plugin-release template (refreshed by +`trellis-update`). The per-version `.md` notes are yours. + +## Convention + +- **The first non-heading line is the headline.** The "What's new" automation extracts it + verbatim into the README, so make it a single plain sentence - no leading `#`, no list marker. + Everything after it is the Release body. +- Keep it ASCII and in the repo's voice. +- The auto-generated fallback (no `.md`) yields a raw commit bullet as the headline, so + write the file for any release whose headline matters. + +## Releasing + +1. `scripts/bump-version.sh X.Y.Z` - rewrites `VERSION` + every manifest in `.version-manifests`. +2. Write `docs/releases/X.Y.Z.md` (headline first, then details). +3. Open a PR and squash-merge to `main`. +4. Your CI workflow runs; on success the `Release` workflow tags `X.Y.Z` and publishes the + Release from your notes. The "What's new" block then refreshes from the headline. diff --git a/trellis/templates/plugin-release/owned/scripts/bump-version.sh b/trellis/templates/plugin-release/owned/scripts/bump-version.sh new file mode 100755 index 0000000..23f1eb1 --- /dev/null +++ b/trellis/templates/plugin-release/owned/scripts/bump-version.sh @@ -0,0 +1,170 @@ +#!/bin/sh +# Plugin version bump - keeps a marketplace plugin's version honest across every manifest. +# +# Shipped by Trellis's "plugin-release" template and OWNED by Trellis: re-run +# `trellis-update` to refresh it. Do not hand-edit; your edits are overwritten by design. +# +# The single source of truth is the root VERSION file. The manifests that must agree are +# listed, one path per line, in the root `.version-manifests` (which you own and edit). Each +# manifest must contain EXACTLY ONE "version" token; this script keeps them identical via a +# format-preserving surgical substitution - no JSON re-serialize that would reflow the files. +# +# Dependency-light: POSIX sh + python3, so it runs identically in CI and a local shell. +# +# Usage: +# scripts/bump-version.sh print the current version (cat VERSION) +# scripts/bump-version.sh --check verify VERSION == every manifest's version (exit 1 on drift) +# scripts/bump-version.sh X.Y.Z write VERSION + all manifests to X.Y.Z (semver, no v) +# scripts/bump-version.sh -h|--help usage +set -eu + +# Root override (BUMP_VERSION_ROOT) lets a test suite run this against a sandbox tree. +ROOT="${BUMP_VERSION_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" + +manifests_file="$ROOT/.version-manifests" + +# Read the manifest list: one path per line, '#' comments and blank lines ignored. +read_manifests() { + [ -f "$manifests_file" ] || { + echo "bump-version: missing $manifests_file (list one manifest path per line)" >&2 + exit 1 + } + sed -e 's/#.*$//' -e 's/[[:space:]]*$//' "$manifests_file" | grep -v '^[[:space:]]*$' || true +} + +usage() { + echo "usage: $0 [--check | X.Y.Z]" + echo " (no arg) print the current version" + echo " --check verify VERSION matches every manifest's version (exit 1 on drift)" + echo " X.Y.Z write VERSION + every listed manifest to X.Y.Z (semver, no 'v' prefix)" +} + +# check -> compare VERSION against every manifest's lone version token; exit 1 on any +# drift/missing/malformed, else 0. +check() { + ROOT="$ROOT" MANIFESTS="$(read_manifests)" python3 - <<'PY' +import json, os, re, sys + +root = os.environ["ROOT"] +manifests = [m for m in os.environ["MANIFESTS"].split("\n") if m] +drift = False + +vfile = os.path.join(root, "VERSION") +try: + want = open(vfile).read().strip() +except OSError as e: + print(f"bump-version: cannot read VERSION: {e}", file=sys.stderr) + sys.exit(1) + +if not manifests: + print("bump-version: .version-manifests lists no manifests", file=sys.stderr) + sys.exit(1) + +for rel in manifests: + path = os.path.join(root, rel) + try: + raw = open(path).read() + except OSError: + print(f"bump-version: missing manifest: {rel}") + drift = True + continue + try: + json.loads(raw) + except Exception as e: + print(f"bump-version: {rel} does not parse as JSON: {e}") + drift = True + continue + found = re.findall(r'"version"\s*:\s*"([^"]*)"', raw) + if len(found) != 1: + print(f"bump-version: {rel} has {len(found)} version tokens (expected exactly 1)") + drift = True + continue + if found[0] != want: + print(f"bump-version: {rel} version {found[0]} != VERSION {want}") + drift = True + +sys.exit(1 if drift else 0) +PY +} + +# write NEW -> surgically substitute the single version token in each manifest, then VERSION. +write() { + new="$1" + # Validate every manifest substitution IN MEMORY first and write the files only once all + # pass; write VERSION LAST. So a guard failure (a stray second "version" token, a file that + # wouldn't parse) aborts with nothing changed on disk - never a half-bumped tree. + ROOT="$ROOT" MANIFESTS="$(read_manifests)" NEW="$new" python3 - <<'PY' +import json, os, re, sys + +root = os.environ["ROOT"] +manifests = [m for m in os.environ["MANIFESTS"].split("\n") if m] +new = os.environ["NEW"] + +if not manifests: + print("bump-version: .version-manifests lists no manifests", file=sys.stderr) + sys.exit(1) + +pending = {} +for rel in manifests: + path = os.path.join(root, rel) + try: + raw = open(path).read() + except OSError as e: + print(f"bump-version: missing manifest: {rel} ({e})", file=sys.stderr) + sys.exit(1) + updated, n = re.subn( + r'("version"\s*:\s*")[^"]*(")', + lambda m: m.group(1) + new + m.group(2), + raw, + ) + if n != 1: + print(f"bump-version: {rel} had {n} version tokens (expected exactly 1)", file=sys.stderr) + sys.exit(1) + try: + json.loads(updated) + except Exception as e: + print(f"bump-version: {rel} would not parse after substitution: {e}", file=sys.stderr) + sys.exit(1) + pending[path] = updated + +for path, content in pending.items(): + open(path, "w").write(content) +PY + printf '%s\n' "$new" > "$ROOT/VERSION" +} + +if [ "$#" -eq 0 ]; then + cat "$ROOT/VERSION" + exit 0 +fi + +case "$1" in + -h|--help) + usage + exit 0 + ;; + --check) + if check; then exit 0; else exit 1; fi + ;; + *) + # Reject anything outside digits and dots first - this also rejects a multiline argument + # (a newline is a non-[0-9.] char), closing the per-line match a bare regex would allow. + case "$1" in + *[!0-9.]*) + echo "bump-version: '$1' is not a semantic version (digits and dots only)." >&2 + echo " expected: X.Y.Z e.g. '1.2.3' (no 'v' prefix, exactly three numeric parts)" >&2 + exit 1 + ;; + esac + if ! printf '%s' "$1" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "bump-version: '$1' is not a semantic version." >&2 + echo " expected: X.Y.Z e.g. '1.2.3' (no 'v' prefix, exactly three numeric parts)" >&2 + exit 1 + fi + write "$1" + if ! check; then + echo "bump-version: version did not converge after write." >&2 + exit 1 + fi + ;; +esac diff --git a/trellis/templates/plugin-release/seed/.version-manifests b/trellis/templates/plugin-release/seed/.version-manifests new file mode 100644 index 0000000..6d97b1f --- /dev/null +++ b/trellis/templates/plugin-release/seed/.version-manifests @@ -0,0 +1,12 @@ +# The manifests that must carry the same version as the root VERSION file. +# One path per line, relative to the repo root. Blank lines and '#' comments are ignored. +# This file is yours to edit; Trellis seeds it once and never overwrites it. +# +# List every manifest that hardcodes your plugin's version - typically the per-host plugin +# manifests and any marketplace files. Each must contain exactly one "version" token. Examples +# (delete the ones you do not have, add any you do): +# +# .claude-plugin/marketplace.json +# .cursor-plugin/marketplace.json +# .codex-plugin/plugin.json +# gemini-extension.json diff --git a/trellis/templates/plugin-release/seed/VERSION b/trellis/templates/plugin-release/seed/VERSION new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/trellis/templates/plugin-release/seed/VERSION @@ -0,0 +1 @@ +0.0.0 From 6c1e2219b55e7d8179eb25cb2140fbd0b577bba6 Mon Sep 17 00:00:00 2001 From: Matthew Maynes Date: Sun, 28 Jun 2026 08:48:34 -0400 Subject: [PATCH 2/2] fix(release): harden workflow_run fork guard + pin the CI-name contract Addresses review findings on the plugin-release template: - security: the release job's workflow_run guard checked conclusion + head_branch == 'main' only. A fork PR whose branch is named 'main' would satisfy head_branch, letting its CI completion reach the contents: write token. Add `workflow_run.event == 'push'` - fork PRs run CI as 'pull_request', so they are now excluded. head_branch stays as defense in depth. - architecture: the one consumer-specific value (workflows: ["CI"]) lives in release.yml, which is owned and clobbered on update - so a consumer edit would be lost. Make the contract "name your CI workflow CI" and document not to edit the owned file (template + plugin-release READMEs, release.yml header). - tests: add a write-atomicity case (a two-token manifest aborts the whole write - VERSION and good manifests unchanged) and a .version-manifests comment/blank-line parsing case. bump-version.test.sh now 22 checks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 16 +++++++++++----- trellis/scripts/bump-version.test.sh | 18 ++++++++++++++++++ trellis/templates/plugin-release/README.md | 5 +++-- .../owned/.github/workflows/release.yml | 16 +++++++++++----- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17b85cd..befedde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,12 @@ name: Release # to refresh it. Do not hand-edit. # # Tags a bare-semver release and publishes a GitHub Release whenever the root VERSION moves. -# It runs AFTER your CI workflow succeeds on main (workflow_run), so it never publishes -# unvalidated code and needs no knowledge of your CI's job names. The one repo-specific knob is -# the workflow name below: it must match the `name:` of the CI workflow you want to gate on. +# It runs AFTER your CI workflow succeeds on a push to main (workflow_run), so it never publishes +# unvalidated code and needs no knowledge of your CI's job names. +# +# This file is OWNED by the plugin-release template and refreshed by trellis-update - do NOT edit +# it. It waits on a CI workflow named "CI": to adopt the template, name your CI workflow `CI` +# rather than changing the line below (any edit here is overwritten on the next update). on: workflow_run: workflows: ["CI"] @@ -20,9 +23,12 @@ jobs: release: name: Tag + GitHub Release on version bump runs-on: ubuntu-latest - # Only after the CI workflow concluded successfully, and only on main. A failed or - # non-main CI run never reaches the write token. + # Only after a push to main's CI concluded successfully. Requiring event == 'push' is the + # fork guard: a fork PR's CI runs as 'pull_request', so it can never reach this write token - + # even a fork whose branch is named 'main' (which would otherwise satisfy head_branch). The + # head_branch check is kept as defense in depth. if: >- + github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' permissions: diff --git a/trellis/scripts/bump-version.test.sh b/trellis/scripts/bump-version.test.sh index 435a395..f005ca1 100755 --- a/trellis/scripts/bump-version.test.sh +++ b/trellis/scripts/bump-version.test.sh @@ -81,6 +81,24 @@ BUMP_VERSION_ROOT="$d" sh "$SUT" --check >/dev/null 2>&1 && rc=0 || rc=$? check "--check exits 1 on a two-token manifest" 1 "$rc" rm -rf "$d" +# --- atomic write: a bad manifest aborts the WHOLE write, no half-bump ------ +# manifest b is fine, a has two tokens. A write must validate all in memory and touch nothing, +# so VERSION and the good manifest stay at the old value (no partial bump). +d=$(sandbox 1.0.0) +printf '{\n "version": "1.0.0",\n "deps": { "x": { "version": "1.0.0" } }\n}\n' > "$d/a/plugin.json" +BUMP_VERSION_ROOT="$d" sh "$SUT" 9.9.9 >/dev/null 2>&1 && rc=0 || rc=$? +check "write aborts on a two-token manifest" 1 "$rc" +checkeq "aborted write leaves VERSION unchanged" "1.0.0" "$(cat "$d/VERSION")" +checkeq "aborted write leaves the good manifest unchanged" "1.0.0" "$(ver "$d/b/market.json")" +rm -rf "$d" + +# --- .version-manifests ignores comments and blank lines ------------------- +d=$(sandbox 1.0.0) +printf '# a comment\n\na/plugin.json\n \nb/market.json # trailing\n' > "$d/.version-manifests" +BUMP_VERSION_ROOT="$d" sh "$SUT" --check >/dev/null 2>&1 && rc=0 || rc=$? +check "--check parses .version-manifests with comments and blanks" 0 "$rc" +rm -rf "$d" + # --- a missing manifest is an error ---------------------------------------- d=$(sandbox 1.0.0) rm -f "$d/b/market.json" diff --git a/trellis/templates/plugin-release/README.md b/trellis/templates/plugin-release/README.md index bdfe180..6971cc8 100644 --- a/trellis/templates/plugin-release/README.md +++ b/trellis/templates/plugin-release/README.md @@ -35,8 +35,9 @@ Yours (seeded once, never touched again): 1. Set `VERSION` to your plugin's current version (e.g. `echo 1.4.0 > VERSION`). 2. Edit `.version-manifests` to list every manifest that hardcodes the version (each must have exactly one `"version"` token). Run `scripts/bump-version.sh --check` until it is clean. -3. Ensure you have a CI workflow and that its `name:` matches the `workflows:` list at the top of - `.github/workflows/release.yml` (default `CI`). The release waits for it to succeed on `main`. +3. Ensure you have a CI workflow **named `CI`** (`name: CI`) that runs on pushes to `main` - the + release waits for it to succeed there. `release.yml` is owned (refreshed on update), so adapt + your workflow's name rather than editing `release.yml`'s `workflows:` line. 4. Wire `scripts/bump-version.sh --check` into that CI so drift is caught. ## Releasing diff --git a/trellis/templates/plugin-release/owned/.github/workflows/release.yml b/trellis/templates/plugin-release/owned/.github/workflows/release.yml index 17b85cd..befedde 100644 --- a/trellis/templates/plugin-release/owned/.github/workflows/release.yml +++ b/trellis/templates/plugin-release/owned/.github/workflows/release.yml @@ -4,9 +4,12 @@ name: Release # to refresh it. Do not hand-edit. # # Tags a bare-semver release and publishes a GitHub Release whenever the root VERSION moves. -# It runs AFTER your CI workflow succeeds on main (workflow_run), so it never publishes -# unvalidated code and needs no knowledge of your CI's job names. The one repo-specific knob is -# the workflow name below: it must match the `name:` of the CI workflow you want to gate on. +# It runs AFTER your CI workflow succeeds on a push to main (workflow_run), so it never publishes +# unvalidated code and needs no knowledge of your CI's job names. +# +# This file is OWNED by the plugin-release template and refreshed by trellis-update - do NOT edit +# it. It waits on a CI workflow named "CI": to adopt the template, name your CI workflow `CI` +# rather than changing the line below (any edit here is overwritten on the next update). on: workflow_run: workflows: ["CI"] @@ -20,9 +23,12 @@ jobs: release: name: Tag + GitHub Release on version bump runs-on: ubuntu-latest - # Only after the CI workflow concluded successfully, and only on main. A failed or - # non-main CI run never reaches the write token. + # Only after a push to main's CI concluded successfully. Requiring event == 'push' is the + # fork guard: a fork PR's CI runs as 'pull_request', so it can never reach this write token - + # even a fork whose branch is named 'main' (which would otherwise satisfy head_branch). The + # head_branch check is kept as defense in depth. if: >- + github.event.workflow_run.event == 'push' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' permissions: