diff --git a/.gitignore b/.gitignore index 743fe05..101bbee 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ opensrc/ CHANGES.md **/CHANGES.md +# Private-term overlay for the public skill sync - never commit. +# See public-manifest.local.json.example. +public-manifest.local.json + # Personal terms list for gitleaks — never commit. See .gitleaks.local.toml.example. .gitleaks.local.toml diff --git a/.gitleaks.toml b/.gitleaks.toml index f962ec6..ca85020 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -27,7 +27,10 @@ tags = ["pii", "path"] [[rules]] id = "rfc1918-ipv4" description = "Private IPv4 address (homelab/LAN). Use a generic example or env var." -regex = '''\b(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b''' +# All four octets are required in every branch. The earlier `10|...` alternation +# made the 10/8 branch three-part, so it matched semver strings ("10.0.2" in any +# lockfile) far more often than it matched an address. +regex = '''\b(?:10(?:\.\d{1,3}){3}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2})\b''' tags = ["pii", "network"] [[rules]] @@ -53,6 +56,9 @@ paths = [ '''(^|/)node_modules/''', '''(^|/)\.gitleaks\.toml$''', # This file (regex literals would self-match) '''(^|/)\.gitleaks\.local\.toml(\.example)?$''', + # Same reason: this file's job is to hold example leaks and assert they are + # detected. Its fixtures are invented, never copied from a real machine. + '''(^|/)scripts/sync-public\.test\.mjs$''', '''(^|/)\.github/workflows/[^/]+\.ya?ml$''', # CI configs may reference 192.168.x.x in matrix ] diff --git a/biome.json b/biome.json index 7f00d4f..0bb9f54 100644 --- a/biome.json +++ b/biome.json @@ -24,6 +24,8 @@ "rules/**", "scripts/**", "skills/**", + "!skills/*/assets", + "!skills/*/templates", "!skills/art/Tools", "!skills/art/Lib", "themes/**", diff --git a/docs/public-sync.md b/docs/public-sync.md new file mode 100644 index 0000000..d2d60f5 --- /dev/null +++ b/docs/public-sync.md @@ -0,0 +1,119 @@ +# Public skill sync + +Most skills under `skills/` are **generated** from a private skill store. You edit +the skill once, in the store, and this repo is rebuilt from it with private detail +removed. `public-manifest.json` is the contract; `scripts/sync-public.mjs` executes it. + +Skills the manifest marks `public-owned` or `forked` are authored here and are never +touched by the sync. + +## Two manifests, on purpose + +`public-manifest.json` is tracked and holds only **structural** patterns: shapes that are +private regardless of who maintains the repo, like a macOS home path or an RFC1918 address. + +`public-manifest.local.json` is **gitignored** and holds every pattern or replacement that +**names** something: your machines, private repos, email domains, internal framework paths. +An enumeration of those names is itself metadata about your private ecosystem, so shipping +the list publicly would leak the very thing the list exists to protect. This mirrors the +`.gitleaks.toml` / `.gitleaks.local.toml` split the repo already uses. + +The sync merges them (overlay patterns appended, overlay replacements last) and **exits 1 +if the overlay is missing**. A scrub that quietly covers less than intended is worse than +one that refuses to run. Copy `public-manifest.local.json.example` to get started. + +## Commands + +```bash +bun scripts/sync-public.mjs --check # report drift, write nothing (exit 1 on drift) +bun scripts/sync-public.mjs # write +bun scripts/sync-public.mjs --prune # also delete orphaned generated files +bun scripts/sync-public.mjs --skill find-docs +``` + +The store defaults to `~/.agents/skills`. Point `AGENT_SKILLS_STORE` elsewhere to +override it. When the store is absent, `--check` exits 0 with a notice saying drift +was **not** verified, so contributors without the store are not blocked. Only the +golden tests run everywhere. + +## Modes + +| Mode | Who is canonical | What sync does | +|---|---|---| +| `mirror` | the store | Copies and transforms store files into `skills//`. | +| `public-owned` | this repo | Skipped entirely. For skills authored here with no private counterpart. | +| `forked` | both, deliberately | Skipped, with a required `reason` recording why they diverged. | + +`mirror` entries may declare: + +- `source`: the store directory name, when it differs from the public one + (`git-workflow` in the store, `gitworkflow` here). +- `publicOwned`: globs the sync must never write **or** delete. This is what lets a + public skill ship tooling (`scripts/`, `fixtures/`, `agents/openai.yaml`) that the + store does not carry. +- `exclude`: globs dropped from the published output entirely. For files that are + private by nature rather than by wording. +- `replace` / `dropLines`: per-skill text rules, applied after the global ones. + `dropLines` removes whole lines matching a regex, which is how a table row pointing + at an excluded file gets removed. + +An entry that is not `mirror` must record a `reason`. A test enforces that, so the +reasons stay readable as a record of why two copies diverged. + +## The four gates + +Being "verifiable" here means a transform gap fails a run rather than shipping. + +1. **Leak assertion** (`scripts/sync-public.mjs`). Every transformed text file is + scanned against `leakPatterns` *after* transforms. Any hit aborts that skill with a + `file:line [pattern-id] excerpt` report and writes nothing. This is the backstop for + a replacement rule that was never written, which is the failure mode a find-and-replace + pipeline otherwise hides. Note that only extensions in `textExtensions` are scanned: + anything else is copied byte-for-byte, so add an extension before publishing a new + file type. + +2. **Excluded-reference check.** Excluding a private file is fine. Leaving the published + SKILL.md pointing at it ships a broken skill, so any published text that still names + an excluded path aborts the run. Resolve it by publishing the target, or by dropping + the pointer with `dropLines` / `replace`. + +3. **Golden tests** (`scripts/sync-public.test.mjs`, part of `bun run test`). The + transform chain and every leak pattern are tested against fixtures, so the scrub + itself is proven rather than trusted. These run without the store. + +4. **gitleaks pre-commit**. `.gitleaks.toml` (tracked, structural PII) plus + `.gitleaks.local.toml` (gitignored, per-contributor terms). Independent of the sync, + so it also covers hand-edited files. + +Run `--check` on a schedule to catch staleness. Nothing in `bun run check` depends on +the store being present. + +## Formatting owns nothing generated + +`biome.json` excludes `skills/*/assets/**` and `skills/*/templates/**`. Those are payloads +a skill ships, and their formatting belongs to whoever wrote them. Without the exclusion, +`lint:fix` rewrites generated files and the very next `--check` reports drift that no one +introduced. + +## Circular sources + +A store entry that is a symlink **into this repo** makes source and destination the +same bytes, so "syncing" it means no scrub ever ran. The script detects this and +refuses: + +``` +diataxis-docs-site: store entry resolves inside this repo (…/skills/diataxis-docs-site). + It is still a symlink into the public checkout, so no scrub can run. + Materialize it in the store first, then re-run. +``` + +Fix it by replacing the store symlink with a real directory holding the content, then +repointing any harness lane symlinks at the store rather than at this repo. + +## Adding a skill + +1. Add an entry to `public-manifest.json` with a mode. Non-`mirror` modes require a `reason`. +2. `bun scripts/sync-public.mjs --skill --check` and read the plan. +3. Resolve any leak findings by fixing the source, adding a transform, or excluding the file. +4. Sync, then run `npm test`. A skill with content tests will tell you if the store + version and the published tooling have drifted apart. diff --git a/package.json b/package.json index 589fbfc..f0b14d2 100644 --- a/package.json +++ b/package.json @@ -54,10 +54,12 @@ "lint": "biome lint .", "lint:fix": "biome check --write .", "prepare": "husky", - "test": "node extensions/autopilot/v2-smoke-test.mjs && node extensions/question/question-smoke-test.mjs && node extensions/conditional-hooks/smoke-test.mjs && node scripts/lib/frontmatter-test.mjs && node scripts/lib/bundle-refs-test.mjs && node scripts/cli-entrypoint-test.mjs && python3 skills/diataxis-docs-site/tests/test_create_site.py && bun test scripts/lib/package-links.test.mjs scripts/validate-lockfile.test.mjs && bun test skills/pr-review-queue/ && bun run test:herdr-fleet", + "test": "node extensions/autopilot/v2-smoke-test.mjs && node extensions/question/question-smoke-test.mjs && node extensions/conditional-hooks/smoke-test.mjs && node scripts/lib/frontmatter-test.mjs && node scripts/lib/bundle-refs-test.mjs && node scripts/cli-entrypoint-test.mjs && python3 skills/diataxis-docs-site/tests/test_create_site.py && bun test scripts/lib/package-links.test.mjs scripts/validate-lockfile.test.mjs scripts/sync-public.test.mjs && bun test skills/pr-review-queue/ && bun run test:herdr-fleet", "test:herdr-fleet": "bun skills/herdr-fleet/scripts/resolve-project-key.mjs --self-test && bun skills/herdr-fleet/scripts/watch-fleet.mjs --self-test && bun skills/herdr-fleet/scripts/consume-events.mjs --self-test && bun test skills/herdr-fleet/skill-content.test.mjs skills/herdr-fleet/scripts/fleet-state.test.mjs skills/herdr-fleet/scripts/review-thread-gate.test.mjs", "typecheck": "tsc --noEmit", - "validate:skills": "bun scripts/validate-agent-skills.mjs" + "validate:skills": "bun scripts/validate-agent-skills.mjs", + "sync:public": "bun scripts/sync-public.mjs", + "sync:public:check": "bun scripts/sync-public.mjs --check" }, "peerDependencies": { "@mariozechner/pi-ai": "*", diff --git a/public-manifest.json b/public-manifest.json new file mode 100644 index 0000000..87adc09 --- /dev/null +++ b/public-manifest.json @@ -0,0 +1,153 @@ +{ + "$comment": "Drives scripts/sync-public.mjs. The private skill store is canonical for mirror skills; this repo is generated from it. Patterns and replacements that NAME private things live in the gitignored localOverlay, never here: the list itself is metadata about a private ecosystem. See docs/public-sync.md.", + "storeRoot": "~/.agents/skills", + "storeRootEnv": "AGENT_SKILLS_STORE", + "publicSkillsDir": "skills", + "textExtensions": [ + ".md", + ".mjs", + ".js", + ".ts", + ".json", + ".yaml", + ".yml", + ".hbs", + ".py", + ".rb", + ".sh", + ".toml", + ".txt", + ".cfg", + ".ini", + ".env", + ".example" + ], + "transforms": { + "$comment": "Applied in order to every text file: frontmatter keys dropped, then the skill name rewritten, then literal replacements.", + "dropFrontmatterKeys": [ + "metadata.machines", + "metadata.requires" + ], + "replace": [ + { + "find": "~/.claude", + "replaceWith": "$AGENT_HOME" + }, + { + "find": "Ossie", + "replaceWith": "the user" + } + ] + }, + "leakPatterns": [ + { + "id": "macos-home-path", + "description": "Contributor home directory path", + "regex": "/Users/(?!(?:you|me|user|username|name)/)[a-z][a-z0-9._-]*/" + }, + { + "id": "rfc1918-ipv4", + "description": "Private LAN address. All four octets are required; a three-part `10.0.2` is a semver, not an address.", + "regex": "\\b(?:10(?:\\.\\d{1,3}){3}|172\\.(?:1[6-9]|2\\d|3[01])(?:\\.\\d{1,3}){2}|192\\.168(?:\\.\\d{1,3}){2})\\b" + } + ], + "skills": { + "adversarial-review": { + "mode": "mirror", + "publicOwned": [ + "agents/**" + ] + }, + "deep-dive": { + "mode": "mirror", + "publicOwned": [ + "agents/**" + ] + }, + "find-docs": { + "mode": "mirror", + "publicOwned": [ + "agents/**" + ] + }, + "diataxis-docs-site": { + "mode": "mirror", + "$comment": "Was a store symlink into this repo until 2026-08-18; store is now canonical." + }, + "github-wiki": { + "mode": "mirror", + "$comment": "Was a store symlink into this repo until 2026-08-18; store is now canonical." + }, + "gitworkflow": { + "mode": "mirror", + "source": "git-workflow", + "publicOwned": [ + "agents/**", + "AGENT.md", + "templates/issue-labeler.yml" + ], + "exclude": [ + "workflows/SetIdentity.md", + "tools/**" + ], + "$comment": "SetIdentity hardcodes real names and email addresses, including a third party's. tools/changelog is a local copy of this repo's own root changelog/ package, so published text points at the package instead.", + "dropLines": [ + "\\|\\s*\\*\\*SetIdentity\\*\\*\\s*\\|" + ], + "replace": [ + { + "find": "Wrapper if not on PATH: `$AGENT_HOME/skills/git-workflow/tools/changelog/changelog` (bundled source in `tools/changelog/`, needs `uv`). Canonical home of the installed CLI: public", + "replaceWith": "Install the CLI from" + }, + { + "find": "# or $AGENT_HOME/skills/GitWorkflow/tools/changelog/changelog", + "replaceWith": "# install with: uv tool install --from git+https://github.com/AojdevStudio/agentic-utilities#subdirectory=changelog changelog" + } + ] + }, + "harness-audit": { + "mode": "mirror", + "exclude": [ + "references/sanity-check.md" + ], + "$comment": "sanity-check.md cites two private-vault pages as its source of truth, so it is useless outside that vault.", + "publicOwned": [ + "agents/**", + "references/evidence-protocol.md", + "references/smoke-ticket-eval.md", + "references/stack-go.md", + "references/symphony-readiness.md", + "references/workflow-template.md" + ] + }, + "herdr-fleet": { + "mode": "forked", + "reason": "Public version ships 18 files (protocols.md, launch-fleet.md, tested scripts/) that its SKILL.md documents. The private counterpart is a lean rewrite composing native herdr commands directly and references none of them, so syncing it orphans the tooling." + }, + "pr-review-queue": { + "mode": "forked", + "reason": "Public version's SKILL.md carries a documented untrusted-data boundary and an explicit-assignment requirement that skills/pr-review-queue/skill-content.test.mjs enforces. The private counterpart dropped both and does not document the shipped scripts/ helpers; syncing it fails 5 content assertions." + }, + "art": { + "mode": "forked", + "reason": "Private counterpart is a superset coupled to a private agent framework (extra Tools, 100+ private-path references). Reconciling it is a rewrite, not a transform." + }, + "awesome-readme": { + "mode": "forked", + "reason": "Private counterpart was restructured into the nested TitleCase Workflows/References layout; public keeps the flat kebab-case shape." + }, + "bambu-slicer": { + "mode": "public-owned", + "reason": "Authored here. No private counterpart." + }, + "scaffold-notes": { + "mode": "public-owned", + "reason": "Maintenance helper for this repo only." + }, + "harness-worktrees": { + "mode": "public-owned", + "reason": "Private counterpart was archived and superseded by a differently-shaped skill." + } + }, + "localOverlay": "public-manifest.local.json" +} diff --git a/public-manifest.local.json.example b/public-manifest.local.json.example new file mode 100644 index 0000000..1e93342 --- /dev/null +++ b/public-manifest.local.json.example @@ -0,0 +1,27 @@ +{ + "$comment": "Copy to public-manifest.local.json (gitignored) and fill in your own terms. Merged into public-manifest.json by scripts/sync-public.mjs: leakPatterns are appended, transforms.replace runs after the tracked rules.", + + "$why": "The tracked manifest holds only structural patterns, the shapes that are private regardless of who maintains this repo (home paths, RFC1918 addresses). Anything that NAMES your machines, repos, domains, or internal frameworks belongs here instead. An enumeration of those names is itself metadata about your private ecosystem, so publishing the list would leak the thing the list exists to protect. Same reasoning as .gitleaks.local.toml.", + + "leakPatterns": [ + { + "id": "private-email", + "description": "Your personal or business email domains", + "regex": "[A-Za-z0-9._%+-]+@(?:your-domain)\\.[A-Za-z]{2,}" + }, + { + "id": "private-machine-names", + "description": "Your machine and host names", + "regex": "\\b(?:your-laptop|your-server)\\b" + }, + { + "id": "private-repo-names", + "description": "Private repos a public skill must not cite", + "regex": "\\b(?:your-private-repo|your-vault)\\b" + } + ], + + "transforms": { + "replace": [{ "find": "your-private-repo", "replaceWith": "a private repo" }] + } +} diff --git a/scripts/sync-public.mjs b/scripts/sync-public.mjs new file mode 100644 index 0000000..ab262c8 --- /dev/null +++ b/scripts/sync-public.mjs @@ -0,0 +1,485 @@ +#!/usr/bin/env node +/** + * Generate this repo's public skills from the private skill store. + * + * The store is canonical for `mirror` skills; everything under `skills/` that a + * manifest entry claims is a build artifact. Private detail is removed by + * declared transforms, and anything the transforms miss is caught by the leak + * assertion — a transform gap fails the run instead of shipping. + * + * bun scripts/sync-public.mjs # write + * bun scripts/sync-public.mjs --check # report drift, write nothing (exit 1 on drift) + * bun scripts/sync-public.mjs --prune # also delete orphaned generated files + * bun scripts/sync-public.mjs --skill find-docs + * + * See docs/public-sync.md. + */ +import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, sep } from "node:path"; +import { parseDocument } from "yaml"; + +// ---- pure helpers (exported for scripts/sync-public.test.mjs) --------------- + +/** Expand a leading `~/` against the current user's home directory. */ +export function expandHome(inputPath, home = homedir()) { + if (inputPath === "~") return home; + if (inputPath.startsWith("~/")) return join(home, inputPath.slice(2)); + return inputPath; +} + +/** + * Translate a manifest glob into an anchored regex. + * `**` crosses path separators, `*` does not. Everything else is literal. + */ +export function globToRegExp(glob) { + let out = ""; + for (let i = 0; i < glob.length; i += 1) { + const char = glob[i]; + if (char === "*") { + if (glob[i + 1] === "*") { + out += ".*"; + i += 1; + } else { + out += "[^/]*"; + } + continue; + } + out += char.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + } + return new RegExp(`^${out}$`); +} + +/** True when `relPath` matches any glob in `globs`. */ +export function matchesAnyGlob(relPath, globs = []) { + return globs.some((glob) => globToRegExp(glob).test(relPath)); +} + +/** + * Delete dotted frontmatter keys (e.g. `metadata.machines`) from a document's + * YAML frontmatter, dropping a parent mapping that the deletion left empty. + * Files without frontmatter pass through untouched — most skill files are prose. + */ +export function dropFrontmatterKeys(content, dottedKeys) { + const normalized = content.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) return content; + const match = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(normalized); + if (!match) return content; + + const doc = parseDocument(match[1]); + let changed = false; + for (const dotted of dottedKeys) { + const path = dotted.split("."); + if (!doc.hasIn(path)) continue; + doc.deleteIn(path); + changed = true; + // A parent left with no keys is noise; remove it rather than emit `metadata: {}`. + if (path.length > 1) { + const parentPath = path.slice(0, -1); + const parent = doc.getIn(parentPath); + if (parent && typeof parent.items?.length === "number" && parent.items.length === 0) { + doc.deleteIn(parentPath); + } + } + } + if (!changed) return content; + + const body = normalized.slice(match[0].length); + // Match hand-written frontmatter conventions: no padding inside flow + // collections, no line wrapping. Without these the serializer rewrites + // `[claude, codex]` and long descriptions on every run, so a content-free + // sync would still report drift. + const yaml = doc.toString({ flowCollectionPadding: false, lineWidth: 0 }).replace(/\n$/, ""); + return `---\n${yaml}\n---\n${body}`; +} + +/** + * Force the frontmatter `name:` to the public directory name. The public repo + * validates that name and directory agree, so a renamed skill (`git-workflow` + * in the store, `gitworkflow` here) must be rewritten, not copied. + */ +export function rewriteSkillName(content, publicName) { + const normalized = content.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) return content; + const match = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(normalized); + if (!match) return content; + const rewritten = match[1].replace(/^name:[ \t]*.*$/m, `name: ${publicName}`); + if (rewritten === match[1]) return content; + return `---\n${rewritten}\n---\n${normalized.slice(match[0].length)}`; +} + +/** + * Merge the gitignored local overlay into the tracked manifest. + * + * The tracked file holds structural patterns only. Everything that names a + * private machine, repo, or domain lives in the overlay, because publishing + * that list would leak exactly what the list exists to protect. Overlay leak + * patterns are appended; overlay replacements run after the tracked ones. + */ +export function mergeLocalOverlay(manifest, overlay) { + if (!overlay) return manifest; + return { + ...manifest, + leakPatterns: [...(manifest.leakPatterns ?? []), ...(overlay.leakPatterns ?? [])], + transforms: { + ...manifest.transforms, + replace: [...(manifest.transforms?.replace ?? []), ...(overlay.transforms?.replace ?? [])], + }, + }; +} + +/** Apply declared literal replacements in manifest order. */ +export function applyReplacements(content, rules = []) { + return rules.reduce((acc, rule) => acc.split(rule.find).join(rule.replaceWith), content); +} + +/** Drop whole lines matching any of the given regex sources. */ +export function dropLines(content, patterns = []) { + if (patterns.length === 0) return content; + const regexes = patterns.map((source) => new RegExp(source)); + const kept = content.split("\n").filter((line) => !regexes.some((regex) => regex.test(line))); + return kept.join("\n"); +} + +/** + * Run the full transform chain for one text file. Per-skill rules run after the + * global ones so a skill can refine, not fight, the shared pass. + */ +export function transformText(content, { publicName, transforms, skillRules = {}, isSkillManifest }) { + let out = content; + if (isSkillManifest) { + out = dropFrontmatterKeys(out, transforms.dropFrontmatterKeys ?? []); + out = rewriteSkillName(out, publicName); + } + out = applyReplacements(out, transforms.replace ?? []); + out = applyReplacements(out, skillRules.replace ?? []); + return dropLines(out, skillRules.dropLines ?? []); +} + +/** + * Find published text that still points at a file the manifest excluded. + * + * This deliberately asks the narrow question rather than "does every path-shaped + * string resolve". Skills legitimately name paths in the repo they operate on + * (`scripts/test.sh`, `.github/workflows/ci.yml`), and treating those as bundle + * references produced far more noise than signal. What actually breaks a + * published skill is an exclusion that left a pointer behind, so that is what + * this checks. + * + * `excludedPaths` are source-relative paths that matched an `exclude` glob, and + * only those exact paths are searched for. Directory prefixes were tried and + * dropped: a skill that documents house conventions ("tools -> `tools/`") + * matches `tools/` without referring to the bundle at all. + */ +export function findExcludedRefs(content, excludedPaths) { + return [...new Set(excludedPaths)].filter((relPath) => content.includes(relPath)).sort(); +} + +/** + * Scan transformed content for private detail the transforms failed to remove. + * Returns one finding per matching line so the report can point at a location. + */ +export function findLeaks(content, patterns) { + const findings = []; + const lines = content.split("\n"); + for (const pattern of patterns) { + const regex = new RegExp(pattern.regex, "g"); + lines.forEach((line, index) => { + regex.lastIndex = 0; + const match = regex.exec(line); + if (!match) return; + findings.push({ patternId: pattern.id, line: index + 1, excerpt: match[0] }); + }); + } + return findings; +} + +// ---- filesystem ------------------------------------------------------------ + +const TEXT_DEFAULTS = [".md", ".txt"]; + +function isTextFile(relPath, extensions) { + return (extensions ?? TEXT_DEFAULTS).some((ext) => relPath.endsWith(ext)); +} + +/** Recursively list files under `dir` as paths relative to it. Missing dir -> []. */ +async function listFiles(dir, prefix = "") { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return []; + throw error; + } + const out = []; + for (const entry of entries) { + if (entry.name === ".DS_Store" || entry.name === "node_modules" || entry.name === ".git") continue; + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) out.push(...(await listFiles(join(dir, entry.name), relPath))); + else out.push(relPath); + } + return out.sort(); +} + +async function pathExists(target) { + try { + await stat(target); + return true; + } catch { + return false; + } +} + +/** + * Refuse to run when a store entry resolves back inside this repo. That is the + * old symlink arrangement, where source and destination are the same bytes and + * "syncing" would silently mean "no scrub ever happened". + */ +async function assertNotCircular(storeDir, repoRoot, publicName) { + const resolved = await realpath(storeDir); + const root = await realpath(repoRoot); + if (resolved === root || resolved.startsWith(root + sep)) { + throw new Error( + `${publicName}: store entry resolves inside this repo (${resolved}).\n` + + ` It is still a symlink into the public checkout, so no scrub can run.\n` + + ` Materialize it in the store first, then re-run.`, + ); + } +} + +/** Build the write plan for one mirrored skill without touching disk. */ +async function planSkill({ publicName, entry, storeRoot, publicSkillsDir, manifest, repoRoot }) { + const sourceName = entry.source ?? publicName; + const storeDir = join(storeRoot, sourceName); + const publicDir = join(publicSkillsDir, publicName); + + if (!(await pathExists(storeDir))) { + return { publicName, status: "missing-source", detail: storeDir, writes: [], orphans: [], leaks: [] }; + } + await assertNotCircular(storeDir, repoRoot, publicName); + + const sourceFiles = await listFiles(storeDir); + const writes = []; + const leaks = []; + + const excludedPaths = sourceFiles.filter((relPath) => matchesAnyGlob(relPath, entry.exclude)); + + for (const relPath of sourceFiles) { + if (matchesAnyGlob(relPath, entry.exclude)) continue; + if (matchesAnyGlob(relPath, entry.publicOwned)) continue; + + const absSource = join(storeDir, relPath); + const absTarget = join(publicDir, relPath); + + if (!isTextFile(relPath, manifest.textExtensions)) { + writes.push({ relPath, absTarget, content: await readFile(absSource), binary: true }); + continue; + } + + const raw = await readFile(absSource, "utf8"); + const content = transformText(raw, { + publicName, + transforms: manifest.transforms, + skillRules: entry, + isSkillManifest: relPath === "SKILL.md", + }); + + for (const finding of findLeaks(content, manifest.leakPatterns)) { + leaks.push({ file: `${publicName}/${relPath}`, ...finding }); + } + writes.push({ relPath, absTarget, content, binary: false }); + } + + const generated = new Set(writes.map((write) => write.relPath)); + const existing = await listFiles(publicDir); + const orphans = existing.filter( + (relPath) => + !generated.has(relPath) && !matchesAnyGlob(relPath, entry.publicOwned) && !matchesAnyGlob(relPath, entry.exclude), + ); + + const dangling = []; + for (const write of writes) { + if (write.binary) continue; + for (const ref of findExcludedRefs(write.content, excludedPaths)) { + dangling.push({ file: `${publicName}/${write.relPath}`, ref }); + } + } + + return { publicName, status: "planned", writes, orphans, leaks, dangling }; +} + +/** Compare a planned write against what is already on disk. */ +async function isDrifted(write) { + try { + const existing = await readFile(write.absTarget, write.binary ? undefined : "utf8"); + return write.binary ? !existing.equals(write.content) : existing !== write.content; + } catch (error) { + if (error?.code === "ENOENT") return true; + throw error; + } +} + +// ---- CLI ------------------------------------------------------------------- + +const GREEN = "✓"; +const RED = "✗"; +const DOT = "·"; + +async function main() { + const args = process.argv.slice(2); + const checkOnly = args.includes("--check"); + const prune = args.includes("--prune"); + const skillFilterIndex = args.indexOf("--skill"); + const skillFilter = skillFilterIndex === -1 ? null : args[skillFilterIndex + 1]; + + const repoRoot = process.cwd(); + const manifestPath = join(repoRoot, "public-manifest.json"); + const tracked = JSON.parse(await readFile(manifestPath, "utf8")); + + // Without the overlay the scrub is strictly weaker than intended, and a + // weaker scrub that runs silently is worse than one that refuses. + const overlayPath = join(repoRoot, tracked.localOverlay ?? "public-manifest.local.json"); + let overlay = null; + if (await pathExists(overlayPath)) { + overlay = JSON.parse(await readFile(overlayPath, "utf8")); + } else { + console.error(`Missing local overlay: ${overlayPath}`); + console.error("It carries the private-term leak patterns; without it the scrub only covers"); + console.error("structural patterns. Copy public-manifest.local.json.example and fill it in."); + process.exit(1); + } + const manifest = mergeLocalOverlay(tracked, overlay); + + const storeRoot = expandHome(process.env[manifest.storeRootEnv] ?? manifest.storeRoot); + const publicSkillsDir = join(repoRoot, manifest.publicSkillsDir); + + if (!(await pathExists(storeRoot))) { + console.log(`Skill store not found at ${storeRoot}.`); + console.log(`Set ${manifest.storeRootEnv} to point at it. Drift was NOT verified.`); + process.exit(checkOnly ? 0 : 1); + } + + const entries = Object.entries(manifest.skills).filter(([name]) => !skillFilter || name === skillFilter); + if (skillFilter && entries.length === 0) { + console.error(`Unknown skill '${skillFilter}'. Known: ${Object.keys(manifest.skills).join(", ")}`); + process.exit(1); + } + + const drifted = []; + const allLeaks = []; + const allOrphans = []; + const allDangling = []; + let missingSource = 0; + + for (const [publicName, entry] of entries) { + if (entry.mode === "public-owned" || entry.mode === "forked") { + console.log( + ` ${DOT} ${publicName.padEnd(20)} ${entry.mode}, skipped${entry.reason ? ` (${entry.reason})` : ""}`, + ); + continue; + } + if (entry.mode !== "mirror") { + console.error(` ${RED} ${publicName.padEnd(20)} unknown mode '${entry.mode}'`); + process.exit(1); + } + + const plan = await planSkill({ publicName, entry, storeRoot, publicSkillsDir, manifest, repoRoot }); + + if (plan.status === "missing-source") { + console.error(` ${RED} ${publicName.padEnd(20)} no source in store (${plan.detail})`); + missingSource += 1; + continue; + } + + if (plan.leaks.length > 0) { + allLeaks.push(...plan.leaks); + console.error(` ${RED} ${publicName.padEnd(20)} ${plan.leaks.length} leak finding(s)`); + continue; + } + + if (plan.dangling.length > 0) { + allDangling.push(...plan.dangling); + console.error(` ${RED} ${publicName.padEnd(20)} ${plan.dangling.length} dangling reference(s)`); + continue; + } + + const changed = []; + for (const write of plan.writes) if (await isDrifted(write)) changed.push(write); + + if (plan.orphans.length > 0) allOrphans.push(...plan.orphans.map((relPath) => ({ publicName, relPath }))); + + if (changed.length === 0 && plan.orphans.length === 0) { + console.log(` ${GREEN} ${publicName.padEnd(20)} up to date (${plan.writes.length} files)`); + continue; + } + + drifted.push(publicName); + const summary = [ + changed.length > 0 ? `${changed.length} changed` : null, + plan.orphans.length > 0 ? `${plan.orphans.length} orphaned` : null, + ] + .filter(Boolean) + .join(", "); + console.log(` ${RED} ${publicName.padEnd(20)} ${summary}`); + for (const write of changed) console.log(` ~ ${write.relPath}`); + for (const relPath of plan.orphans) console.log(` ? ${relPath} (only in public)`); + + if (checkOnly) continue; + + for (const write of changed) { + await mkdir(dirname(write.absTarget), { recursive: true }); + await writeFile(write.absTarget, write.content); + } + if (prune) { + for (const relPath of plan.orphans) { + await rm(join(publicSkillsDir, publicName, relPath)); + console.log(` - removed ${relPath}`); + } + } + } + + console.log(""); + + if (allLeaks.length > 0) { + console.error("Leak assertion failed. Nothing was written for the affected skills.\n"); + for (const leak of allLeaks) { + console.error(`- ${leak.file}:${leak.line} [${leak.patternId}] ${leak.excerpt}`); + } + console.error("\nFix the source, add a transform, or exclude the file in public-manifest.json."); + process.exit(1); + } + + if (allDangling.length > 0) { + console.error("Dangling references. Nothing was written for the affected skills.\n"); + for (const item of allDangling) + console.error(`- ${item.file} points at ${item.ref}, which the published skill does not ship`); + console.error("\nEither publish the target, or drop the reference with a per-skill dropLines/replace rule."); + process.exit(1); + } + + if (missingSource > 0) process.exit(1); + + if (drifted.length === 0) { + console.log("Public skills match the store."); + return; + } + + if (checkOnly) { + console.error(`${drifted.length} skill(s) drifted: ${drifted.join(", ")}`); + console.error("Run without --check to sync."); + process.exit(1); + } + + console.log(`Synced ${drifted.length} skill(s): ${drifted.join(", ")}`); + if (allOrphans.length > 0 && !prune) { + console.log(`${allOrphans.length} orphaned file(s) left in place. Re-run with --prune to delete them.`); + } +} + +// Only run the CLI when executed directly, so the test file can import helpers. +if (import.meta.main !== false && process.argv[1]?.endsWith("sync-public.mjs")) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/scripts/sync-public.test.mjs b/scripts/sync-public.test.mjs new file mode 100644 index 0000000..0f661d6 --- /dev/null +++ b/scripts/sync-public.test.mjs @@ -0,0 +1,341 @@ +import { test } from "bun:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { + applyReplacements, + dropFrontmatterKeys, + dropLines, + expandHome, + findExcludedRefs, + findLeaks, + globToRegExp, + matchesAnyGlob, + mergeLocalOverlay, + rewriteSkillName, + transformText, +} from "./sync-public.mjs"; + +const manifest = JSON.parse(await readFile(new URL("../public-manifest.json", import.meta.url), "utf8")); +const { transforms, leakPatterns } = manifest; + +// The tracked manifest carries structural patterns only. Private-term patterns +// live in the gitignored overlay, so they are exercised here through a synthetic +// overlay rather than by naming real machines in a public test file. +const SYNTHETIC_OVERLAY = { + leakPatterns: [ + { id: "private-machine-names", description: "example", regex: "\\b(?:example-host|example-box)\\b" }, + { id: "private-repo-names", description: "example", regex: "\\b(?:example-vault)\\b" }, + ], + transforms: { replace: [{ find: "example-vault", replaceWith: "a private repo" }] }, +}; + +// ---- glob matching --------------------------------------------------------- + +test("`**` crosses separators, `*` does not", () => { + assert.ok(globToRegExp("agents/**").test("agents/openai.yaml")); + assert.ok(globToRegExp("scripts/**").test("scripts/nested/deep.mjs")); + assert.ok(!globToRegExp("scripts/*").test("scripts/nested/deep.mjs")); + assert.ok(globToRegExp("scripts/*").test("scripts/run.mjs")); +}); + +test("literal dots in a glob do not act as regex wildcards", () => { + assert.ok(globToRegExp("protocols.md").test("protocols.md")); + assert.ok(!globToRegExp("protocols.md").test("protocolsXmd")); +}); + +test("matchesAnyGlob tolerates an absent glob list", () => { + assert.equal(matchesAnyGlob("SKILL.md", undefined), false); + assert.equal(matchesAnyGlob("SKILL.md", []), false); +}); + +// ---- home expansion -------------------------------------------------------- + +test("expandHome rewrites only a leading tilde segment", () => { + assert.equal(expandHome("~/store", "/home/x"), "/home/x/store"); + assert.equal(expandHome("~", "/home/x"), "/home/x"); + assert.equal(expandHome("/abs/path", "/home/x"), "/abs/path"); + assert.equal(expandHome("./rel/~/path", "/home/x"), "./rel/~/path"); +}); + +// ---- frontmatter surgery --------------------------------------------------- + +const STORE_SKILL = `--- +name: find-docs +description: Fetch current docs. USE WHEN the user asks about a library. +metadata: + category: engineering + lanes: [claude, codex, pi] + machines: [example-host] + requires: [ctx7] +--- + +# Documentation Lookup + +Body text. +`; + +test("dropFrontmatterKeys removes only the declared keys", () => { + const out = dropFrontmatterKeys(STORE_SKILL, transforms.dropFrontmatterKeys); + assert.ok(!out.includes("machines")); + assert.ok(!out.includes("example-host")); + assert.ok(!out.includes("requires")); + assert.ok(out.includes("category: engineering")); + assert.ok(out.includes("lanes:")); + assert.ok(out.includes("# Documentation Lookup")); + assert.ok(out.includes("Body text.")); +}); + +test("an emptied parent mapping is removed rather than left as `metadata: {}`", () => { + const input = `--- +name: x +metadata: + machines: [example-host] + requires: [ctx7] +--- + +Body. +`; + const out = dropFrontmatterKeys(input, transforms.dropFrontmatterKeys); + assert.ok(!out.includes("metadata")); + assert.ok(out.includes("name: x")); + assert.ok(out.includes("Body.")); +}); + +test("files without frontmatter pass through byte-identical", () => { + const prose = "# A reference file\n\nNo frontmatter here.\n"; + assert.equal(dropFrontmatterKeys(prose, transforms.dropFrontmatterKeys), prose); + assert.equal(rewriteSkillName(prose, "anything"), prose); +}); + +test("a `---` inside the body does not truncate the frontmatter block", () => { + const input = `--- +name: x +metadata: + machines: [example-host] +--- + +Body with a rule: + +--- + +More body. +`; + const out = dropFrontmatterKeys(input, transforms.dropFrontmatterKeys); + assert.ok(out.includes("More body.")); + assert.ok(!out.includes("example-host")); +}); + +test("rewriteSkillName forces the public directory name", () => { + const out = rewriteSkillName("---\nname: git-workflow\ndescription: d\n---\n\nBody.\n", "gitworkflow"); + assert.ok(out.includes("name: gitworkflow")); + assert.ok(!out.includes("name: git-workflow")); + assert.ok(out.includes("Body.")); +}); + +// ---- replacements ---------------------------------------------------------- + +test("declared replacements run in manifest order", () => { + const out = applyReplacements("Run ~/.claude/skills and ask Ossie about Ossie's setup.", transforms.replace); + assert.equal(out, "Run $AGENT_HOME/skills and ask the user about the user's setup."); +}); + +// ---- leak assertion -------------------------------------------------------- + +test("structural leak patterns catch what the transforms miss", () => { + const cases = [ + ["/Users/someone/Projects/x", "macos-home-path"], + ["curl http://192.168.1.44:8080", "rfc1918-ipv4"], + ]; + for (const [content, expectedId] of cases) { + const findings = findLeaks(content, leakPatterns); + assert.ok(findings.length > 0, `expected a finding for: ${content}`); + assert.ok( + findings.some((finding) => finding.patternId === expectedId), + `expected ${expectedId} for: ${content}, got ${findings.map((f) => f.patternId).join(",")}`, + ); + } +}); + +test("the tracked manifest names no private machines, repos, or domains", () => { + // The whole point of the overlay split: this file ships publicly, so an + // enumeration of private terms must not survive in it. + const raw = JSON.stringify(manifest); + for (const id of ["private-machine-names", "private-repo-names", "private-email", "private-framework-paths"]) { + assert.ok(!raw.includes(id), `${id} belongs in the gitignored overlay, not the tracked manifest`); + } + assert.ok(manifest.localOverlay, "the tracked manifest must point at its overlay file"); +}); + +test("overlay leak patterns are appended and overlay replacements run last", () => { + const merged = mergeLocalOverlay(manifest, SYNTHETIC_OVERLAY); + assert.deepEqual( + merged.leakPatterns.map((p) => p.id), + [...leakPatterns.map((p) => p.id), "private-machine-names", "private-repo-names"], + ); + assert.deepEqual( + merged.transforms.replace.map((r) => r.find), + [...transforms.replace.map((r) => r.find), "example-vault"], + ); + // Structural patterns survive the merge. + assert.ok(findLeaks("/Users/someone/x", merged.leakPatterns).length > 0); +}); + +test("merged private-term patterns fire on overlay terms", () => { + const merged = mergeLocalOverlay(manifest, SYNTHETIC_OVERLAY); + for (const [content, expectedId] of [ + ["ssh me@example-host", "private-machine-names"], + ["see the example-vault repo", "private-repo-names"], + ]) { + assert.ok( + findLeaks(content, merged.leakPatterns).some((f) => f.patternId === expectedId), + `expected ${expectedId} for: ${content}`, + ); + } +}); + +test("mergeLocalOverlay with no overlay returns the manifest unchanged", () => { + assert.equal(mergeLocalOverlay(manifest, null), manifest); +}); + +test("documented placeholder home paths are not flagged", () => { + assert.deepEqual(findLeaks("/Users/you/Projects/demo", leakPatterns), []); + assert.deepEqual(findLeaks("/Users/username/skills", leakPatterns), []); +}); + +test("bare ~/.claude is normalized, not gated", () => { + // It is the documented Claude Code config directory, not private. Gating it + // would fire on every harness-path mention and train hook bypasses. The + // transform still rewrites it for portability. + assert.deepEqual(findLeaks("cd ~/.claude/skills", leakPatterns), []); + assert.equal(applyReplacements("cd ~/.claude/skills", transforms.replace), "cd $AGENT_HOME/skills"); +}); + +test("semver strings are not mistaken for private addresses", () => { + // Regression: a three-part 10/8 branch matched every "10.0.2" in a lockfile, + // which is how this gate first fired on diataxis-docs-site's package-lock. + for (const version of ['"version": "10.0.2"', "node@10.8.2", "^192.168.0", "172.16.4"]) { + assert.deepEqual(findLeaks(version, leakPatterns), [], `flagged: ${version}`); + } + for (const address of ["10.0.2.15", "192.168.1.44", "172.16.4.9"]) { + assert.ok( + findLeaks(address, leakPatterns).some((f) => f.patternId === "rfc1918-ipv4"), + `missed: ${address}`, + ); + } +}); + +test("leak findings carry a line number for the report", () => { + const merged = mergeLocalOverlay(manifest, SYNTHETIC_OVERLAY); + const [finding] = findLeaks("clean line\nanother\nssh example-host\n", merged.leakPatterns); + assert.equal(finding.line, 3); + assert.equal(finding.patternId, "private-machine-names"); + assert.equal(finding.excerpt, "example-host"); +}); + +// ---- golden: the whole chain ---------------------------------------------- + +test("golden — a store SKILL.md transforms into publishable output", () => { + const out = transformText(STORE_SKILL, { publicName: "find-docs", transforms, isSkillManifest: true }); + assert.equal( + out, + `--- +name: find-docs +description: Fetch current docs. USE WHEN the user asks about a library. +metadata: + category: engineering + lanes: [claude, codex, pi] +--- + +# Documentation Lookup + +Body text. +`, + ); + assert.deepEqual(findLeaks(out, leakPatterns), []); +}); + +test("golden — a renamed skill keeps its body and gains the public name", () => { + const input = `--- +name: git-workflow +description: Commit and branch. USE WHEN Ossie asks to commit. +metadata: + machines: [example-host] +--- + +Templates live in ~/.claude/skills/git-workflow. +`; + const out = transformText(input, { publicName: "gitworkflow", transforms, isSkillManifest: true }); + assert.equal( + out, + `--- +name: gitworkflow +description: Commit and branch. USE WHEN the user asks to commit. +--- + +Templates live in $AGENT_HOME/skills/git-workflow. +`, + ); + assert.deepEqual(findLeaks(out, leakPatterns), []); +}); + +test("non-SKILL.md files skip frontmatter surgery but still get replacements", () => { + const merged = mergeLocalOverlay(manifest, SYNTHETIC_OVERLAY); + const input = "---\nname: not-a-skill\nmetadata:\n machines: [example-host]\n---\n\nAsk Ossie.\n"; + const out = transformText(input, { publicName: "find-docs", transforms, isSkillManifest: false }); + assert.ok(out.includes("machines: [example-host]"), "reference files keep their own frontmatter"); + assert.ok(out.includes("Ask the user.")); + // The leak gate is what stops this from shipping, not the transform. + assert.ok(findLeaks(out, merged.leakPatterns).some((f) => f.patternId === "private-machine-names")); +}); + +// ---- exclusion bookkeeping ------------------------------------------------- + +test("dropLines removes matching lines and keeps the rest", () => { + const table = [ + "| **Commit** | c | workflows/Commit.md |", + "| **SetIdentity** | i | workflows/SetIdentity.md |", + "| **Release** | r | workflows/Release.md |", + ].join("\n"); + const out = dropLines(table, ["\\|\\s*\\*\\*SetIdentity\\*\\*\\s*\\|"]); + assert.ok(!out.includes("SetIdentity")); + assert.ok(out.includes("**Commit**")); + assert.ok(out.includes("**Release**")); +}); + +test("a reference to an excluded file is reported", () => { + const content = "See `workflows/SetIdentity.md` for identity setup."; + assert.deepEqual(findExcludedRefs(content, ["workflows/SetIdentity.md"]), ["workflows/SetIdentity.md"]); +}); + +test("excluding one file does not flag its siblings", () => { + // Regression: deriving the parent directory as a needle made every + // `workflows/...` mention in the skill look like a dangling reference. + const content = "See `workflows/Commit.md` and `workflows/Release.md`."; + assert.deepEqual(findExcludedRefs(content, ["workflows/SetIdentity.md"]), []); +}); + +test("house-convention prose is not mistaken for a bundle reference", () => { + // Regression: `tools/**` as a directory needle flagged "tools -> `tools/`", + // which describes the audited repo's layout, not this skill's files. + const content = "House path conventions: docs -> `docs/`; tools -> `tools/`."; + assert.deepEqual(findExcludedRefs(content, ["tools/changelog/changelog"]), []); +}); + +// ---- manifest integrity ---------------------------------------------------- + +test("every manifest skill declares a known mode, and forks explain themselves", () => { + const modes = new Set(["mirror", "forked", "public-owned"]); + for (const [name, entry] of Object.entries(manifest.skills)) { + assert.ok(modes.has(entry.mode), `${name}: unknown mode '${entry.mode}'`); + if (entry.mode !== "mirror") { + assert.ok(entry.reason, `${name}: '${entry.mode}' entries must record a reason`); + } + } +}); + +test("every leak pattern compiles and carries an id", () => { + for (const pattern of leakPatterns) { + assert.ok(pattern.id, "leak pattern is missing an id"); + assert.doesNotThrow(() => new RegExp(pattern.regex), `${pattern.id}: regex does not compile`); + } +}); diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index f396283..7769db4 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -1,104 +1,75 @@ --- name: adversarial-review -description: Deep implementation review that hunts for real bugs. Use when Ossie asks for adversarial review, implementation audit, ship-readiness review, stress test this, review this against the spec, or find problems with this code. This skill is for implemented code, configs, scripts, and pipelines. +description: Deep implementation review that hunts for real bugs by sending a heavyweight external reviewer (the codex frontier model or Opus) actual file contents, not summaries. Covers implementations only (code, configs, scripts, pipelines). USE WHEN adversarial review, review this implementation, audit my code, stress test this, find problems with this, ship-readiness review, is this ready to ship, check this against the plan. NOT FOR plans and designs before implementation exists (use RedTeam for adversarial plan critique, or grilling for a collaborative interview). +metadata: + author: ossie + category: engineering + lanes: [claude, codex, pi] --- # Adversarial Review -Use Pi's `adversarial_review` tool to run a second-pass, read-only implementation audit with a separate reviewer model inside Pi. +Sends a deeply structured adversarial prompt to a heavyweight reviewer (the codex frontier model or Opus 4.7) to catch real bugs before they hit production. The reviewer reads actual file contents, not summaries, and returns a trinary verdict with file:line citations and a prioritized fix list. -This is for **implementations**. - -- Use `the-fool` for challenging plans before code exists. -- Use `grill-me` when the user wants questioning and design pressure-testing. -- Use normal inline review only for light checks. -- Use this skill when the user wants the toughest audit before shipping. +Distinct from RedTeam (which adversarially challenges plans and ideas with parallel expert attackers) and grilling (which interviews the user about designs). This skill reviews working or near-complete code against its own specification. ## Workflow -### 1. Gather only what is missing - -Figure out: - -1. **Target directory** - - Default to the current working directory when the user is clearly referring to the current repo. - - Ask only if the target is unclear. -2. **Plan or spec file** - - Use it if the user gave one. - - If none exists, review for internal consistency and production-readiness. -3. **Reviewer model** - - Prefer a reviewer model different from the current one when possible. - - Let the tool pick the best available default if the user does not care. -4. **Review areas** - - If the user gives focused areas, pass them through. - - Otherwise use the default 8 areas from `references/prompt-template.md`. - -### 2. Run the Pi review tool first - -Call `adversarial_review` with: +### Step 1 — Gather inputs -- `targetDir` -- optional `planFile` -- optional `reviewerModel` -- optional `reviewAreas` +Use `AskUserQuestion` with these four questions in a single prompt: -The tool normally enforces the right shape: +1. **Target directory** — What directory should the reviewer work in? (e.g., `~/Projects/arbol/v3`) +2. **Plan or spec file** — Path to the spec, plan, or design doc to review against. If none, the reviewer will assess internal consistency instead. +3. **Reviewer** — Which model? Options: + - the codex frontier model (recommended — deepest code reasoning, runs locally) + - `opus 4.7` (alternative — best for architecture-level concerns) +4. **Review areas** — Provide 4–10 numbered areas to focus on (e.g., "1. Cron scheduling, 2. File path assumptions, 3. Error handling"). If unspecified, use the 8 default areas from the prompt template. -- separate reviewer model inside Pi -- read-only tools only -- no file edits -- required file:line citations for non-PASS findings +### Step 2 — Build the prompt -### 2b. Claude Code fallback / explicit Claude request - -If the user specifically asks to "ask Claude", "ask Claude Code", "use Opus", or if the Pi `adversarial_review` tool returns no grounded report, use Claude Code directly instead of treating the failed tool call as a valid review. - -Build the filled prompt from `references/prompt-template.md`, then run Claude Code headlessly from the target directory with read-only tools: - -```bash -claude --print --model opus --effort high \ - --add-dir "{{TARGET_DIR}}" \ - --add-dir "$(dirname "{{PLAN_FILE}}")" \ - --tools "Read,Grep,Glob,Bash" \ - --disallowedTools "Edit,Write,MultiEdit,NotebookEdit" \ - "{{FILLED_PROMPT}}" -``` +Load `references/prompt-template.md` and fill in: +- `{{TARGET_DIR}}` — from input 1 +- `{{PLAN_FILE}}` — from input 2 (or "no plan file — review for internal consistency") +- `{{REVIEW_AREAS}}` — from input 4 (or the 8 defaults in the template) -When intentionally delegating to the Claude-side adversarial-review skill from `~/.agents/skills/adversarial-review`, use its headless form: +### Step 3 — Invoke the reviewer +**For the codex frontier model (default):** the model comes from `~/.codex/config.toml` (the single authority; never pinned here; see `ask-codex`). ```bash -claude --print --model opus --effort high \ - --add-dir "{{TARGET_DIR}}" \ - --add-dir "$(dirname "{{PLAN_FILE}}")" \ - --tools "Read,Grep,Glob,Bash" \ - --disallowedTools "Edit,Write,MultiEdit,NotebookEdit" \ - "Run /adversarial-review headlessly with: target={{TARGET_DIR}}, plan={{PLAN_FILE_OR_NONE}}, reviewer=opus 4.7, areas=[{{REVIEW_AREAS}}]" +codex exec --skip-git-repo-check \ + --config model_reasoning_effort="high" \ + --sandbox read-only \ + -C {{TARGET_DIR}} \ + "{{FILLED_PROMPT}}" 2>/dev/null ``` -Notes: +**For Opus 4.7:** +Spawn a subagent with model `opus` and pass the filled prompt directly. -- Use `--dangerously-skip-permissions` only inside disposable/sandboxed worktrees when the user explicitly wants that mode; prefer tool allow/deny lists first. -- If `{{PLAN_FILE}}` is omitted, omit the second `--add-dir` and pass `plan=none`. -- The Claude result is valid only if it contains file:line citations for non-PASS findings. If Claude cannot read files or returns an environment/tooling failure, report that as a failed review, not as implementation findings. +### Step 4 — Parse and present output -### 3. Present the result cleanly +Extract from the reviewer's response: +- **Overall verdict**: ship / fix-before-ship / significant-rework +- **Per-area verdicts**: PASS / NEEDS-FIX / BROKEN for each numbered area +- **Prioritized fix list**: P0 (blocks launch) → P1 (reliability) → P2 (polish) -Always present: +Present as a clean summary with the fix list ordered by priority. -- **Overall verdict** — ship / fix-before-ship / significant-rework -- **Per-area verdicts** — PASS / NEEDS-FIX / BROKEN -- **Prioritized fixes** — P0, then P1, then P2 +### Step 5 — Offer P0 handoff -Do not flatten or soften the review. Keep the citations. +If there are any P0 items, ask: "Should I hand these P0 items to an Engineer agent for immediate fixes?" +If yes, spawn an Engineer subagent with the P0 list and the target directory. -### 4. If P0s exist, switch to execution mode +## Reference -If the review surfaces P0 items, ask whether to fix them immediately. -If yes, move straight into implementation work on those items. +| Topic | File | +|-------|------| +| Full adversarial prompt template | `references/prompt-template.md` | ## Constraints -- Never use write/edit/bash for this review pass unless the user explicitly changes the task from review to implementation. -- Never summarize non-PASS findings without at least one file:line citation per finding. -- Keep the review adversarial and truth-seeking, not encouraging. -- This skill audits implementations, not ideas. +- Always use `--sandbox read-only` — this skill reads, never writes +- Always suppress stderr with `2>/dev/null` unless the user asks for thinking tokens +- Never summarize findings without showing at least one file:line citation per finding +- After the review, offer `codex resume` if using the codex frontier model diff --git a/skills/adversarial-review/references/prompt-template.md b/skills/adversarial-review/references/prompt-template.md index abf1d26..db05b12 100644 --- a/skills/adversarial-review/references/prompt-template.md +++ b/skills/adversarial-review/references/prompt-template.md @@ -6,7 +6,7 @@ Fill in all `{{PLACEHOLDERS}}` before sending to the reviewer. You are performing an adversarial implementation review. Your job is to find real problems, not validate the work. -BE ADVERSARIAL. Ossie explicitly asked you to find problems. Your value here is truthfulness, not encouragement. +BE ADVERSARIAL. the user explicitly asked you to find problems. Your value here is truthfulness, not encouragement. ## What you are reviewing @@ -36,10 +36,10 @@ Default areas (use if none specified): 2. **Control flow and logic** — Are conditionals correct? Are there off-by-one errors, incorrect comparisons, inverted boolean logic? 3. **Error handling** — Are all error paths handled? Are exceptions caught or propagated correctly? Are partial failure states recoverable? 4. **External dependencies** — Are env vars validated at startup? Are file paths correct and not machine-specific? Are shell commands safe from injection? -5. **Scheduling and timing** — Is timing behavior correct? Are timezone assumptions explicit? Are there race conditions between scheduled jobs or async work? +5. **Scheduling and timing** — Is cron syntax correct and tested? Are timezone assumptions explicit (CT vs UTC)? Are there race conditions between scheduled jobs? 6. **Idempotency and state** — Can operations run more than once safely? Are there missing deduplication guards? Can partial runs leave corrupted state? -7. **Data parsing and serialization** — Are parsing failures handled? Are schema assumptions validated? -8. **Session and path assumptions** — Do file paths work across machines? Are session and cwd assumptions explicit and stable? +7. **Data parsing and serialization** — Are JSON parse errors handled? Is frontmatter parsing resilient to typos? Are schema assumptions validated? +8. **Session and path assumptions** — Do file paths work across machines? Are session file locations hardcoded? Are PATH assumptions explicit? ## Bug classes to hunt @@ -49,13 +49,14 @@ Look specifically for these — they are the most common sources of silent failu - Unhandled exceptions that swallow errors silently - Race conditions between async operations or scheduled jobs - Missing idempotency guards on operations that repeat -- Incorrect cron syntax or timing assumptions +- Incorrect cron syntax (fields out of order, wrong timezone field) +- Frontmatter typos that pass parsing but produce wrong values - Path assumptions that break on a different machine or user home - Missing env var handling (crash on undefined vs. graceful fallback) - Shell injection in subprocess calls (unquoted variables, user input in shell strings) - JSON parse errors from untrimmed whitespace, trailing commas, encoding issues -- Timezone bugs — code uses local time where UTC is expected or vice versa -- PATH assumptions — hardcoded binary paths that break in non-login shells +- Timezone bugs — code uses local time (CT) where UTC is expected or vice versa +- PATH assumptions — hardcoded binary paths that break in cron or non-login shells - Session file location assumptions — files written to cwd instead of stable paths ## Required output format @@ -70,7 +71,7 @@ Justify in 2-3 sentences. For each numbered review area: -```text +``` [N. Area Name] — PASS | NEEDS-FIX | BROKEN Finding: Evidence: : @@ -93,3 +94,7 @@ List every non-PASS finding in priority order: - [ ] — `:` If a priority level has no items, omit that section. + +--- + +*End of prompt template. Fill all `{{PLACEHOLDERS}}` before sending.* diff --git a/skills/deep-dive/SKILL.md b/skills/deep-dive/SKILL.md index 99061e4..65fc623 100644 --- a/skills/deep-dive/SKILL.md +++ b/skills/deep-dive/SKILL.md @@ -1,14 +1,11 @@ --- +disable-model-invocation: true name: deep-dive -description: > - Structured deep-dive analysis and investigation for any technical, operational, or strategic topic. - Use this skill whenever the user wants a thorough, opinionated breakdown of how to manage, implement, - audit, or fix something — not just a quick answer. Trigger phrases include: "deep dive into...", - "break this down for me", "audit my approach to...", "give me a thorough breakdown of...", - "how should I manage X", "create a policy for...", "expert rundown on...", "investigate X", - "what's the best practice for X", or any request that implies the user wants structured, - actionable, comprehensive guidance rather than a surface-level response. - Do NOT trigger for simple factual questions, quick how-tos, or code-only requests. +description: Structured, opinionated deep-dive analysis and investigation for any technical, operational, or strategic topic, delivering one clear recommendation instead of a menu of options. USE WHEN deep dive into, break this down for me, audit my approach to, thorough breakdown of, how should I manage X, create a policy for, expert rundown on, investigate X, best practice for X. NOT FOR simple factual questions, quick how-tos, or code-only requests. +metadata: + category: research + lanes: [claude, codex, pi] + author: ossie --- # Deep Dive Skill diff --git a/skills/diataxis-docs-site/SKILL.md b/skills/diataxis-docs-site/SKILL.md index c9450b2..7a49719 100644 --- a/skills/diataxis-docs-site/SKILL.md +++ b/skills/diataxis-docs-site/SKILL.md @@ -2,6 +2,9 @@ name: diataxis-docs-site description: Build, convert, and maintain source-backed Diátaxis sites with the fixed AOJ Astro Starlight starter. Use when a repository, Wiki, or docs collection must become a consistently branded site organized into tutorials, how-to guides, reference, and explanation, including publication and ongoing documentation-impact checks. disable-model-invocation: false +metadata: + category: docs + lanes: [claude, codex] --- # Diátaxis Docs Site diff --git a/skills/find-docs/SKILL.md b/skills/find-docs/SKILL.md index d614f1f..70ede2e 100644 --- a/skills/find-docs/SKILL.md +++ b/skills/find-docs/SKILL.md @@ -1,15 +1,14 @@ --- name: find-docs -description: "Retrieves authoritative, up-to-date technical documentation, API references, configuration details, and code examples for any developer technology. Use this skill whenever answering technical questions or writing code that interacts with external technologies. This includes libraries, frameworks, programming languages, SDKs, APIs, CLI tools, cloud services, infrastructure tools, and developer platforms. Common scenarios: looking up API endpoints, classes, functions, or method parameters, checking configuration options or CLI commands, answering how do I technical questions, generating code that uses a specific library or service, debugging issues related to frameworks, SDKs, or APIs, retrieving setup instructions, examples, or migration guides, verifying version-specific behavior or breaking changes." +description: Fetch current, version-accurate documentation, API references, and code examples for any developer library, framework, SDK, CLI tool, or cloud service via the ctx7 CLI, instead of relying on training data that may be stale. USE WHEN the user asks about a specific library/framework/SDK/CLI tool/cloud service (even well-known ones like React, Next.js, Prisma, Django), API syntax, configuration options, version migration, library-specific debugging, or setup instructions. Prefer over web search for library documentation. NOT FOR an open question with no named library (use research). +metadata: + category: engineering + lanes: [claude, codex, pi] --- -## Prefer this skill over training memory when correctness, recency, or an exact - - parameter or endpoint name matters. - # Documentation Lookup -Retrieve current documentation and code examples for any library using the Context7 CLI. +Retrieve current documentation and code examples for any library using the globally-installed `ctx7` CLI (never `npx` — the global install stays current and avoids per-call fetch overhead). Make sure the CLI is up to date before running commands: @@ -17,12 +16,6 @@ Make sure the CLI is up to date before running commands: npm install -g ctx7@latest ``` -Or run directly without installing: - -```bash -npx ctx7@latest -``` - ## Workflow Two-step process: resolve the library name to an ID, then query docs with that ID. @@ -39,6 +32,8 @@ You MUST call `ctx7 library` first to obtain a valid library ID UNLESS the user IMPORTANT: Do not run these commands more than 3 times per question. If you cannot find what you need after 3 attempts, use the best result you have. +Run Context7 CLI requests outside Codex's default sandbox. If a Context7 CLI command fails with DNS or network errors such as ENOTFOUND, host resolution failures, or fetch failed, rerun it outside the sandbox instead of retrying inside the sandbox. + ## Step 1: Resolve a Library Resolves a package/product name to a Context7-compatible library ID and returns matching libraries. @@ -67,11 +62,11 @@ Each result includes: 1. Analyze the query to understand what library/package the user is looking for 2. Select the most relevant match based on: - - Name similarity to the query (exact matches prioritized) - - Description relevance to the query's intent - - Documentation coverage (prioritize libraries with higher Code Snippet counts) - - Source reputation (consider libraries with High or Medium reputation more authoritative) - - Benchmark score (higher is better, 100 is the maximum) + - Name similarity to the query (exact matches prioritized) + - Description relevance to the query's intent + - Documentation coverage (prioritize libraries with higher Code Snippet counts) + - Source reputation (consider libraries with High or Medium reputation more authoritative) + - Benchmark score (higher is better, 100 is the maximum) 3. If multiple good matches exist, acknowledge this but proceed with the most relevant one 4. If no good matches exist, clearly state this and suggest query refinements 5. For ambiguous queries, request clarification before proceeding with a best-guess match @@ -104,14 +99,12 @@ ctx7 docs /prisma/prisma "How to define one-to-many relations with cascade delet The query directly affects the quality of results. Be specific and include relevant details. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query. - -| Quality | Example | -| ------- | ---------------------------------------------------------- | -| Good | `"How to set up authentication with JWT in Express.js"` | -| Good | `"React useEffect cleanup function with async operations"` | -| Bad | `"auth"` | -| Bad | `"hooks"` | - +| Quality | Example | +|---------|---------| +| Good | `"How to set up authentication with JWT in Express.js"` | +| Good | `"React useEffect cleanup function with async operations"` | +| Bad | `"auth"` | +| Bad | `"hooks"` | Use the user's full question as the query when possible, vague one-word queries return generic results. @@ -132,7 +125,6 @@ ctx7 login ## Error Handling If a command fails with a quota error ("Monthly quota reached" or "quota exceeded"): - 1. Inform the user their Context7 quota is exhausted 2. Suggest they authenticate for higher limits: `ctx7 login` 3. If they cannot or choose not to authenticate, answer from training knowledge and clearly note it may be outdated @@ -145,4 +137,4 @@ Do not silently fall back to training data — always tell the user why Context7 - Always run `ctx7 library` first — `ctx7 docs react "hooks"` will fail without a valid ID - Use descriptive queries, not single words — `"React useEffect cleanup function"` not `"hooks"` - Do not include sensitive information (API keys, passwords, credentials) in queries - +- Sandboxed environments: `ENOTFOUND` / `fetch failed` inside a sandbox is a sandbox failure, not a CLI failure — run outside the sandbox rather than retrying inside it diff --git a/skills/github-wiki/SKILL.md b/skills/github-wiki/SKILL.md index dfb7716..84fe228 100644 --- a/skills/github-wiki/SKILL.md +++ b/skills/github-wiki/SKILL.md @@ -1,6 +1,9 @@ --- name: github-wiki description: Build and maintain a canonical, source-backed GitHub Wiki. Use when a project needs a new Wiki, a complete Wiki refresh, rendered-page verification, or an event-driven process that keeps Wiki content aligned with code, issues, and releases. +metadata: + category: docs + lanes: [claude, codex] --- # GitHub Wiki diff --git a/skills/gitworkflow/SKILL.md b/skills/gitworkflow/SKILL.md index 4ad3b78..e650a0b 100755 --- a/skills/gitworkflow/SKILL.md +++ b/skills/gitworkflow/SKILL.md @@ -1,291 +1,69 @@ --- name: gitworkflow -emoji: 🌿 -description: "Smart Git workflow — Git Flow branching, CI monitoring, auto-merge, submodule awareness, issue analysis/routing, and deploy-workflow isolation. USE WHEN commit, branch, pull request, monitor CI, merge PR, create release, submodules, CodeRabbit, push and merge, --issue-analysis, analyze issues, route issues across worktrees, deploy workflow." -when_to_use: "USE WHEN commit, branch, pull request, monitor CI, merge PR, create release, submodules, CodeRabbit, push and merge, --issue-analysis, analyze issues, label issues, route issues across worktrees, plan beta cut, plan mvp cut, who should work on what, agent ownership labels, deploy workflow." -category: development -triggers: - - commit - - git - - branch - - pull request - - release - - merge - - submodule - - CI - - checks - - push and merge - - monitor CI - - auto-merge - - wait for CI - - is CI passing - - merge my PR - - submit PR - - code review - - --issue-analysis - - analyze issues - - label issues - - route issues across worktrees - - plan beta cut - - plan mvp cut - - who should work on what - - agent ownership labels - - deploy workflow - - push workflow to main - - merge workflow only - - deploy gh action +description: Smart Git workflow engine — hook-aware commits, Git Flow branching, CI monitoring and auto-merge, changelog automation, submodule handling. USE WHEN committing, branching (feature/release/hotfix), cutting a release, opening or submitting a PR, merging or monitoring CI, setting up CI, auditing Dependabot alerts, or managing submodules. +metadata: + category: git/pr + lanes: [claude, codex, pi] + author: ossie context: fork --- -# GitWorkflow +# git-workflow -Smart Git workflow engine with submodule detection, hook-aware commit strategies, **repo-aware branch targeting**, **CI monitoring & auto-merge**, **issue analysis/routing**, **deploy-workflow isolation**, and **changelog awareness**. +Smart Git workflow engine: hook-aware commits, Git Flow branching, CI monitoring and auto-merge, changelog awareness, submodule handling. -## Changelog Tool (GATED) +## By Default -Every commit and release workflow uses the `changelog` CLI tool (`~/.local/bin/changelog`) for repo awareness. +Invoking this skill makes a repo match a sane, low-friction workflow without being asked. Every **Commit**: -```bash -changelog --auto --dry-run # Preview unreleased changes (used after every commit) -changelog VERSION --auto # Generate CHANGELOG.md entry (used in releases) -changelog VERSION --auto --force # Non-interactive changelog generation -``` - -**Source:** `~/Projects/desktop-commander/scripts/changelog/` - -This is **mandatory** — the user needs visibility into what's changing across repos. There is no skip flag. A gate with an escape hatch isn't a gate. - -## Repository Adaptation Rules +- **Auto-updates the CHANGELOG** (mandatory, no skip flag — a gate with an escape hatch isn't a gate): regenerates the `Unreleased` section in place, idempotently, via the bundled changelog tool. +- **Surfaces open Dependabot alerts** (never blocks — one-line reason and continue when unavailable). +- **Checks CI + local hooks exist** (never blocks — nudges toward CISetup when absent; runs `lefthook install` when configured-but-uninstalled). -Before running Branch, PullRequest, CIMerge, or Release workflows, detect the repo shape: +## Changelog tool (tool-contract) ```bash -DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name' 2>/dev/null || git remote show origin | sed -n '/HEAD branch/s/.*: //p') -if git show-ref --verify --quiet refs/heads/develop || git ls-remote --exit-code --heads origin develop >/dev/null 2>&1; then - INTEGRATION_BRANCH=develop -else - INTEGRATION_BRANCH="$DEFAULT_BRANCH" -fi +changelog --unreleased --force # rewrite the Unreleased section (Commit does this every run) +changelog VERSION --auto --dry-run # preview a release cut +changelog VERSION --auto --force # write the release section ``` -Rules: +Install the CLI from `AojdevStudio/agentic-utilities#subdirectory=changelog` via `uv tool install`. Release semantics: cutting a release REPLACES the Unreleased section from commits — hand-written notes are previewed, then discarded. Gotcha (2026-07-30): `uv tool install --from ` silently reuses a cached build when the version string is unchanged; pass `--reinstall` when re-verifying local source changes. -- If `develop` exists, use Git Flow normally. -- If `develop` does **not** exist, treat the repo `defaultBranch` as the integration branch too. -- Feature branches PR into `INTEGRATION_BRANCH`. -- Release and hotfix branches PR into `DEFAULT_BRANCH`. -- If the repo has `.github/PULL_REQUEST_TEMPLATE.md`, use it as the starting shape for the PR body. -- If the repo has an issue-link check or contributing docs that require issue closure keywords, the PR body must contain a **real** closing reference like `Closes #123`, `Fixes #123`, or `Resolves #123`, or explicitly mark `No issue required` when the repo allows that path. -- Never leave placeholders like `Closes #`, `#ISSUE_NUM`, or a bare `#123` in prose and assume GitHub will auto-close the issue. +## Workflow routing -This matters for repos like Keepfolio, which now fail PR checks unless the PR body contains a valid closing keyword or an explicit `No issue required` marker. - -## Workflow Routing - -**When executing a workflow, output this notification directly:** - -``` -Running the **WorkflowName** workflow from the **GitWorkflow** skill... -``` +Announce `Running the **** workflow from the **git-workflow** skill...`, then follow the file: | Workflow | Trigger | File | |----------|---------|------| -| **Commit** | "commit", "commit changes", "make a commit" | `workflows/Commit.md` | -| **Branch** | "create branch", "start feature", "finish branch" | `workflows/Branch.md` | -| **Release** | "create release", "bump version", "tag release" | `workflows/Release.md` | -| **PullRequest** | "create PR", "open pull request", "submit PR" | `workflows/PullRequest.md` | -| **CIMerge** | "merge PR", "check CI", "wait for checks", "is CI passing", "monitor CI", "auto-merge" | `workflows/CIMerge.md` | -| **Submodule** | "add submodule", "add repo as submodule", "submodule add/update/remove" | `workflows/Submodule.md` | -| **IssueAnalysis** | `--issue-analysis`, "analyze issues", "label issues", "route issues across worktrees", "plan the cut", "who should work on what" | `workflows/IssueAnalysis.md` | -| **DeployWorkflow** | "deploy workflow", "push workflow to main", "merge workflow only", "deploy gh action" | `workflows/DeployWorkflow.md` | - -## Examples - -**Example 1: Smart commit with submodule handling** - -``` -User: "commit my changes" -→ Invokes Commit workflow -→ Detects dirty submodules, commits them first -→ Analyzes hooks to choose strategy (PARALLEL/COORDINATED/HYBRID) -→ Runs pre-commit validation -→ Generates conventional commit message with emoji -→ Executes commit -``` - -**Example 2: Create and manage a feature branch** - -``` -User: "create a feature branch for user authentication" -→ Invokes Branch workflow -→ Detects repo default/integration branch -→ Creates feature/user-authentication branch from develop when present, otherwise from the repo default branch -→ Pushes to remote with tracking -``` +| **Commit** | commit | `workflows/Commit.md` | +| **Branch** | create/finish a feature, release, or hotfix branch | `workflows/Branch.md` | +| **Release** | create release, bump version, tag | `workflows/Release.md` | +| **PullRequest** | create/submit PR | `workflows/PullRequest.md` | +| **CIMerge** | merge PR, check/monitor CI, auto-merge | `workflows/CIMerge.md` | +| **CISetup** | set up/scaffold CI, GitHub Actions, runners | `workflows/CISetup.md` | +| **DependencyAudit** | dependabot alerts, security audit | `workflows/DependencyAudit.md` | +| **DeployWorkflow** | land a workflow file on main independent of feature work | `workflows/DeployWorkflow.md` | +| **IssueAnalysis** | analyze/route issues, plan a release cut | `workflows/IssueAnalysis.md` | +| **Submodule** | add/update/remove/sync submodules | `workflows/Submodule.md` | -**Example 3: Create PR, monitor CI, and auto-merge** +git-workflow is the canonical head for GitHub delivery: if another skill or connector creates a PR, return to `workflows/PullRequest.md` and complete its metadata reconciliation — a PR sidebar may be empty only after sources were checked and reported none. -``` -User: "create a PR and merge it when CI passes" -→ Invokes PullRequest workflow -→ Detects target branch from repo shape + current branch type -→ Reads PR template / issue-link requirements when present -→ Creates PR with a valid closing keyword or marks no-issue-required when allowed -→ Continues to CIMerge workflow -→ Polls CI checks (GitHub Actions, CodeRabbit, GitGuardian, repo-specific metadata checks) -→ Waits 240s for automated reviews to settle (minimum 4 minutes) -→ Checks review decision (changes requested? approved? none?) -→ Auto-merges with squash when all clear -``` - -**Example 4: Create a release with version bump** - -``` -User: "create a release for version 2.0.0" -→ Invokes Release workflow -→ Analyzes commits to confirm MAJOR bump is appropriate -→ Creates release/v2.0.0 branch from develop when present, otherwise from the default branch -→ Updates version files -→ Generates changelog from conventional commits -→ Pushes release branch for testing -``` - -**Example 5: Route issues across coding-agent worktrees toward a beta cut** - -``` -User: "/git-workflow --issue-analysis --apply" -→ Invokes IssueAnalysis workflow -→ Detects worktrees (claude / codex / pi) and in-flight PRs -→ Asks for the cut sentence (north-star check) -→ Splits backlog into -blocker vs post- -→ Assigns issues to agents by warm context + file boundary -→ Creates labels: agent:, -blocker, post- -→ Edits all open issues with appropriate labels -→ Prints routing table + ASCII route map + per-agent gh cheat-line -``` - -**Example 6: Deploy a GitHub Actions workflow from a feature branch without merging the feature** - -``` -User: "deploy this workflow to main, but don't merge my feature branch yet" -→ Invokes DeployWorkflow workflow -→ Stashes uncommitted changes on the current feature branch -→ Creates an isolated branch from origin/main -→ Cherry-picks or checks out only the workflow files -→ Commits, pushes, opens PR, merges with squash -→ Returns to the feature branch and restores the stash -→ Workflow is live on main; feature branch remains untouched -``` - ---- - -## Quick Reference: Commit Messages - -Use Conventional Commits with emoji prefixes: +## Commit message format (contract) ``` (): -[optional body] +[optional body — what and why] Co-Authored-By: AOJDevStudio ``` -**Common Types:** - -- ✨ `feat` - New feature -- 🐛 `fix` - Bug fix -- 📝 `docs` - Documentation -- 💄 `style` - Formatting/style -- ♻️ `refactor` - Code refactoring -- ✅ `test` - Tests -- 🔧 `chore` - Tooling, configuration - -**Reference:** See `${PAI_DIR}/ai-docs/templates/emoji-commit-ref.yaml` for 50+ emoji mappings - ---- - -## Quick Reference: Branch Commands - -### Detect Base Branches - -```bash -DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name' 2>/dev/null || git remote show origin | sed -n '/HEAD branch/s/.*: //p') -if git show-ref --verify --quiet refs/heads/develop || git ls-remote --exit-code --heads origin develop >/dev/null 2>&1; then - INTEGRATION_BRANCH=develop -else - INTEGRATION_BRANCH="$DEFAULT_BRANCH" -fi -``` - -### Feature Branch - -```bash -# Start -git checkout "$INTEGRATION_BRANCH" && git pull origin "$INTEGRATION_BRANCH" -git checkout -b feature/descriptive-name -git push -u origin feature/descriptive-name - -# Finish (preferred: open a PR into $INTEGRATION_BRANCH) -``` - -### Release Branch - -```bash -# Start -git checkout "$INTEGRATION_BRANCH" && git checkout -b release/vX.Y.Z -git commit -am "🔖 release: bump version to X.Y.Z" -git push -u origin release/vX.Y.Z - -# Finish (merge to default branch, then back-merge to develop only when develop exists) -``` - -### Hotfix Branch - -```bash -# Start (from repo default branch) -git checkout "$DEFAULT_BRANCH" && git checkout -b hotfix/descriptive-name -git push -u origin hotfix/descriptive-name - -# Finish (merge to default branch, then back-merge to develop only when develop exists) -``` - ---- - -## Commit Strategy Detection - -The Commit workflow automatically detects the optimal strategy based on pre-commit hooks: - -| Hook Configuration | Strategy | Behavior | -|--------------------|----------|----------| -| No formatting hooks | PARALLEL | Stage multiple commits independently | -| Formatting hooks (non-aggressive) | COORDINATED | Stage and commit sequentially | -| Aggressive formatting (prettier --write) | HYBRID | Stage all, let hook format, single commit | - ---- - -## Gotchas - -- `lefthook install` in postinstall crashes on Vercel (not a git repo). Fix: `"postinstall": "lefthook install || true"`. -- Always check postinstall scripts before first Vercel deploy. -- Always verify `NEXT_PUBLIC_SITE_URL` env var is set to the production URL, not localhost, on first deploy. -- Do not assume every repo has `develop`. Detect it. -- Do not assume every PR can omit issue metadata. Inspect `.github/` and contributing docs first. - -## Reusable Workflow Templates - -Ready-to-copy GitHub Actions workflows stored in `templates/`: - -| Template | Purpose | Files | -|----------|---------|-------| -| **Issue Auto-Labeler** | Deterministic keyword-based issue labeling, AI-swap-in ready | `templates/issue-labeler.yml` + `templates/labeler-config.json` | - -Drop both files into a repo's `.github/` directory, customize `labeler-config.json` to match the repo's labels, and the workflow is live on the next push to the default branch. - -## Supplementary Resources +Conventional Commits types; emoji per `templates/emoji-commit-ref.yaml`. -**Detailed workflows, conflict resolution, error handling:** -Read: `AGENT.md` +## Gotchas (verified) -**Comprehensive emoji commit reference:** -Read: `${PAI_DIR}/ai-docs/templates/emoji-commit-ref.yaml` +- **Forked-execution merge loss (2026-07-13, a private repo PR #81).** Run as a forked execution, this skill's background CI poller dies with the forked context: checks go green and the merge silently never happens, leaving the PR open and MERGEABLE forever. The MAIN session owns the terminal merge step — verify the poller task still exists before trusting auto-merge, and on "No task found" re-check `gh pr checks` and merge directly. A completion claim from this skill is not evidence; `gh pr view --json state` is. +- **Local hooks are inert until `lefthook install` runs** (and the binary must exist: `bun add -d lefthook` or `brew install lefthook`). Writing `lefthook.yml` does nothing on its own; teammates cloning the repo must install too. +- **Vercel:** `lefthook install` in postinstall crashes there (not a git repo) — use `"postinstall": "lefthook install || true"`; and verify `NEXT_PUBLIC_SITE_URL` points at production, not localhost, on first deploy. +- Release-model gotchas (GITHUB_TOKEN tag suppression, auto-release XOR) live in `workflows/Release.md` § Posture. Runner/fork/org-auth gotchas live in `workflows/CISetup.md`. Dependabot scope gotcha lives in `workflows/DependencyAudit.md`. diff --git a/skills/gitworkflow/templates/ci/README.md b/skills/gitworkflow/templates/ci/README.md new file mode 100644 index 0000000..4590c93 --- /dev/null +++ b/skills/gitworkflow/templates/ci/README.md @@ -0,0 +1,41 @@ +# CI Templates + +Handlebars-over-YAML templates consumed by the `CISetup` workflow (`../../workflows/CISetup.md`). + +## Variable conventions + +| Style | Meaning | Substituted at | +|-------|---------|----------------| +| `{{var}}` | Handlebars — substituted at scaffold time by CISetup | Skill execution | +| `${{ expr }}` | GitHub Actions expression — preserved verbatim in output YAML | GitHub Actions runtime | +| `{{#eq x "y"}}…{{/eq}}` | Handlebars conditional block | Skill execution | +| `{{#if x}}…{{/if}}` | Handlebars conditional block | Skill execution | + +## Shipped templates (v1) + +| File | Purpose | Default runner | +|------|---------|----------------| +| `node-pr-gate.yml.hbs` | Typecheck + lint + test + build smoke (Node/bun/pnpm/yarn/npm) | `vars.SELF_HOSTED_LINUX` → `[self-hosted, homelab-ci]` | +| `gitleaks.yml.hbs` | Secret scan on every PR + push | `vars.SELF_HOSTED_LINUX` | +| `tauri-macos-build.yml.hbs` | Tauri macOS build (with optional Apple signing) | `vars.SELF_HOSTED_MACOS` → `[self-hosted, homelab-macos, mac-mini-m4]` | +| `runner-health-check.yml.hbs` | Cron probe that updates `SELF_HOSTED_*_AVAILABLE` org Variables | `ubuntu-latest` (must be hosted) | + +## Deferred templates (to be authored on first invocation that needs them) + +- `drizzle-migrate-diff.yml.hbs` — `drizzle-kit generate` dry-run + PR comment +- `prisma-migrate-diff.yml.hbs` — `prisma migrate diff` against shadow DB +- `playwright-e2e.yml.hbs` — Playwright against preview URL +- `wrangler-deploy-dry-run.yml.hbs` — Cloudflare Workers dry-run +- `release-notes.yml.hbs` — Tag-triggered changelog generation +- `node-deploy-vercel.yml.hbs` — Vercel deploy + preview comment + +Adding a template: drop the `.yml.hbs` file here, then update `../../workflows/CISetup.md` § "Templates Inventory" table with the row, and add a Phase 3 parameter question. + +## Conventions + +- **Always** wrap `runs-on` with `${{ fromJSON(vars.SELF_HOSTED_X || '[…]') }}` — never hardcode self-hosted labels. +- **Always** carve out fork PRs with `if: github.event.pull_request.head.repo.full_name == github.repository` for any job that targets self-hosted runners; route fork PRs to `ubuntu-latest`/`macos-latest`. +- **Always** include `timeout-minutes` (self-hosted runners can hang on stuck jobs forever otherwise). +- **Always** include `concurrency:` with `cancel-in-progress: true` for PR-triggered workflows — prevents stacked runs on rapid pushes. +- **Avoid** `secrets: inherit`. Pass secrets explicitly via `env:` blocks scoped to the step that needs them. +- **Avoid** mutating shared state on self-hosted runners (caches, global npm installs). Workspaces should be ephemeral within the job. diff --git a/skills/gitworkflow/templates/ci/dependabot-auto-merge.yml.hbs b/skills/gitworkflow/templates/ci/dependabot-auto-merge.yml.hbs new file mode 100644 index 0000000..e773ad5 --- /dev/null +++ b/skills/gitworkflow/templates/ci/dependabot-auto-merge.yml.hbs @@ -0,0 +1,70 @@ +{{!-- + Template: dependabot-auto-merge.yml.hbs + Purpose: Enable GitHub auto-merge for Dependabot PRs after branch-protection + CI is green. Patch updates auto-merge by default; minor updates are + opt-in; major updates always wait for a human. + Vars: + auto_merge_minor — boolean (opt-in) — also allow semver-minor updates. + Default false — only semver-patch updates are eligible. +--}} +name: Dependabot Auto-Merge + +on: + # Dependabot version PRs are created from dependabot/* branches in this repo. + # pull_request is enough for the declared GITHUB_TOKEN permissions and avoids + # the broader trust boundary of pull_request_target. + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +# contents: write lets GitHub complete the squash merge; pull-requests: write +# lets gh enable auto-merge on the PR. No broader scopes are needed. +permissions: + contents: write + pull-requests: write + +# One auto-merge intent per PR is enough; cancel stale runs when Dependabot +# pushes a newer revision to the same dependency-update branch. +concurrency: + group: dependabot-auto-merge-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dependabot-auto-merge: + if: github.actor == 'dependabot[bot]' && github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Read Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for eligible updates + {{#if auto_merge_minor}} + if: steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.update-type == 'version-update:semver-minor' + {{else}} + if: steps.meta.outputs.update-type == 'version-update:semver-patch' + {{/if}} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + run: | + set -euo pipefail + echo "Enabling auto-merge for Dependabot $UPDATE_TYPE update on PR #$PR_NUMBER" + # --auto registers exactly one merge intent; GitHub finalizes the squash + # only after required status checks and branch protection are green. + gh pr merge --auto --squash "$PR_NUMBER" + + - name: Leave non-eligible updates for human review + {{#if auto_merge_minor}} + if: steps.meta.outputs.update-type != 'version-update:semver-patch' && steps.meta.outputs.update-type != 'version-update:semver-minor' + {{else}} + if: steps.meta.outputs.update-type != 'version-update:semver-patch' + {{/if}} + env: + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + run: | + set -euo pipefail + echo "Dependabot update type '$UPDATE_TYPE' is not eligible for auto-merge; leaving the PR open." diff --git a/skills/gitworkflow/templates/ci/dependabot.yml.hbs b/skills/gitworkflow/templates/ci/dependabot.yml.hbs new file mode 100644 index 0000000..a90d40a --- /dev/null +++ b/skills/gitworkflow/templates/ci/dependabot.yml.hbs @@ -0,0 +1,148 @@ +{{!-- + Template: dependabot.yml.hbs + Purpose: .github/dependabot.yml (config version 2) governing Dependabot + VERSION-update PRs for the detected stack. Each package ecosystem is + emitted as its own update block gated behind a Handlebars flag, so the + CISetup scaffolder PRUNES the file down to the ecosystems it actually + detected in the repo. The github-actions ecosystem is ALWAYS emitted — + Actions pinning drift is a supply-chain risk in every repo, JS or not. + + Scope note: this file controls dependency *version* updates only. + Dependabot SECURITY updates (the vulnerability-driven PRs that pair + with the dependency-audit degradation in DependencyAudit.md) are a + REPOSITORY-LEVEL toggle — enable them under + Settings → Code security → Dependabot → "Dependabot security updates" + (or org-wide). They do not live in this config and need no block here. + + Companion: dependabot-auto-merge.yml.hbs auto-merges the PRs this file + opens once CI is green (patch by default; minor opt-in). + + Vars: + ecosystems — array the scaffolder iterates to emit only detected blocks. + Each entry: { type, directory }. `type` is the + package-ecosystem id (npm | pip | cargo | gomod | docker). + github-actions is emitted unconditionally (not in this list). + Prefer this {{#each ecosystems}} path; the per-ecosystem + {{#if has_*}} blocks below are an equivalent, equally-prunable + alternative the scaffolder may use instead — never both. + has_npm — boolean — emit the npm (Node/bun/pnpm/yarn) block. + has_pip — boolean — emit the pip (Python / uv-managed) block. + has_cargo — boolean — emit the cargo (Rust) block. + has_gomod — boolean — emit the gomod (Go modules) block. + has_docker — boolean — emit the docker (Dockerfile base images) block. + npm_directory — manifest dir for the npm block — default "/" + pip_directory — manifest dir for the pip block — default "/" + cargo_directory — manifest dir for the cargo block — default "/" + gomod_directory — manifest dir for the gomod block — default "/" + docker_directory — Dockerfile dir for the docker block — default "/" + schedule_interval — version-update cadence — default "weekly" + open_pr_limit — max concurrent version-update PRs per ecosystem — default 5 +--}} +version: 2 +updates: +{{#each ecosystems}} + # ── {{this.type}} version updates ──────────────────────────────────────────── + - package-ecosystem: "{{this.type}}" + directory: "{{this.directory}}" + schedule: + interval: "{{../schedule_interval}}" + open-pull-requests-limit: {{../open_pr_limit}} + # Emoji-conventional prefix so Dependabot PRs match the repo's commit voice + # AND the auto-release semver scan classifies them as a `chore` (no release). + commit-message: + prefix: "⬆️ chore(deps)" + # Collapse the weekly noise: one PR per ecosystem for all minor+patch bumps. + groups: + {{this.type}}-minor-patch: + update-types: + - "minor" + - "patch" +{{/each}} +{{#if has_npm}} + # ── npm / Node (bun · pnpm · yarn · npm) version updates ───────────────────── + - package-ecosystem: "npm" + directory: "{{#if npm_directory}}{{npm_directory}}{{else}}/{{/if}}" + schedule: + interval: "{{#if schedule_interval}}{{schedule_interval}}{{else}}weekly{{/if}}" + open-pull-requests-limit: {{#if open_pr_limit}}{{open_pr_limit}}{{else}}5{{/if}} + commit-message: + prefix: "⬆️ chore(deps)" + groups: + npm-minor-patch: + update-types: + - "minor" + - "patch" +{{/if}} +{{#if has_pip}} + # ── pip / Python (uv-managed) version updates ─────────────────────────────── + - package-ecosystem: "pip" + directory: "{{#if pip_directory}}{{pip_directory}}{{else}}/{{/if}}" + schedule: + interval: "{{#if schedule_interval}}{{schedule_interval}}{{else}}weekly{{/if}}" + open-pull-requests-limit: {{#if open_pr_limit}}{{open_pr_limit}}{{else}}5{{/if}} + commit-message: + prefix: "⬆️ chore(deps)" + groups: + pip-minor-patch: + update-types: + - "minor" + - "patch" +{{/if}} +{{#if has_cargo}} + # ── cargo / Rust version updates ──────────────────────────────────────────── + - package-ecosystem: "cargo" + directory: "{{#if cargo_directory}}{{cargo_directory}}{{else}}/{{/if}}" + schedule: + interval: "{{#if schedule_interval}}{{schedule_interval}}{{else}}weekly{{/if}}" + open-pull-requests-limit: {{#if open_pr_limit}}{{open_pr_limit}}{{else}}5{{/if}} + commit-message: + prefix: "⬆️ chore(deps)" + groups: + cargo-minor-patch: + update-types: + - "minor" + - "patch" +{{/if}} +{{#if has_gomod}} + # ── gomod / Go modules version updates ────────────────────────────────────── + - package-ecosystem: "gomod" + directory: "{{#if gomod_directory}}{{gomod_directory}}{{else}}/{{/if}}" + schedule: + interval: "{{#if schedule_interval}}{{schedule_interval}}{{else}}weekly{{/if}}" + open-pull-requests-limit: {{#if open_pr_limit}}{{open_pr_limit}}{{else}}5{{/if}} + commit-message: + prefix: "⬆️ chore(deps)" + groups: + gomod-minor-patch: + update-types: + - "minor" + - "patch" +{{/if}} +{{#if has_docker}} + # ── docker / Dockerfile base-image version updates ────────────────────────── + - package-ecosystem: "docker" + directory: "{{#if docker_directory}}{{docker_directory}}{{else}}/{{/if}}" + schedule: + interval: "{{#if schedule_interval}}{{schedule_interval}}{{else}}weekly{{/if}}" + open-pull-requests-limit: {{#if open_pr_limit}}{{open_pr_limit}}{{else}}5{{/if}} + commit-message: + prefix: "⬆️ chore(deps)" + groups: + docker-minor-patch: + update-types: + - "minor" + - "patch" +{{/if}} + # ── github-actions version updates (ALWAYS emitted — every repo has workflows) ─ + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "{{#if schedule_interval}}{{schedule_interval}}{{else}}weekly{{/if}}" + open-pull-requests-limit: {{#if open_pr_limit}}{{open_pr_limit}}{{else}}5{{/if}} + commit-message: + prefix: "⬆️ chore(deps)" + groups: + github-actions-minor-patch: + update-types: + - "minor" + - "patch" diff --git a/skills/gitworkflow/templates/ci/gitleaks.yml.hbs b/skills/gitworkflow/templates/ci/gitleaks.yml.hbs new file mode 100644 index 0000000..bb3b33b --- /dev/null +++ b/skills/gitworkflow/templates/ci/gitleaks.yml.hbs @@ -0,0 +1,30 @@ +{{!-- + Template: gitleaks.yml.hbs + Purpose: Secret scan via gitleaks on every PR + push + Vars: + gitleaks_config_path — default ".gitleaks.toml" if present, else omit +--}} +name: Secret Scan + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + secret-scan: + runs-on: ${{ fromJSON(vars.SELF_HOSTED_LINUX || '["ubuntu-latest"]') }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + {{#if gitleaks_config_path}} + GITLEAKS_CONFIG: {{gitleaks_config_path}} + {{/if}} diff --git a/skills/gitworkflow/templates/ci/lefthook.yml.hbs b/skills/gitworkflow/templates/ci/lefthook.yml.hbs new file mode 100644 index 0000000..50b34ba --- /dev/null +++ b/skills/gitworkflow/templates/ci/lefthook.yml.hbs @@ -0,0 +1,153 @@ +{{!-- + Template: lefthook.yml.hbs + Purpose: Local Lefthook config for auto-fix-then-block-on-unfixable commit hooks. + Fixers repair and re-stage files; pure checks block only when code or + dependency state still cannot be made safe automatically. + Vars: + package_manager — bun | pnpm | yarn | npm — default bun + test_cmd — pre-push test command — e.g. "bun test" + lint_cmd — lint FIXER command — e.g. "bun run lint" (default derives from package_manager) + format_cmd — format FIXER command — e.g. "bun run format" (default derives from package_manager) + typecheck_cmd — blocking typecheck command — e.g. "bun run typecheck" (default derives from package_manager) + include_rust — boolean — emit cargo fmt (fixer) + cargo clippy (block) — default false + include_python — boolean — emit ruff/black fixer + blocking check via uv — default false +--}} +# GitWorkflow writes this file, then runs `lefthook install` once so git hooks are registered. +# If the lefthook binary is absent, install it with `brew install lefthook` or `bun add -D lefthook`. + +pre-commit: + # Auto-fix first, then block only on issues a fixer cannot repair. + parallel: true + commands: + js-lint: + # Glob guards keep JS tooling from running in repos with no matching staged JS files. + glob: "*.{ts,tsx,js,jsx}" + {{#if lint_cmd}} + run: {{lint_cmd}} {staged_files} + {{else}} + {{#if package_manager}} + {{#eq package_manager "bun"}} + run: bun run lint {staged_files} + {{/eq}} + {{#eq package_manager "pnpm"}} + run: pnpm run lint {staged_files} + {{/eq}} + {{#eq package_manager "yarn"}} + run: yarn lint {staged_files} + {{/eq}} + {{#eq package_manager "npm"}} + run: npm run lint -- {staged_files} + {{/eq}} + {{else}} + run: bun run lint {staged_files} + {{/if}} + {{/if}} + # This is a fixer: re-stage repaired files and let the commit continue. + stage_fixed: true + + js-format: + glob: "*.{ts,tsx,js,jsx}" + {{#if format_cmd}} + run: {{format_cmd}} {staged_files} + {{else}} + {{#if package_manager}} + {{#eq package_manager "bun"}} + run: bun run format {staged_files} + {{/eq}} + {{#eq package_manager "pnpm"}} + run: pnpm run format {staged_files} + {{/eq}} + {{#eq package_manager "yarn"}} + run: yarn format {staged_files} + {{/eq}} + {{#eq package_manager "npm"}} + run: npm run format -- {staged_files} + {{/eq}} + {{else}} + run: bun run format {staged_files} + {{/if}} + {{/if}} + # This is a fixer: formatting edits are automatically added back to the commit. + stage_fixed: true + + js-typecheck: + glob: "*.{ts,tsx,js,jsx}" + {{#if typecheck_cmd}} + run: {{typecheck_cmd}} + {{else}} + {{#if package_manager}} + {{#eq package_manager "bun"}} + run: bun run typecheck + {{/eq}} + {{#eq package_manager "pnpm"}} + run: pnpm run typecheck + {{/eq}} + {{#eq package_manager "yarn"}} + run: yarn typecheck + {{/eq}} + {{#eq package_manager "npm"}} + run: npm run typecheck + {{/eq}} + {{else}} + run: bun run typecheck + {{/if}} + {{/if}} + # This is a pure check: fail the commit if types are still broken. + + js-lockfile-drift: + glob: "{package.json,bun.lock,bun.lockb,pnpm-lock.yaml,yarn.lock,package-lock.json}" + # Frozen installs detect manifest/lockfile drift without writing fixes. + run: | + set -euo pipefail + {{#if package_manager}} + {{#eq package_manager "bun"}} + bun install --frozen-lockfile --ignore-scripts + {{/eq}} + {{#eq package_manager "pnpm"}} + pnpm install --frozen-lockfile --ignore-scripts + {{/eq}} + {{#eq package_manager "yarn"}} + yarn install --frozen-lockfile --ignore-scripts + {{/eq}} + {{#eq package_manager "npm"}} + npm ci --ignore-scripts + {{/eq}} + {{else}} + bun install --frozen-lockfile --ignore-scripts + {{/if}} + git diff --exit-code -- bun.lock bun.lockb pnpm-lock.yaml yarn.lock package-lock.json + # This is a pure check: update dependency state intentionally, then re-run the hook. + + {{#if include_rust}} + rust-format: + glob: "*.rs" + run: cargo fmt --all + # This is a fixer: cargo fmt changes are re-staged automatically. + stage_fixed: true + + rust-clippy: + glob: "*.rs" + run: cargo clippy --all-targets --all-features -- -D warnings + # This is a pure check: clippy findings require code changes before commit. + + {{/if}} + {{#if include_python}} + python-format: + glob: "*.py" + run: uv run ruff check --fix {staged_files} && uv run black {staged_files} + # This is a fixer: Python lint and format edits are re-staged automatically. + stage_fixed: true + + python-check: + glob: "*.py" + run: uv run ruff check {staged_files} && uv run black --check {staged_files} + # This is a pure check: unresolved Python issues block the commit. + + {{/if}} +pre-push: + commands: + tests: + # Run the full suite before pushing. Test runners take their own scope, so we do + # NOT append {push_files} — a file list would break most runners (bun test, cargo + # test, vitest run) and a passing partial run is a false green. + run: {{test_cmd}} diff --git a/skills/gitworkflow/templates/ci/node-pr-gate.yml.hbs b/skills/gitworkflow/templates/ci/node-pr-gate.yml.hbs new file mode 100644 index 0000000..ee79d17 --- /dev/null +++ b/skills/gitworkflow/templates/ci/node-pr-gate.yml.hbs @@ -0,0 +1,112 @@ +{{!-- + Template: node-pr-gate.yml.hbs + Purpose: typecheck + lint + test + build smoke for Node/bun/pnpm/npm projects + Vars: + package_manager — bun | pnpm | yarn | npm + runtime_version — e.g. "20" for Node, "1.1.x" for bun + typecheck_cmd — e.g. "bun tsc --noEmit" or "pnpm typecheck" + lint_cmd — e.g. "bun run lint" + test_cmd — e.g. "bun test" + build_cmd — e.g. "bun run build" +--}} +name: PR Gate + +on: + pull_request: + push: + branches: [main, develop] + +permissions: + contents: read + +concurrency: + group: pr-gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + pr-gate: + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' + runs-on: ${{ fromJSON(vars.SELF_HOSTED_LINUX || '["ubuntu-latest"]') }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + {{#eq package_manager "bun"}} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "{{runtime_version}}" + - run: bun install --frozen-lockfile + {{/eq}} + {{#eq package_manager "pnpm"}} + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: pnpm + - run: pnpm install --frozen-lockfile + {{/eq}} + {{#eq package_manager "yarn"}} + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: yarn + - run: yarn install --frozen-lockfile + {{/eq}} + {{#eq package_manager "npm"}} + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: npm + - run: npm ci + {{/eq}} + + - name: Typecheck + run: {{typecheck_cmd}} + + - name: Lint + run: {{lint_cmd}} + + - name: Test + run: {{test_cmd}} + + - name: Build + run: {{build_cmd}} + + pr-gate-forks: + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + {{#eq package_manager "bun"}} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "{{runtime_version}}" + - run: bun install --frozen-lockfile + {{/eq}} + {{#eq package_manager "pnpm"}} + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: pnpm + - run: pnpm install --frozen-lockfile + {{/eq}} + {{#eq package_manager "yarn"}} + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: yarn + - run: yarn install --frozen-lockfile + {{/eq}} + {{#eq package_manager "npm"}} + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: npm + - run: npm ci + {{/eq}} + - run: {{typecheck_cmd}} + - run: {{lint_cmd}} + - run: {{test_cmd}} + - run: {{build_cmd}} diff --git a/skills/gitworkflow/templates/ci/release-auto.yml.hbs b/skills/gitworkflow/templates/ci/release-auto.yml.hbs new file mode 100644 index 0000000..d7ebef6 --- /dev/null +++ b/skills/gitworkflow/templates/ci/release-auto.yml.hbs @@ -0,0 +1,320 @@ +{{!-- + Template: release-auto.yml.hbs + Purpose: Auto-publish a GitHub Release on every push/merge to the default branch. + This is the DEFAULT release posture: no human tag step. On each merge it + scans the conventional commits since the last tag, computes the next semver, + writes the CHANGELOG.md section for that version, creates and pushes vX.Y.Z, + then SELF-PUBLISHES the GitHub Release in the SAME run. + + NO-DOUBLE-PUBLISH (critical): a tag pushed by the default GITHUB_TOKEN does + NOT retrigger tag-triggered workflows, so this workflow can never rely on a + separate release.yml / release-notes.yml catching its tag. It therefore + publishes the Release itself. That makes release-auto.yml MUTUALLY EXCLUSIVE + (XOR) with the tag-triggered release.yml / release-notes.yml: install ONE path + or the OTHER, never both, or every merge double-publishes. CISetup enforces + this XOR at scaffold time. + + Semver bump rules (computed in bash below, in order of precedence): + - BREAKING ("BREAKING CHANGE" in a commit body, OR "!" before the ":" in a + subject like "feat!:" / "feat(scope)!:") => major; + BUT when the current major is 0, breaking => minor (0.x convention). + - any feat => minor + - any fix or perf => patch + - only docs/style/refactor/test/chore/ci/build (or no recognized type) + => NO release (skip, exit 0, no tag) + Both emoji-prefixed ("✨ feat(x): …", "🐛 fix: …", "⚡ perf: …") and plain + ("feat:", "fix!:") conventional subjects are detected. With no tags yet and at + least one releasable commit, the first release uses {{initial_version}}. + Vars: + default_branch : branch whose pushes trigger an auto-release (default "main") + changelog_path : path to the Keep-a-Changelog file to update (default "CHANGELOG.md") + initial_version : version for the FIRST release when no tags exist (default "v0.1.0") + draft : boolean, publish the Release as a draft (default false) +--}} +name: Release (auto) + +on: + push: + branches: + - "{{default_branch}}" + workflow_dispatch: + +# contents: write is required to push the tag, commit the changelog, and create the Release. +permissions: + contents: write + +# Never cancel a publish mid-flight: a half-created tag/Release is worse than a queued run. +concurrency: + group: release-auto-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-auto: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + # Full history + all tags are required for `git describe` (last tag) and the + # `git log ` body scan. A shallow clone would mis-detect the bump. + fetch-depth: 0 + + - name: Compute next version from conventional commits + id: bump + env: + # The initial version is a scaffold-time constant; bind it so the shell never + # interpolates Handlebars output mid-command. + INITIAL_VERSION: "{{initial_version}}" + run: | + set -euo pipefail + + # --- Range: everything since the last vX.Y.Z tag, or the whole history. ------- + LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)" + if [ -n "$LAST_TAG" ]; then + RANGE="${LAST_TAG}..HEAD" + echo "Last tag: $LAST_TAG (scanning $RANGE)" + else + RANGE="" + echo "No tags yet (scanning full history)" + fi + + # --- Collect subjects and full messages over the range. ----------------------- + # %s = subject only (one line per commit); %B = full message incl. body, with a + # NUL separator so multi-line bodies stay grouped for the BREAKING scan. + if [ -n "$RANGE" ]; then + SUBJECTS="$(git log "$RANGE" --no-merges --format='%s')" + BODIES="$(git log "$RANGE" --no-merges --format='%B%x00')" + else + SUBJECTS="$(git log --no-merges --format='%s')" + BODIES="$(git log --no-merges --format='%B%x00')" + fi + + if [ -z "$SUBJECTS" ]; then + echo "No non-merge commits in range. Nothing to release." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # --- Classify the whole commit set. Highest precedence wins. ------------------ + HAS_BREAKING=false + HAS_FEAT=false + HAS_PATCH=false # fix or perf + + # A real Conventional-Commits breaking footer forces major. Match only a line that + # STARTS with "BREAKING CHANGE:" / "BREAKING-CHANGE:" (the spec footer) — an unanchored + # substring would let prose like "NONBREAKING CHANGE" or "no breaking changes" trip a + # false major release. + if printf '%s' "$BODIES" | grep -qE '^BREAKING[ -]CHANGE:'; then + HAS_BREAKING=true + fi + + # Walk each subject. Strip a leading emoji + spaces (anything before the first + # ASCII letter), then match "(scope)?!?:". The "!" before ":" => breaking. + while IFS= read -r SUBJECT; do + [ -n "$SUBJECT" ] || continue + # Remove a leading run of non-letter, non-"(" characters (emoji, spaces) so + # "✨ feat(x): …" and "feat(x): …" parse identically. + CLEAN="$(printf '%s' "$SUBJECT" | sed -E 's/^[^A-Za-z(]*//')" + # type = leading run of letters; capture an optional "!" before the colon. + # Case-insensitive so a stray "Feat!:"/"Fix!:" is still detected as breaking. + if printf '%s' "$CLEAN" | grep -qiE '^(feat|fix|perf|docs|style|refactor|test|chore|ci|build|revert)(\([^)]*\))?!:'; then + HAS_BREAKING=true + fi + # Capture the type in any case, then lower-case it so "Feat:" does not silently + # fall through to "no release". + TYPE="$(printf '%s' "$CLEAN" | sed -nE 's/^([A-Za-z]+)(\([^)]*\))?!?:.*/\1/p' | tr '[:upper:]' '[:lower:]')" + case "$TYPE" in + feat) HAS_FEAT=true ;; + fix|perf) HAS_PATCH=true ;; + *) : ;; # docs/style/refactor/test/chore/ci/build/revert/unknown (not releasable on their own) + esac + # Process substitution (not a pipe) keeps this loop in the current shell so the + # HAS_* flags set above survive. It also keeps every line indented inside the + # YAML block scalar, unlike a column-0 heredoc body which would break parsing. + done < <(printf '%s\n' "$SUBJECTS") + + # --- Decide the bump level. --------------------------------------------------- + if [ "$HAS_BREAKING" = true ]; then + LEVEL="major" + elif [ "$HAS_FEAT" = true ]; then + LEVEL="minor" + elif [ "$HAS_PATCH" = true ]; then + LEVEL="patch" + else + echo "Only non-releasable commits (docs/style/refactor/test/chore/ci/build). Skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Detected bump level: $LEVEL (breaking=$HAS_BREAKING feat=$HAS_FEAT patch=$HAS_PATCH)" + + # --- Compute the new version. ------------------------------------------------- + if [ -z "$LAST_TAG" ]; then + # First-ever release: use the configured initial version verbatim, no bump. + NEW_TAG="$INITIAL_VERSION" + echo "First release. Using initial version $NEW_TAG" + else + # Parse vMAJOR.MINOR.PATCH (tolerate a missing leading "v"). + CURRENT="${LAST_TAG#v}" + MAJOR="$(printf '%s' "$CURRENT" | cut -d. -f1)" + MINOR="$(printf '%s' "$CURRENT" | cut -d. -f2)" + PATCH="$(printf '%s' "$CURRENT" | cut -d. -f3)" + # 0.x convention: a breaking change pre-1.0 bumps minor, not major. + if [ "$LEVEL" = "major" ] && [ "$MAJOR" = "0" ]; then + echo "0.x convention: breaking change bumps MINOR, not MAJOR." + LEVEL="minor" + fi + case "$LEVEL" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}" + fi + + NEW_VERSION="${NEW_TAG#v}" + echo "Next version: $NEW_TAG" + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "tag=$NEW_TAG" >> "$GITHUB_OUTPUT" + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + # Preserve the range so the changelog step reuses the exact same commit set. + echo "range=$RANGE" >> "$GITHUB_OUTPUT" + + - name: Build CHANGELOG section + release body + id: notes + if: steps.bump.outputs.skip != 'true' + env: + CHANGELOG_PATH: "{{changelog_path}}" + NEW_VERSION: ${{ steps.bump.outputs.version }} + RANGE: ${{ steps.bump.outputs.range }} + run: | + set -euo pipefail + + DATE="$(date -u +%Y-%m-%d)" + + # Gather the released commit subjects for this version's body. We slice the raw + # conventional subjects directly so the workflow has NO hard dependency on the + # bundled changelog tool being present in CI. If the bundled wrapper happens to + # be available it can be run locally before merge; here we stay self-contained. + if [ -n "$RANGE" ]; then + SUBJECTS="$(git log "$RANGE" --no-merges --format='%s')" + else + SUBJECTS="$(git log --no-merges --format='%s')" + fi + + # Emit a Keep-a-Changelog body. Group by section using the conventional type, + # stripping any leading emoji so headings stay clean. + { + ADDED=""; FIXED=""; CHANGED="" + while IFS= read -r SUBJECT; do + [ -n "$SUBJECT" ] || continue + CLEAN="$(printf '%s' "$SUBJECT" | sed -E 's/^[^A-Za-z(]*//')" + TYPE="$(printf '%s' "$CLEAN" | sed -nE 's/^([A-Za-z]+)(\([^)]*\))?!?:.*/\1/p' | tr '[:upper:]' '[:lower:]')" + case "$TYPE" in + feat) ADDED="${ADDED}- ${CLEAN}"$'\n' ;; + fix|perf) FIXED="${FIXED}- ${CLEAN}"$'\n' ;; + *) CHANGED="${CHANGED}- ${CLEAN}"$'\n' ;; + esac + # Process substitution keeps this loop in the current shell so ADDED/FIXED/ + # CHANGED survive, and keeps every line inside the YAML block scalar. + done < <(printf '%s\n' "$SUBJECTS") + if [ -n "$ADDED" ]; then printf '### Added\n%s\n' "$ADDED"; fi + if [ -n "$FIXED" ]; then printf '### Fixed\n%s\n' "$FIXED"; fi + if [ -n "$CHANGED" ]; then printf '### Changed\n%s\n' "$CHANGED"; fi + } > section_body.md + + # Trim trailing blank lines from the body. + sed -e :a -e '/^[[:space:]]*$/{$d;N;ba}' section_body.md > notes.md + + if [ -s notes.md ]; then + echo "found=true" >> "$GITHUB_OUTPUT" + else + # No subjects survived (should not happen post-skip-check): let GitHub + # generate notes as a fallback rather than publishing an empty body. + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + # --- Insert the new "## [VERSION] - DATE" section into the changelog. ---------- + # The section lands at the top of the entries: after a leading "# Changelog" + # title (and any prose before the first "## " release header) if present, + # otherwise at the very top of the file. We never rewrite existing sections. + HEADER="## [${NEW_VERSION}] - ${DATE}" + if [ -f "$CHANGELOG_PATH" ]; then + awk -v header="$HEADER" ' + BEGIN { inserted = 0 } + # Insert just before the first existing release section. + !inserted && /^## \[/ { + print header + print "" + while ((getline line < "notes.md") > 0) print line + print "" + inserted = 1 + } + { print } + END { + # No existing "## [" section: append after whatever prose exists. + if (!inserted) { + print "" + print header + print "" + while ((getline line < "notes.md") > 0) print line + } + } + ' "$CHANGELOG_PATH" > changelog.new + else + # Bootstrap a fresh Keep-a-Changelog file. + { + printf '# Changelog\n\n' + printf '%s\n\n' "$HEADER" + cat notes.md + printf '\n' + } > changelog.new + fi + mv changelog.new "$CHANGELOG_PATH" + echo "Wrote $HEADER to $CHANGELOG_PATH" + + - name: Commit changelog, tag, and push + id: publish_git + if: steps.bump.outputs.skip != 'true' + env: + CHANGELOG_PATH: "{{changelog_path}}" + NEW_TAG: ${{ steps.bump.outputs.tag }} + run: | + set -euo pipefail + + # Identify as the Actions bot for the changelog commit + annotated tag. + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add "$CHANGELOG_PATH" + # Use the emoji-conventional release commit style used elsewhere in this skill. + git commit -m "🔖 release: ${NEW_TAG}" + + # Annotated tag on the changelog commit, then push commit + tag together. + git tag -a "$NEW_TAG" -m "Release ${NEW_TAG}" + git push origin "HEAD:${GITHUB_REF_NAME}" + git push origin "refs/tags/${NEW_TAG}" + echo "Pushed commit + tag $NEW_TAG" + + - name: Publish GitHub Release + if: steps.bump.outputs.skip != 'true' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.bump.outputs.tag }} + name: ${{ steps.bump.outputs.tag }} + body_path: notes.md + # Self-published in THIS run: the tag push above will not retrigger any + # tag-triggered workflow (default GITHUB_TOKEN), so nothing else publishes it. + draft: {{#if draft}}true{{else}}false{{/if}} + # vX.Y.Z-rc.1 (any pre-release suffix) is auto-flagged as a prerelease. + prerelease: ${{ contains(steps.bump.outputs.tag, '-') }} + # Only auto-generate notes when we produced NO curated body. Otherwise + # softprops APPENDS the auto-notes to our body instead of replacing it. + generate_release_notes: ${{ steps.notes.outputs.found != 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: No-op (no releasable changes) + if: steps.bump.outputs.skip == 'true' + # Block scalar so the colon in the message is not parsed as a YAML mapping. + run: | + echo "No releasable commits since the last tag: no version bump, no tag, no Release." diff --git a/skills/gitworkflow/templates/ci/release-notes.yml.hbs b/skills/gitworkflow/templates/ci/release-notes.yml.hbs new file mode 100644 index 0000000..315fcd8 --- /dev/null +++ b/skills/gitworkflow/templates/ci/release-notes.yml.hbs @@ -0,0 +1,125 @@ +{{!-- + Template: release-notes.yml.hbs + Purpose: Changelog-only GitHub Release for libraries/services that ship NO binaries. + Layer 2 of the release flow — the Release workflow (Layer 1) bumps the version, + commits CHANGELOG.md (Keep-a-Changelog), tags vX.Y.Z and pushes the tag; this + tag push triggers this workflow, which extracts the matching CHANGELOG section + into the GitHub Release body. No build matrix, no uploaded files. + Vars: + tag_pattern — tag glob that triggers a release — default "v*" + changelog_path — path to the changelog file — default "CHANGELOG.md" + draft — boolean — create the Release as a draft — default false + use_self_hosted_macos — boolean (opt-in) — run on self-hosted macOS via vars.SELF_HOSTED_MACOS + instead of ubuntu-latest. Default OFF — release jobs stay on + GitHub-hosted ephemeral runners because they touch production secrets. +--}} +name: Release Notes + +on: + push: + tags: + - "{{tag_pattern}}" + workflow_dispatch: + inputs: + tag: + description: "Tag to (re-)publish a Release for (e.g. v1.2.0). Must already exist." + required: false + type: string + +# contents: write is required to create/update the GitHub Release object. +permissions: + contents: write + +# Never cancel a publish mid-flight — a half-created Release is worse than a queued one. +concurrency: + group: release-notes-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-notes: + {{#if use_self_hosted_macos}} + runs-on: ${{ fromJSON(vars.SELF_HOSTED_MACOS || '["macos-latest"]') }} + {{else}} + runs-on: ubuntu-latest + {{/if}} + timeout-minutes: 10 + steps: + - name: Resolve tag + id: tag + env: + # Bind the (trusted) tag/ref to an env var rather than inlining the + # ${{ }} expression directly into the shell command. + REF_NAME: ${{ github.event.inputs.tag || github.ref_name }} + run: | + set -euo pipefail + # workflow_dispatch may pass an explicit tag; otherwise use the pushed ref name. + TAG="$REF_NAME" + if [ -z "$TAG" ]; then + echo "::error::No tag resolved — provide an input tag or push a tag." >&2 + exit 1 + fi + # Strip a leading "v" to match the Keep-a-Changelog "## [1.2.0]" header. + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Resolved tag=$TAG version=$VERSION" + + - uses: actions/checkout@v4 + with: + # Check out the tag itself so {{changelog_path}} matches the released version. + # (Default shallow depth is fine — we read the working-tree file, not git log.) + ref: ${{ steps.tag.outputs.tag }} + + - name: Extract changelog section + id: notes + env: + # Same reason as the Resolve tag step: never inline a ${{ }} expression + # into a shell script. Tag names allow $, backtick, (, ), ; and &, and + # this job holds contents: write. + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + CHANGELOG="{{changelog_path}}" + + if [ ! -f "$CHANGELOG" ]; then + echo "No $CHANGELOG found — falling back to generated release notes." + : > notes.md + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Pull the lines between this version's "## [VERSION]" header and the next + # "## " header (or end of file). Matches "## [1.2.0] - 2026-06-02" style headers. + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' "$CHANGELOG" > section.md + + # Trim leading/trailing blank lines. + sed -e :a -e '/^[[:space:]]*$/{$d;N;ba}' section.md \ + | sed -e '/./,$!d' > notes.md + + if [ -s notes.md ]; then + echo "Extracted notes for version $VERSION:" + cat notes.md + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "No '## [$VERSION]' section in $CHANGELOG — falling back to generated release notes." + : > notes.md + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + body_path: notes.md + # Changelog-only release — no binaries are attached. + draft: {{#if draft}}true{{else}}false{{/if}} + # v1.2.0-beta.1 (any pre-release suffix) is auto-flagged as a prerelease. + prerelease: ${{ contains(steps.tag.outputs.tag, '-') }} + # Only auto-generate when we found NO curated section — otherwise softprops + # APPENDS the auto-notes to our body instead of using them as a fallback. + generate_release_notes: ${{ steps.notes.outputs.found != 'true' }} diff --git a/skills/gitworkflow/templates/ci/release.yml.hbs b/skills/gitworkflow/templates/ci/release.yml.hbs new file mode 100644 index 0000000..c0499f1 --- /dev/null +++ b/skills/gitworkflow/templates/ci/release.yml.hbs @@ -0,0 +1,250 @@ +{{!-- + Template: release.yml.hbs + Purpose: GitHub Release WITH binaries — tag push builds cross-platform assets, + then creates a real GitHub Release with those assets attached. + Layer 2 of the release flow: Release.md (Layer 1) bumps version, + commits CHANGELOG.md (Keep-a-Changelog), tags vX.Y.Z, pushes tag — + which triggers THIS workflow. The Release BODY is extracted from the + matching "## [version]" section of CHANGELOG.md, falling back to + softprops generate_release_notes when no section is found. + Vars: + release_kind — tauri | binary (tauri = bundle paths; binary = artifact_glob) + package_manager — bun | pnpm | yarn | npm + runtime_version — e.g. "1.1.x" for bun, "20" for Node + build_cmd — e.g. "bun run build:release", or for cross-platform tauri + target the matrix triple so bundles land under + src-tauri/target//...: "bun tauri build --target $MATRIX_RUST_TARGET" + tag_pattern — default "v*" + targets — which OS/arch legs to build (see {{#each targets}} include matrix) + each entry: { os, arch, runner, rust_target?, label } + runtime_version — package-manager runtime pin + signing_required — boolean — macOS legs only; needs APPLE_CERTIFICATE + + APPLE_CERTIFICATE_PASSWORD + KEYCHAIN_PASSWORD secrets + use_self_hosted_macos — boolean (default false) — opt-in self-hosted macOS via + vars.SELF_HOSTED_MACOS; HOSTED is the default for release jobs + (they touch production secrets — ephemeral isolation is the rule) + draft — boolean (default false) — publish as a draft Release + artifact_glob — release_kind=binary only — files to attach (e.g. "out/**/*") +--}} +name: Release + +on: + push: + tags: + - "{{tag_pattern}}" + workflow_dispatch: + inputs: + tag: + description: "Tag to (re)release, e.g. v1.2.0 — defaults to the triggering tag" + required: false + type: string + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + {{#each targets}} + - os: {{this.os}} + arch: {{this.arch}} + {{#eq this.os "macos"}} + {{#if ../use_self_hosted_macos}} + runner: ${{ fromJSON(vars.SELF_HOSTED_MACOS || '["macos-latest"]') }} + {{else}} + runner: {{this.runner}} + {{/if}} + {{else}} + runner: {{this.runner}} + {{/eq}} + {{#if this.rust_target}} + rust_target: {{this.rust_target}} + {{/if}} + label: {{this.label}} + {{/each}} + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + {{#eq package_manager "bun"}} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "{{runtime_version}}" + - run: bun install --frozen-lockfile + {{/eq}} + {{#eq package_manager "pnpm"}} + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: pnpm + - run: pnpm install --frozen-lockfile + {{/eq}} + {{#eq package_manager "yarn"}} + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: yarn + - run: yarn install --frozen-lockfile + {{/eq}} + {{#eq package_manager "npm"}} + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: npm + - run: npm ci + {{/eq}} + + {{#eq release_kind "tauri"}} + - name: Add Rust target + if: matrix.rust_target != '' + run: rustup target add ${{ matrix.rust_target }} + + - name: Install Linux build deps + if: matrix.os == 'linux' + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev patchelf libayatana-appindicator3-dev + {{/eq}} + + {{#if signing_required}} + - name: Import Apple signing cert + if: matrix.os == 'macos' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + echo "$APPLE_CERTIFICATE" | base64 --decode > /tmp/cert.p12 + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security import /tmp/cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain + rm /tmp/cert.p12 + {{/if}} + + - name: Build + {{#if signing_required}} + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + {{#eq release_kind "tauri"}} + MATRIX_RUST_TARGET: ${{ matrix.rust_target }} + {{/eq}} + {{else}} + {{#eq release_kind "tauri"}} + env: + MATRIX_RUST_TARGET: ${{ matrix.rust_target }} + {{/eq}} + {{/if}} + run: {{build_cmd}} + + - name: Upload build artifacts + if: success() + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.label }}-${{ github.sha }} + path: | + {{#eq release_kind "tauri"}} + {{#eq this.os "macos"}} + src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg/*.dmg + src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos/*.app.tar.gz + {{/eq}} + {{#eq this.os "linux"}} + src-tauri/target/${{ matrix.rust_target }}/release/bundle/deb/*.deb + src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage/*.AppImage + {{/eq}} + {{#eq this.os "windows"}} + src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi/*.msi + src-tauri/target/${{ matrix.rust_target }}/release/bundle/nsis/*.exe + {{/eq}} + {{/eq}} + {{#eq release_kind "binary"}} + {{artifact_glob}} + {{/eq}} + if-no-files-found: warn + retention-days: 14 + + {{#if signing_required}} + - name: Cleanup keychain + if: always() && matrix.os == 'macos' + run: security delete-keychain build.keychain || true + {{/if}} + + release: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Checkout (for CHANGELOG.md) + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Extract release notes from CHANGELOG.md + id: notes + env: + # Bind the (trusted) tag/ref to an env var rather than inlining the + # ${{ }} expression into the shell — hardens against future changes + # that loosen input typing or reuse the value in a less-quoted context. + REF_NAME: ${{ github.event.inputs.tag || github.ref_name }} + run: | + set -euo pipefail + REF="$REF_NAME" + # Strip a leading "v" so "v1.2.0" matches "## [1.2.0]" in Keep-a-Changelog. + VERSION="${REF#v}" + : > notes.md + if [ -f CHANGELOG.md ]; then + # Pull the body between "## [VERSION]" and the next "## [" header. + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { capture=1; next } + capture && /^## \[/ { exit } + capture { print } + ' CHANGELOG.md > notes.md || true + fi + if [ -s notes.md ]; then + echo "Found CHANGELOG.md section for $VERSION" + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "No CHANGELOG.md section for $VERSION — falling back to generate_release_notes" + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + name: ${{ github.event.inputs.tag || github.ref_name }} + body_path: notes.md + files: dist/** + draft: {{#if draft}}true{{else}}false{{/if}} + prerelease: ${{ contains(github.event.inputs.tag || github.ref_name, '-') }} + # Only let GitHub auto-generate notes when we found NO curated CHANGELOG + # section — otherwise softprops APPENDS the auto-notes to our body. + generate_release_notes: ${{ steps.notes.outputs.found != 'true' }} + fail_on_unmatched_files: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/skills/gitworkflow/templates/ci/runner-health-check.yml.hbs b/skills/gitworkflow/templates/ci/runner-health-check.yml.hbs new file mode 100644 index 0000000..4412661 --- /dev/null +++ b/skills/gitworkflow/templates/ci/runner-health-check.yml.hbs @@ -0,0 +1,83 @@ +{{!-- + Template: runner-health-check.yml.hbs + Purpose: Scheduled probe of self-hosted runner availability. + Flips org-level Variables SELF_HOSTED_LINUX_AVAILABLE and SELF_HOSTED_MACOS_AVAILABLE + so main-branch jobs can fall back to GitHub-hosted when runners are offline. + Vars: + org — GitHub org name (e.g. "your-org") + cron_schedule — default "*/5 * * * *" (every 5 min) + Required: A GitHub App token or PAT with org:variables write scope, stored as RUNNER_HEALTH_TOKEN. + The default GITHUB_TOKEN cannot write org Variables. +--}} +name: Runner Health Check + +on: + schedule: + - cron: "{{cron_schedule}}" + workflow_dispatch: + +permissions: + contents: read + +jobs: + probe: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Query org runners + id: runners + env: + GH_TOKEN: ${{ secrets.RUNNER_HEALTH_TOKEN }} + run: | + set -euo pipefail + # An "online" runner has status=online AND is not busy with a stuck job. + gh api orgs/{{org}}/actions/runners --paginate \ + --jq '.runners[] | {name, status, busy, labels: [.labels[].name]}' \ + > runners.jsonl + + LINUX_ONLINE=$(jq -s 'map(select(.status=="online" and (.labels | contains(["homelab-ci"])))) | length' runners.jsonl) + MACOS_ONLINE=$(jq -s 'map(select(.status=="online" and (.labels | contains(["homelab-macos"])))) | length' runners.jsonl) + + echo "linux_online=$LINUX_ONLINE" >> "$GITHUB_OUTPUT" + echo "macos_online=$MACOS_ONLINE" >> "$GITHUB_OUTPUT" + echo "Linux online: $LINUX_ONLINE | macOS online: $MACOS_ONLINE" + + - name: Update SELF_HOSTED_LINUX_AVAILABLE + env: + GH_TOKEN: ${{ secrets.RUNNER_HEALTH_TOKEN }} + # Bind step outputs through env rather than inlining ${{ }} into the + # script. These are jq counts today, but this job writes org variables, + # so keep the boundary explicit. + ONLINE: ${{ steps.runners.outputs.linux_online }} + run: | + if [ "$ONLINE" -gt 0 ]; then + gh api -X PATCH orgs/{{org}}/actions/variables/SELF_HOSTED_LINUX_AVAILABLE \ + -f name=SELF_HOSTED_LINUX_AVAILABLE -f value=true \ + || gh api -X POST orgs/{{org}}/actions/variables \ + -f name=SELF_HOSTED_LINUX_AVAILABLE -f value=true -f visibility=all + else + gh api -X PATCH orgs/{{org}}/actions/variables/SELF_HOSTED_LINUX_AVAILABLE \ + -f name=SELF_HOSTED_LINUX_AVAILABLE -f value=false \ + || gh api -X POST orgs/{{org}}/actions/variables \ + -f name=SELF_HOSTED_LINUX_AVAILABLE -f value=false -f visibility=all + fi + + - name: Update SELF_HOSTED_MACOS_AVAILABLE + env: + GH_TOKEN: ${{ secrets.RUNNER_HEALTH_TOKEN }} + # Bind step outputs through env rather than inlining ${{ }} into the + # script. These are jq counts today, but this job writes org variables, + # so keep the boundary explicit. + ONLINE: ${{ steps.runners.outputs.macos_online }} + run: | + if [ "$ONLINE" -gt 0 ]; then + gh api -X PATCH orgs/{{org}}/actions/variables/SELF_HOSTED_MACOS_AVAILABLE \ + -f name=SELF_HOSTED_MACOS_AVAILABLE -f value=true \ + || gh api -X POST orgs/{{org}}/actions/variables \ + -f name=SELF_HOSTED_MACOS_AVAILABLE -f value=true -f visibility=all + else + gh api -X PATCH orgs/{{org}}/actions/variables/SELF_HOSTED_MACOS_AVAILABLE \ + -f name=SELF_HOSTED_MACOS_AVAILABLE -f value=false \ + || gh api -X POST orgs/{{org}}/actions/variables \ + -f name=SELF_HOSTED_MACOS_AVAILABLE -f value=false -f visibility=all + fi diff --git a/skills/gitworkflow/templates/ci/tauri-macos-build.yml.hbs b/skills/gitworkflow/templates/ci/tauri-macos-build.yml.hbs new file mode 100644 index 0000000..a5813bc --- /dev/null +++ b/skills/gitworkflow/templates/ci/tauri-macos-build.yml.hbs @@ -0,0 +1,100 @@ +{{!-- + Template: tauri-macos-build.yml.hbs + Purpose: Tauri macOS build on the Mac Mini self-hosted runner + Vars: + package_manager — bun | pnpm | yarn | npm + runtime_version — e.g. "1.1.x" for bun + build_cmd — default "bun tauri build" + tauri_targets — comma-separated, e.g. "aarch64-apple-darwin" + signing_required — boolean — when true, requires APPLE_CERTIFICATE + APPLE_CERTIFICATE_PASSWORD secrets +--}} +name: Tauri macOS Build + +on: + pull_request: + paths: + - "src-tauri/**" + - "src/**" + - "package.json" + - "{{package_manager}}.lock*" + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tauri-macos-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' + runs-on: ${{ fromJSON(vars.SELF_HOSTED_MACOS || '["macos-latest"]') }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + {{#eq package_manager "bun"}} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "{{runtime_version}}" + - run: bun install --frozen-lockfile + {{/eq}} + {{#eq package_manager "pnpm"}} + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: "{{runtime_version}}" + cache: pnpm + - run: pnpm install --frozen-lockfile + {{/eq}} + + - name: Add Rust target + run: rustup target add {{tauri_targets}} + + {{#if signing_required}} + - name: Import Apple signing cert + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + echo "$APPLE_CERTIFICATE" | base64 --decode > /tmp/cert.p12 + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security import /tmp/cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain + rm /tmp/cert.p12 + {{/if}} + + - name: Build + env: + {{#if signing_required}} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + {{/if}} + run: {{build_cmd}} + + - name: Upload artifact + if: success() + uses: actions/upload-artifact@v4 + with: + name: tauri-macos-${{ github.sha }} + path: | + src-tauri/target/{{tauri_targets}}/release/bundle/dmg/*.dmg + src-tauri/target/{{tauri_targets}}/release/bundle/macos/*.app + if-no-files-found: warn + retention-days: 14 + + {{#if signing_required}} + - name: Cleanup keychain + if: always() + run: security delete-keychain build.keychain || true + {{/if}} diff --git a/skills/gitworkflow/templates/emoji-commit-ref.yaml b/skills/gitworkflow/templates/emoji-commit-ref.yaml new file mode 100644 index 0000000..ea3d86d --- /dev/null +++ b/skills/gitworkflow/templates/emoji-commit-ref.yaml @@ -0,0 +1,192 @@ +# A comprehensive list of conventional commit types with corresponding emojis. +# This can be used to enforce or generate standardized commit messages. +commit_message_conventions: + - emoji: "✨" + type: "feat" + description: "New feature" + - emoji: "🐛" + type: "fix" + description: "Bug fix" + - emoji: "📝" + type: "docs" + description: "Documentation" + - emoji: "💄" + type: "style" + description: "Formatting/style" + - emoji: "♻️" + type: "refactor" + description: "Code refactoring" + - emoji: "⚡️" + type: "perf" + description: "Performance improvements" + - emoji: "✅" + type: "test" + description: "Tests" + - emoji: "🔧" + type: "chore" + description: "Tooling, configuration" + - emoji: "🚀" + type: "ci" + description: "CI/CD improvements" + - emoji: "🗑️" + type: "revert" + description: "Reverting changes" + - emoji: "🧪" + type: "test" + description: "Add a failing test" + - emoji: "🚨" + type: "fix" + description: "Fix compiler/linter warnings" + - emoji: "🔒️" + type: "fix" + description: "Fix security issues" + - emoji: "👥" + type: "chore" + description: "Add or update contributors" + - emoji: "🚚" + type: "refactor" + description: "Move or rename resources" + - emoji: "🏗️" + type: "refactor" + description: "Make architectural changes" + - emoji: "🔀" + type: "chore" + description: "Merge branches" + - emoji: "📦️" + type: "chore" + description: "Add or update compiled files or packages" + - emoji: "➕" + type: "chore" + description: "Add a dependency" + - emoji: "➖" + type: "chore" + description: "Remove a dependency" + - emoji: "🌱" + type: "chore" + description: "Add or update seed files" + - emoji: "🧑‍💻" + type: "chore" + description: "Improve developer experience" + - emoji: "🧵" + type: "feat" + description: "Add or update code related to multithreading or concurrency" + - emoji: "🔍️" + type: "feat" + description: "Improve SEO" + - emoji: "🏷️" + type: "feat" + description: "Add or update types" + - emoji: "💬" + type: "feat" + description: "Add or update text and literals" + - emoji: "🌐" + type: "feat" + description: "Internationalization and localization" + - emoji: "👔" + type: "feat" + description: "Add or update business logic" + - emoji: "📱" + type: "feat" + description: "Work on responsive design" + - emoji: "🚸" + type: "feat" + description: "Improve user experience / usability" + - emoji: "🩹" + type: "fix" + description: "Simple fix for a non-critical issue" + - emoji: "🥅" + type: "fix" + description: "Catch errors" + - emoji: "👽️" + type: "fix" + description: "Update code due to external API changes" + - emoji: "🔥" + type: "fix" + description: "Remove code or files" + - emoji: "🎨" + type: "style" + description: "Improve structure/format of the code" + - emoji: "🚑️" + type: "fix" + description: "Critical hotfix" + - emoji: "🎉" + type: "chore" + description: "Begin a project" + - emoji: "🔖" + type: "chore" + description: "Release/Version tags" + - emoji: "🚧" + type: "wip" + description: "Work in progress" + - emoji: "💚" + type: "fix" + description: "Fix CI build" + - emoji: "📌" + type: "chore" + description: "Pin dependencies to specific versions" + - emoji: "👷" + type: "ci" + description: "Add or update CI build system" + - emoji: "📈" + type: "feat" + description: "Add or update analytics or tracking code" + - emoji: "✏️" + type: "fix" + description: "Fix typos" + - emoji: "⏪️" + type: "revert" + description: "Revert changes" + - emoji: "📄" + type: "chore" + description: "Add or update license" + - emoji: "💥" + type: "feat" + description: "Introduce breaking changes" + - emoji: "🍱" + type: "assets" + description: "Add or update assets" + - emoji: "♿️" + type: "feat" + description: "Improve accessibility" + - emoji: "💡" + type: "docs" + description: "Add or update comments in source code" + - emoji: "🗃️" + type: "db" + description: "Perform database related changes" + - emoji: "🔊" + type: "feat" + description: "Add or update logs" + - emoji: "🔇" + type: "fix" + description: "Remove logs" + - emoji: "🤡" + type: "test" + description: "Mock things" + - emoji: "🥚" + type: "feat" + description: "Add or update an easter egg" + - emoji: "🙈" + type: "chore" + description: "Add or update .gitignore file" + - emoji: "📸" + type: "test" + description: "Add or update snapshots" + - emoji: "⚗️" + type: "experiment" + description: "Perform experiments" + - emoji: "🚩" + type: "feat" + description: "Add, update, or remove feature flags" + - emoji: "💫" + type: "ui" + description: "Add or update animations and transitions" + - emoji: "⚰️" + type: "refactor" + description: "Remove dead code" + - emoji: "🦺" + type: "feat" + description: "Add or update code related to validation" + - emoji: "✈️" + type: "feat" + description: "Improve offline support" diff --git a/skills/gitworkflow/templates/labeler-config.json b/skills/gitworkflow/templates/labeler-config.json index 1bfbad8..4c6e5e9 100644 --- a/skills/gitworkflow/templates/labeler-config.json +++ b/skills/gitworkflow/templates/labeler-config.json @@ -4,51 +4,70 @@ { "name": "bug", "keywords": [ - "bug", - "crash", - "error", - "fail", - "broken", - "not working", - "exception", - "stack trace", - "regression", - "fix" + "bug", "crash", "error", "fail", "broken", "not working", + "exception", "stack trace", "regression", "fix" ] }, { "name": "enhancement", - "keywords": ["feature", "request", "add", "support", "implement", "would be nice", "improve", "upgrade"] + "keywords": [ + "feature", "request", "add", "support", "implement", + "would be nice", "improve", "upgrade" + ] }, { "name": "documentation", - "keywords": ["docs", "readme", "documentation", "guide", "tutorial", "wiki", "explain"] + "keywords": [ + "docs", "readme", "documentation", "guide", "tutorial", "wiki", "explain" + ] }, { "name": "security", - "keywords": ["security", "vulnerability", "auth", "xss", "injection", "exploit", "sanitize", "credential", "leak"] + "keywords": [ + "security", "vulnerability", "auth", "xss", "injection", + "exploit", "sanitize", "credential", "leak" + ] }, { "name": "performance", - "keywords": ["performance", "slow", "latency", "bottleneck", "optimize", "memory leak", "cache", "speed"] + "keywords": [ + "performance", "slow", "latency", "bottleneck", "optimize", + "memory leak", "cache", "speed" + ] }, { "name": "infrastructure", - "keywords": ["ci", "cd", "build", "workflow", "action", "deploy", "terraform", "docker", "environment", "repo"] + "keywords": [ + "ci", "cd", "build", "workflow", "action", "deploy", + "terraform", "docker", "environment", "repo" + ] }, { "name": "dependencies", - "keywords": ["dependency", "dependencies", "bump", "update", "upgrade", "npm", "pip", "cargo", "gem", "outdated"] + "keywords": [ + "dependency", "dependencies", "bump", "update", "upgrade", + "npm", "pip", "cargo", "gem", "outdated" + ] }, { "name": "tests", - "keywords": ["test", "testing", "spec", "coverage", "jest", "vitest", "pytest", "unit test", "e2e", "integration"] + "keywords": [ + "test", "testing", "spec", "coverage", "jest", "vitest", + "pytest", "unit test", "e2e", "integration" + ] } ], "settings": { "wholeWord": true, "caseInsensitive": true, "maxLabels": 3, - "excludedLabels": ["duplicate", "good first issue", "help wanted", "invalid", "wontfix", "question"] + "excludedLabels": [ + "duplicate", + "good first issue", + "help wanted", + "invalid", + "wontfix", + "question" + ] } } diff --git a/skills/gitworkflow/workflows/Branch.md b/skills/gitworkflow/workflows/Branch.md index af26675..7f179e6 100755 --- a/skills/gitworkflow/workflows/Branch.md +++ b/skills/gitworkflow/workflows/Branch.md @@ -1,197 +1,28 @@ # Branch Workflow -Create and manage feature, release, and hotfix branches with repo-aware base branch detection. +Create and finish Git Flow branches with repo-aware base detection. -## Variables +## Repo shape (detect first) -```bash -BRANCH_TYPE: {{feature|release|hotfix}} -BRANCH_NAME: $ARGUMENTS -ACTION: {{start|finish}} -DEFAULT_BRANCH: detected from GitHub / origin HEAD -INTEGRATION_BRANCH: develop when present, otherwise DEFAULT_BRANCH -``` +Integration branch = `develop` when the repo has one (local or remote), else the default branch. Never hard-code `develop` without confirming it exists. -Detect repo branches first: +| Type | Branch from | Merges to | +|------|-------------|-----------| +| `feature/` | integration branch | PR to integration branch | +| `release/vX.Y.Z` | integration branch | default branch, then back-merge to develop *only when develop exists and differs* | +| `hotfix/` | **default branch** | default branch, then the same conditional back-merge | -```bash -DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name' 2>/dev/null || git remote show origin | sed -n '/HEAD branch/s/.*: //p') -if git show-ref --verify --quiet refs/heads/develop || git ls-remote --exit-code --heads origin develop >/dev/null 2>&1; then - INTEGRATION_BRANCH=develop -else - INTEGRATION_BRANCH="$DEFAULT_BRANCH" -fi -``` +## Start -## Workflow +From an up-to-date base branch (no uncommitted changes, base current with origin), create the typed branch and push with tracking. Release branches also bump the version files and commit `🔖 release: bump version to X.Y.Z` before pushing. -### Start a Branch +## Finish -#### Feature Branch (from `INTEGRATION_BRANCH`) +- **Feature:** the preferred path on shared repos is the **PullRequest** workflow targeting the integration branch; after merge, sync and delete the branch local + remote. Direct local merge only when the user explicitly wants a non-PR flow and the target isn't protected. +- **Release / hotfix:** merge `--no-ff` to the default branch, tag `vX.Y.Z` (annotated), push with tags, do the conditional develop back-merge, delete the branch local + remote. Hotfixes bump the patch version first. -```bash -# Step 1: Ensure the integration branch is up to date -git checkout "$INTEGRATION_BRANCH" -git pull origin "$INTEGRATION_BRANCH" +**Complete when:** the work is on its target branch(es), the tag exists (release/hotfix), and the working branch is deleted both sides. -# Step 2: Create and switch to feature branch -git checkout -b feature/BRANCH_NAME +## Pre-finish checklist -# Step 3: Push to remote and set up tracking -git push -u origin feature/BRANCH_NAME -``` - -**Pre-creation validation:** -- [ ] Check no uncommitted changes: `git status` -- [ ] Verify `INTEGRATION_BRANCH` is current: `git log origin/$INTEGRATION_BRANCH..$INTEGRATION_BRANCH` (should be empty) -- [ ] Confirm the repo uses `develop` before hard-coding `develop` - -#### Release Branch (from `INTEGRATION_BRANCH`) - -```bash -# Step 1: Create release branch from integration branch -git checkout "$INTEGRATION_BRANCH" -git pull origin "$INTEGRATION_BRANCH" -git checkout -b release/vX.Y.Z - -# Step 2: Update version numbers -# Edit package.json, version files, etc. - -# Step 3: Commit version bump -git commit -am "🔖 release: bump version to X.Y.Z" - -# Step 4: Push release branch -git push -u origin release/vX.Y.Z -``` - -#### Hotfix Branch (from `DEFAULT_BRANCH`) - -```bash -# Step 1: Branch from repo default branch -git checkout "$DEFAULT_BRANCH" -git pull origin "$DEFAULT_BRANCH" -git checkout -b hotfix/BRANCH_NAME - -# Step 2: Push to remote -git push -u origin hotfix/BRANCH_NAME -``` - -**Hotfix urgency indicators:** -- 🚨 Site down / Service unavailable -- 🔐 Security vulnerability discovered -- 💥 Data corruption or loss -- ⚠️ Critical feature broken in production - ---- - -### Finish a Branch - -#### Finish Feature - -Preferred path for shared repos: - -1. Run the **PullRequest** workflow and target `INTEGRATION_BRANCH`. -2. After the PR merges, sync and clean up: - -```bash -git checkout "$INTEGRATION_BRANCH" -git pull origin "$INTEGRATION_BRANCH" -git branch -d feature/BRANCH_NAME -git push origin --delete feature/BRANCH_NAME -``` - -Only do a direct local merge when the user explicitly wants a non-PR flow and the target branch is not protected. - -#### Finish Release - -```bash -# Step 1: Merge to default branch -git checkout "$DEFAULT_BRANCH" -git pull origin "$DEFAULT_BRANCH" -git merge --no-ff release/vX.Y.Z - -# Step 2: Tag the release -git tag -a vX.Y.Z -m "Release vX.Y.Z" - -# Step 3: Push default branch with tags -git push origin "$DEFAULT_BRANCH" --tags - -# Step 4: Back-merge to develop only when develop exists and differs from default -if [ "$INTEGRATION_BRANCH" != "$DEFAULT_BRANCH" ]; then - git checkout "$INTEGRATION_BRANCH" - git pull origin "$INTEGRATION_BRANCH" - git merge --no-ff release/vX.Y.Z - git push origin "$INTEGRATION_BRANCH" -fi - -# Step 5: Clean up release branch -git branch -d release/vX.Y.Z -git push origin --delete release/vX.Y.Z -``` - -#### Finish Hotfix - -```bash -# Step 1: Bump patch version -git checkout hotfix/BRANCH_NAME -# Update version (e.g., 1.2.0 → 1.2.1) -git commit -am "🔖 hotfix: bump version to X.Y.Z" - -# Step 2: Merge to default branch -git checkout "$DEFAULT_BRANCH" -git pull origin "$DEFAULT_BRANCH" -git merge --no-ff hotfix/BRANCH_NAME - -# Step 3: Tag the hotfix -git tag -a vX.Y.Z -m "Hotfix vX.Y.Z - BRANCH_NAME" - -# Step 4: Push default branch with tags -git push origin "$DEFAULT_BRANCH" --tags - -# Step 5: Back-merge to develop only when develop exists and differs from default -if [ "$INTEGRATION_BRANCH" != "$DEFAULT_BRANCH" ]; then - git checkout "$INTEGRATION_BRANCH" - git pull origin "$INTEGRATION_BRANCH" - git merge --no-ff hotfix/BRANCH_NAME - git push origin "$INTEGRATION_BRANCH" -fi - -# Step 6: Clean up -git branch -d hotfix/BRANCH_NAME -git push origin --delete hotfix/BRANCH_NAME -``` - ---- - -## Branch Validation Rules - -**Valid branch names:** -- ✅ `feature/user-authentication` -- ✅ `release/v1.2.0` -- ✅ `hotfix/security-patch` - -**Invalid branch names:** -- ❌ `my-new-feature` (no prefix) -- ❌ `fix-bug` (wrong prefix for this workflow) - -**Branch sources:** -- Features → branch from `INTEGRATION_BRANCH` -- Releases → branch from `INTEGRATION_BRANCH` -- Hotfixes → branch from `DEFAULT_BRANCH` - -**Merge targets:** -- Features → PR to `INTEGRATION_BRANCH` -- Releases → merge/PR to `DEFAULT_BRANCH`, then back-merge to `develop` only when it exists -- Hotfixes → merge/PR to `DEFAULT_BRANCH`, then back-merge to `develop` only when it exists - ---- - -## Pre-Merge Checklist - -Before finishing any branch: -- [ ] No uncommitted changes -- [ ] Tests passing -- [ ] No merge conflicts -- [ ] Remote is up to date -- [ ] Correct target branch for this repo shape -- [ ] If using a PR workflow, issue-link / PR-template requirements are satisfied +No uncommitted changes · tests passing · no conflicts · remote current · correct target for this repo's shape · PR-template/issue-link requirements satisfied when using the PR path. diff --git a/skills/gitworkflow/workflows/CIMerge.md b/skills/gitworkflow/workflows/CIMerge.md index f67750b..9654228 100644 --- a/skills/gitworkflow/workflows/CIMerge.md +++ b/skills/gitworkflow/workflows/CIMerge.md @@ -1,279 +1,60 @@ # CI Monitor & Auto-Merge Workflow -Monitor CI checks, wait for automated reviews to settle, repair PR metadata failures when possible, and merge PRs when all gates pass. +The last mile: turn "PR created" into "PR merged" — monitor CI, let automated reviewers settle, repair metadata failures, merge when every gate passes. -## When to Use +**Spec-fidelity gate (required before any merge, 2026-08-06):** CI proves the code works, not that it's what was asked for. Before merging, diff the PR's result against the ORIGINATING spec — the issue's acceptance criteria, the ratified plan, the mock — and report every deviation or an explicit "no deviations". Deviations that expand or reshape scope need the user's yes before the merge proceeds. The `SpecFidelityGate` hook reminds once per PR per session; this paragraph is the standing requirement it points at. -- After creating a PR (automatically offered by the PullRequest workflow) -- When a user says "merge my PR", "check CI", "is CI passing", "wait for checks" -- When resuming a previously-created PR that was waiting on CI or review -- Any time a PR exists and needs to get from "open" to "merged" +## Route on current state -## Variables - -```bash -PR_NUMBER: from PullRequest workflow output, or detected from current branch -BRANCH: current git branch or specified branch -MAX_CI_WAIT: 15 minutes (default) -MAX_FIX_ATTEMPTS: 3 -SETTLE_WAIT: 240 seconds (for automated reviewers to post after checks pass; minimum 4 minutes) -``` - -## Workflow - -### Phase 1: Detect PR State - -If `PR_NUMBER` is not provided, detect it: - -```bash -BRANCH=$(git branch --show-current) -PR_NUMBER=$(gh pr list --head "$BRANCH" --json number -q '.[0].number') -``` - -If no PR found, stop: "No open PR found for branch `$BRANCH`. Create one first with /GitWorkflow PR." - -Gather current state: - -```bash -# CI status -gh pr checks "$PR_NUMBER" - -# Review / metadata state -gh pr view "$PR_NUMBER" --json reviewDecision,body,baseRefName,url,closingIssuesReferences - -# PR merge state -gh pr view "$PR_NUMBER" --json mergedAt -q '.mergedAt' -``` - -Route based on state: +Detect the PR from the branch if not given (none open → stop and point at the PullRequest workflow). Then: | State | Action | |-------|--------| -| Already merged | Report "PR already merged." Stop. | -| `PR issue link` or similar metadata check failed | Go to Phase 2D (repair PR metadata) | -| CI passing, review approved | Go to Phase 4 (merge) | -| CI passing, changes requested | Go to Phase 3 (address feedback) | -| CI passing, no review decision | Go to Phase 2B (review settlement) | -| CI pending/running | Go to Phase 2A (monitor CI) | -| CI failed | Go to Phase 2C (fix CI) | - ---- - -### Phase 2A: Monitor CI - -CI takes time to queue after a push. Do not panic if the first poll returns empty. - -```bash -sleep 15 -``` - -Poll every 30 seconds, up to `MAX_CI_WAIT`: - -```bash -ELAPSED=0 -while [ "$ELAPSED" -lt 900 ]; do - ACTIONS=$(gh run list --branch "$BRANCH" --limit 1 --json status,conclusion 2>/dev/null) - CHECKS=$(gh pr checks "$PR_NUMBER" 2>&1) - - # Parse results: all pass, metadata failure, any fail, or still pending - # ... +| Already merged | Report and stop | +| Metadata check failing (e.g. `PR issue link`) | § Metadata repair | +| CI pending/running | § Monitor | +| CI failed | § Fix CI | +| CI passing, changes requested | § Address feedback | +| CI passing, approved or no decision | § Settle, then § Merge | - sleep 30 - ELAPSED=$((ELAPSED + 30)) -done -``` - -Important: -- Poll both `gh run list` and `gh pr checks`. -- Repo-specific checks may exist outside Actions runs. -- If a metadata check such as `PR issue link` fails, route to Phase 2D instead of treating it like a code/test failure. - -If CI passes → proceed to Phase 2B. -If CI fails → proceed to Phase 2C. - ---- - -### Phase 2B: Review Settlement - -Automated reviewers (CodeRabbit, Codex, GitGuardian) analyze PRs asynchronously after CI passes. - -**Step 1 — Wait for all PR checks to complete:** - -```bash -SETTLE_ELAPSED=0 -while [ "$SETTLE_ELAPSED" -lt 180 ]; do - PENDING=$(gh pr checks "$PR_NUMBER" --json state -q '[.[] | select(.state == "PENDING")] | length' 2>/dev/null) - [ "${PENDING:-0}" -eq 0 ] && break - sleep 30 - SETTLE_ELAPSED=$((SETTLE_ELAPSED + 30)) -done -``` - -**Step 2 — Wait for automated reviews to post:** - -```bash -sleep 240 -``` - -> **Mandatory minimum:** 240 seconds. Automated reviewers (Codex, CodeRabbit, GitGuardian) often post 60–180 seconds after checks complete. Merging before this window risks missing actionable feedback. The previous 90-second window was too short in practice. +## Monitor -**Step 3 — Check for review feedback:** +CI takes ~15s to queue after a push — an empty first poll is normal. Poll every 30s up to 15 minutes, checking **both** `gh run list` and `gh pr checks`: GitHub Actions, CodeRabbit, Codex, and GitGuardian register on different surfaces, and some appear only as PR checks. Conclude "no CI configured" only after 5 minutes of empty results from both — and then stop rather than merging unverified. -```bash -REVIEW_DECISION=$(gh pr view "$PR_NUMBER" --json reviewDecision -q '.reviewDecision') -``` +## Settle (mandatory before merge) -| `reviewDecision` | Action | -|------------------|--------| -| `CHANGES_REQUESTED` | Go to Phase 3 | -| `APPROVED` | Go to Phase 4 | -| `""` (empty) | Go to Phase 4 if all checks are green | +Automated reviewers post asynchronously *after* checks pass; merging before they finish defeats them. Wait for pending checks to clear, then hold a **240-second minimum settlement window** (90s proved too short in practice). Then read `reviewDecision` — `CHANGES_REQUESTED` → § Address feedback; approved or empty → check for substantive COMMENTED reviews too, address any actionable ones, then merge. -Also check for substantive review comments even without a formal decision: +## Fix CI -```bash -gh pr view "$PR_NUMBER" --json reviews --jq '.reviews[] | select(.state == "COMMENTED" or .state == "CHANGES_REQUESTED") | {author: .author.login, state: .state}' -``` +Read the actual failed logs (`gh run view --log-failed`) — never guess from the check name. Fix, push, return to § Monitor. Maximum 3 fix attempts, then stop with the failure report. -If automated reviewers left actionable comments, address them before merging. +## Metadata repair ---- +When a repo-specific check fails on PR metadata: inspect `body` and `closingIssuesReferences`. Typical causes — prose mention without a parseable closing link, wrong template section, an unchecked `- [x] No issue required` box. Fix the body in place (`gh pr edit --body-file`), let the edit re-run checks, return to § Monitor. No valid issue path exists → stop and report; never merge around a failing metadata check. -### Phase 2C: Fix CI Failures +## Address feedback -When CI fails, do not guess — read the actual logs. +Read all review comments, address each, push, return to § Monitor (CI re-runs on new commits). Maximum 5 review cycles, then stop and report the disagreement. -```bash -RUN_ID=$(gh run list --branch "$BRANCH" --limit 1 --json databaseId,conclusion -q '.[] | select(.conclusion == "failure") | .databaseId') -gh run view "$RUN_ID" --log-failed -``` +## Merge -Attempt to fix the issue. After fixing: +Escalation ladder: -```bash -git add . && git commit -m "fix: address CI failure" && git push -``` +1. `gh pr merge --squash --delete-branch` (strategy per table below). +2. Blocked needing review → self-approve with a body noting CI is verified, retry the merge. +3. Branch protection still blocks → the one legitimate pause: report the PR URL and that external review is required; resume later with `/GitWorkflow merge`. -Return to Phase 2A to re-monitor. Maximum `MAX_FIX_ATTEMPTS` (3) before stopping with an error report. - ---- - -### Phase 2D: Repair PR Metadata Failures - -Use this when a repo-specific PR check fails because the PR body is missing valid issue metadata. - -Inspect the PR body and parsed closing links: - -```bash -gh pr view "$PR_NUMBER" --json body,closingIssuesReferences,baseRefName,url -``` - -Common failure mode: -- body contains `Closes #` or `#123` in prose, -- body uses the wrong template section, -- or the repo expects `- [x] No issue required ...` and the box is still unchecked. - -Repair path: - -1. If the PR should close an issue, update the body with a real closing line such as `Closes #123`. -2. If the repo allows a no-issue path and this PR qualifies, check the exact template box text. -3. Edit the PR body in place: - -```bash -gh pr edit "$PR_NUMBER" --body-file /tmp/pr-body.md -``` - -4. Wait for the `edited` event to rerun checks, then return to Phase 2A. - -Do **not** continue toward merge while the metadata check is failing. - -If there is no issue number and the repo does not allow a no-issue path, stop and report that the PR cannot merge until the issue linkage problem is fixed. - ---- - -### Phase 3: Address Review Feedback - -Read all review comments: - -```bash -gh pr view "$PR_NUMBER" --json reviews --jq '.reviews[]' -``` - -Address each piece of feedback. After pushing fixes, return to Phase 2A. - -Maximum 5 review cycles before stopping. - ---- - -### Phase 4: Merge - -All checks pass and no blocking reviews. Attempt merge: - -**Step 1 — Try direct merge:** - -```bash -gh pr merge "$PR_NUMBER" --squash --delete-branch -``` - -If exit code 0 → done. Report success. - -**Step 2 — If merge blocked (review required):** - -```bash -gh pr review "$PR_NUMBER" --approve --body "Self-approved: CI passing, all checks verified." -gh pr merge "$PR_NUMBER" --squash --delete-branch -``` - -If exit code 0 → done. Report success. - -**Step 3 — If self-approve fails (branch protection):** - -This is the legitimate pause point. - -```md -⏸️ PR # requires external review approval. -CI is passing. All automated checks clear. -URL: - -Resume with: /GitWorkflow merge -``` - ---- +| Branch type | Strategy | +|-------------|----------| +| `feature/*`, `hotfix/*` | squash | +| `release/*` | merge commit (preserve history) | ## Report -```md +``` ## PR Merged ✅ - -**PR:** #PR_NUMBER -**Branch:** BRANCH -**Merge:** Squash merge -**CI:** All checks passing -**Reviews:** [summary of review state] - -**URL:** +**PR:** #N **Branch:** → **Merge:** squash +**CI:** all checks passing **Reviews:** +**URL:** ``` - ---- - -## Error Handling - -| Error | Action | -|-------|--------| -| No PR found for branch | Prompt user to create PR first | -| CI not detected after 5 minutes | Report and stop | -| PR metadata / issue-link check failing | Repair body or stop with exact missing requirement | -| CI fails 3 times | Report failure logs, stop | -| Review rejected 5 times | Report "fundamental disagreement", stop | -| Merge conflicts | Attempt rebase, or report and stop | -| Branch protection blocks merge | Report PR URL, suggest manual review | - ---- - -## Merge Strategy Selection - -| Branch Type | Default Strategy | Rationale | -|-------------|------------------|-----------| -| `feature/*` | `--squash` | Clean single commit on target branch | -| `release/*` | `--merge` | Preserve release commit history | -| `hotfix/*` | `--squash` | Minimal footprint for emergency fix | diff --git a/skills/gitworkflow/workflows/CISetup.md b/skills/gitworkflow/workflows/CISetup.md new file mode 100644 index 0000000..b373929 --- /dev/null +++ b/skills/gitworkflow/workflows/CISetup.md @@ -0,0 +1,82 @@ +# CI Setup Workflow + +Scaffold GitHub Actions for a repo: detect the stack, collect parameters via `AskUserQuestion`, render `.github/workflows/*.yml` from the bundled Handlebars templates, then hand to Commit → PullRequest → CIMerge so the new CI proves itself on its own bootstrap PR. This is the setup half; CIMerge owns monitor/merge. + +## 0. Runner inventory + +Before targeting self-hosted runners, confirm the labels exist (`gh api orgs/$ORG/actions/runners`, falling back to the repo-level endpoint). Required by the default templates — Linux: `homelab-ci`; macOS: `homelab-macos`, `mac-mini-m4` (GitHub auto-adds `self-hosted`, `Linux`, `X64`, `macOS`, `ARM64` — never re-declare those). + +Labels missing → AskUserQuestion: register org runners now (recommended — generate a migration script) / use repo-level runners / all GitHub-hosted / stop. The migration script (written to `MEMORY/WORK/{slug}/runner-migration.sh`, **shown as a diff and run only on confirmation**) mints an org registration token, SSHes to the runner host (`ssh proxmox 'sudo pct exec 120 -- …'` for VM 120, `ssh macmini`), does `./svc.sh stop && ./svc.sh uninstall`, `./config.sh remove --token `, re-registers against `https://github.com/$ORG`, restarts. The homelab `github-runner-{ci,macos}/` install scripts (via `Skill("homelab")`) work with org URLs unchanged. + +## 1. Probe → confirm + +Detect the stack from manifests and configs (runtime, package manager, frameworks, DB tooling, test runners, linters, existing workflows — never clobber those silently). Confirm the profile with the user before scaffolding. + +## 2. Category menu (multiSelect) + +PR gate · Secret scan (gitleaks) · DB safety (migration diff dry-run) · Worker safety (wrangler dry-run) · E2E (Playwright vs preview) · Native build (Tauri, macOS runner) · Deploy · GitHub Release with binaries (tag-triggered) · Release notes (tag-triggered, changelog-only) · Auto-release on merge · Dependabot · Dependabot auto-merge · Local hooks (Lefthook) · Scheduled checks. + +**XOR guard:** "Auto-release on merge" is mutually exclusive with both tag-triggered Release categories — enforce before scaffolding, resolving via AskUserQuestion (auto-release for continuously-shipping apps / tag-triggered for deliberate cuts). Never write `release-auto.yml` alongside `release.yml`/`release-notes.yml`. Why: `workflows/Release.md` § Posture (the single source for the GITHUB_TOKEN/double-publish facts). + +**Dependabot is on by default** — if unselected, offer it once (weekly, grouped minor+patch; the `github-actions` ecosystem is always included even in manifest-less repos). + +## 3. Per-category parameters (AskUserQuestion, detected defaults first) + +- **All categories:** runner — `[self-hosted, homelab-ci]` recommended for test/build/lint; **GitHub-hosted for anything touching secrets (deploy/release) — ephemeral isolation is the rule**; homelab-macos only for macOS-required jobs. +- **PR gate:** test/lint/build commands, runtime version (from `engines`/`.nvmrc`). +- **DB safety:** migration tool (from probe), shadow DB secret name (default `SHADOW_DATABASE_URL`). +- **Native build / Release-with-binaries:** targets matrix, code signing (`APPLE_CERTIFICATE` + `APPLE_CERTIFICATE_PASSWORD` + `KEYCHAIN_PASSWORD` secrets), build command, artifacts glob (binary kind), draft?, opt-in self-hosted macOS (default no). +- **Release notes:** changelog path, draft?. +- **Auto-release:** initial version when untagged (default `v0.1.0`), changelog path. Bump rules are baked into the template (canonical: `templates/ci/release-auto.yml.hbs`); no question needed. +- **Dependabot:** ecosystems (pre-checked from probe; `github-actions` non-optional), schedule, group minor+patch?, offer auto-merge. +- **Auto-merge:** patch-only (default) or patch+minor; majors never auto-merge; fires only when `github.actor == 'dependabot[bot]'` and the PR gate passed. +- **Lefthook:** package manager, Rust/Python hook inclusion (auto from probe), pre-push test command. Lint/format run their **fixers** with `stage_fixed: true` (auto-fixable never blocks); typecheck and lockfile-drift are pure blockers. + +## 4. Scaffold (safety-gated) + +Per category: render the template, **show the YAML as a diff, write only on confirmation**, verify with `git diff` after. + +- **Repo Variables:** after the first self-hosted-targeting workflow, offer to set `SELF_HOSTED_LINUX` / `SELF_HOSTED_MACOS` (JSON-array strings, org-level preferred so all repos inherit). +- **Lefthook:** after writing the config, run `lefthook install` once (`bunx lefthook install` fallback) — the only place CISetup touches `.git/hooks`; hooks fire on the *next* commit. Install failure is non-fatal: surface the manual command. +- **Fork safety (every self-hosted template):** paired jobs split on `if: github.event.pull_request.head.repo.full_name == github.repository` — same-repo PRs run self-hosted, fork PRs run `ubuntu-latest`. Persistent runners + untrusted fork code = credential theft; public repos should also require approval for first-time contributors. +- **Fallback policy:** `main` jobs read `SELF_HOSTED_LINUX_AVAILABLE`, which `runner-health-check.yml.hbs` (scheduled, must run hosted) flips false when the runner is offline >5 min. + +## 5. Verify and self-test + +`actionlint` + `yamllint` when installed, `gh workflow list` to confirm GitHub sees them. Then Commit → PullRequest → CIMerge: the bootstrap PR exercises the new CI, and failures flow into CIMerge's fix loop. + +## Templates inventory (`templates/ci/`) + +| Template | Purpose | Runner | Trigger | +|----------|---------|--------|---------| +| `node-pr-gate.yml.hbs` | typecheck+lint+test+build smoke | homelab-ci | PR + push | +| `gitleaks.yml.hbs` | secret scan | homelab-ci | PR + push | +| `drizzle-migrate-diff.yml.hbs` / `prisma-migrate-diff.yml.hbs` | migration dry-run diff | homelab-ci | PR touching schema | +| `playwright-e2e.yml.hbs` | e2e vs preview URL | homelab-ci | PR after preview | +| `tauri-macos-build.yml.hbs` | macOS arm64 build smoke | homelab-macos | PR + tag | +| `wrangler-deploy-dry-run.yml.hbs` | workers dry-run | homelab-ci | PR touching workers | +| `release.yml.hbs` | build matrix → Release with binaries, body from CHANGELOG | hosted | tag + dispatch | +| `release-notes.yml.hbs` | changelog-only Release | hosted | tag `v*` + dispatch | +| `release-auto.yml.hbs` | bump→changelog→tag→self-publish (XOR with the two above) | hosted | push to main | +| `dependabot.yml.hbs` | version+security updates config | n/a | Dependabot service | +| `dependabot-auto-merge.yml.hbs` | auto-merge after CI, patch-default | hosted | Dependabot PRs | +| `lefthook.yml.hbs` | local hooks: fixers + blockers | n/a | commit/push | +| `runner-health-check.yml.hbs` | flips the AVAILABLE var | hosted (must be) | cron 5 min | + +New template = drop the `.hbs` file, add a row here and a Phase-3 question; the menu picks it up next invocation. + +## Runner conventions (house policy, 2026-05-19) + +| Host | Labels | Notes | +|------|--------|-------| +| Proxmox VM 120 (org) | `self-hosted, homelab-ci` | | +| Mac Mini M4 (org) | `self-hosted, homelab-macos, mac-mini-m4` | `tauri-macos` is a Finance-Guru-v2 repo-level override only | + +Variables: `SELF_HOSTED_LINUX` / `SELF_HOSTED_MACOS` (JSON arrays) and `SELF_HOSTED_LINUX_AVAILABLE` (string bool), org-level. **Labels are referenced in YAML only via `${{ vars.* }}`** — hardcoding leaks topology and breaks portability. + +## Gotchas (verified — CI-domain single source) + +- **Org-runner registration needs org-scope auth** (`gh auth status` must show it) or fails silently; repo-level needs only repo scope. +- **`fromJSON(vars.X)` with an undefined variable errors at *run* time, not parse time** — always fall back: `${{ fromJSON(vars.SELF_HOSTED_LINUX || '["ubuntu-latest"]') }}`. +- **Re-registering a per-repo runner at org level** requires clean uninstall first (`./svc.sh uninstall && ./config.sh remove`) or a full reinstall — the install dir's `.runner` file keeps the old repo association. +- **Actions cache is per-repo even on org runners** — sibling repos share nothing; use explicit `actions/cache` keys. diff --git a/skills/gitworkflow/workflows/Commit.md b/skills/gitworkflow/workflows/Commit.md index 6aa93b7..4273e45 100755 --- a/skills/gitworkflow/workflows/Commit.md +++ b/skills/gitworkflow/workflows/Commit.md @@ -1,269 +1,90 @@ # Commit Workflow -Smart commit workflow with submodule awareness, hook-aware strategy detection, conventional commit messages, and **auto-push to remote**. +Commit with submodule awareness, hook-aware strategy, conventional messages, auto-push, and the changelog gate. -**Default behavior:** Commits are automatically pushed to the remote repository. Use `--no-push` flag to skip pushing. +## Flags -## Variables +| Flag | Effect | +|------|--------| +| `--no-verify` | Skip pre-commit hooks and validation | +| `--no-submodules` | Skip submodule processing | +| `--no-push` | Commit only, no push | +| `-m "..."` / `--message "..."` | Use this message instead of generating one | -``` -COMMIT_OPTIONS: $ARGUMENTS -STRATEGY_MODE: auto-detected -NO_VERIFY: {{if contains COMMIT_OPTIONS "--no-verify"}}true{{else}}false{{endif}} -NO_SUBMODULES: {{if contains COMMIT_OPTIONS "--no-submodules"}}true{{else}}false{{endif}} -NO_PUSH: {{if contains COMMIT_OPTIONS "--no-push"}}true{{else}}false{{endif}} -CUSTOM_MESSAGE: {{extract message from COMMIT_OPTIONS}} -``` +No flag skips the changelog (gated by design) or the audit and CI/hooks checks (non-blocking, nothing to opt out of). -## Workflow +## 1. Submodules first (unless `--no-submodules`) -### Phase 0: Submodule Detection & Processing +If `.gitmodules` exists, find dirty submodules and, for each: commit its changes inside the submodule with a conventional message, push it, then return to the parent (whose pointer update rides in the parent commit). -**Skip if `NO_SUBMODULES` is true.** +| `git submodule status` signal | Meaning | +|-------------------------------|---------| +| `+` | New commits — parent commit updates the pointer | +| `-` | Uninitialized — `git submodule update --init` | +| `(modified/untracked content)` | Commit inside the submodule first | -1. Check if `.gitmodules` file exists in the repository root -2. If submodules exist, run `git submodule status` to detect dirty submodules - - Look for `+` prefix (submodule has new commits) or `M` in status - - Also check `git status --porcelain` for `modified: (modified content)` -3. **For each dirty submodule:** - a. Display: "📦 Found dirty submodule: `` - processing first..." - b. `cd` into the submodule directory - c. Run `git status --porcelain` inside the submodule - d. If uncommitted changes exist: - - Auto-stage with `git add .` - - Analyze changes for appropriate commit message - - Commit with conventional message + emoji - - Auto-push submodule to remote: `git push` - e. Return to parent directory -4. After all submodules processed, continue with parent repo workflow +**Complete when:** no submodule shows modified or untracked content. -#### Submodule Status Indicators +## 2. Strategy and validation -| Indicator | Meaning | Action | -|-----------|---------|--------| -| `+abc123` | Submodule has new commits not in parent | Commit parent to update pointer | -| ` abc123` | Submodule is clean | No action needed | -| `-abc123` | Submodule not initialized | Run `git submodule update --init` | -| `(modified content)` | Uncommitted changes in submodule | **Commit submodule first** | -| `(untracked content)` | Untracked files in submodule | Stage and commit submodule first | +Detect the commit strategy from the repo's formatting hooks: ---- +| Hooks | Strategy | +|-------|----------| +| No formatting hooks | PARALLEL — multiple independent commits allowed | +| Formatting hooks (non-aggressive) | COORDINATED — stage and commit sequentially | +| Aggressive formatters (e.g. prettier --write) | HYBRID — stage all, let the hook format, single commit | -### Phase 1: Parent Repository Analysis +Unless `--no-verify`: run the project's own lint/check command, alert on staged sensitive files (block those) and files >1MB (warn). -5. Run `git status --porcelain` to analyze current repository state -6. Execute formatting hook analysis to determine optimal commit strategy: +## 3. Commit and push -#### Hook-Aware Strategy Detection +Stage what belongs together (split into atomic commits when the diff mixes concerns: code vs docs vs tests). Message per the SKILL.md contract (emoji + type(scope) + `Co-Authored-By: AOJDevStudio`), or the user's `-m`. Then push, setting upstream if the branch has none — the full commit→push cycle runs without confirmation unless `--no-push`. -Analyze pre-commit hooks to determine commit strategy: +## 4. Changelog gate (ALWAYS runs — tool-contract) ```bash -# Check for formatting hooks -cat .git/hooks/pre-commit 2>/dev/null | grep -E "(prettier|eslint|black|rustfmt)" || echo "no-formatting-hooks" - -# Check for husky/lint-staged -cat package.json 2>/dev/null | grep -E "(husky|lint-staged)" || echo "no-husky" -``` - -**Strategy Selection:** - -| Hook Configuration | Strategy | Behavior | -|-------------------|----------|----------| -| No formatting hooks | PARALLEL | Can stage multiple commits independently | -| Formatting hooks (non-aggressive) | COORDINATED | Stage and commit sequentially | -| Aggressive formatting (prettier --write) | HYBRID | Stage all, let hook format, single commit | - -7. Check for `--no-verify` flag in `COMMIT_OPTIONS`, skip pre-commit checks if present - ---- - -### Phase 2: Pre-commit Validation - -**Skip if `NO_VERIFY` is true.** - -8. Run pre-commit validation (if applicable to project type): - - Node.js: `pnpm lint` (or npm/yarn) - - Python: `ruff check .` or `black --check .` - - Rust: `cargo clippy` - -9. Validate `.gitignore` configuration: - - Check for common sensitive files (.env, credentials, etc.) - - Alert if sensitive files are staged - -10. Check for large files (>1MB): - ```bash - git diff --cached --name-only | xargs -I{} du -h {} 2>/dev/null | awk '$1 ~ /M|G/ {print}' - ``` - ---- - -### Phase 3: Staging & Commit - -11. Auto-stage files with `git add .` if no files currently staged -12. Execute `git diff --staged --name-status` to analyze staged changes -13. Analyze changes for atomic commit splitting opportunities: - - Group by feature/component - - Separate docs from code - - Separate tests from implementation - -14. Generate conventional commit message: - -#### Commit Message Format - -``` - (): - -[optional body - what and why] - -Co-Authored-By: AOJDevStudio +changelog --unreleased --force # install with: uv tool install --from git+https://github.com/AojdevStudio/agentic-utilities#subdirectory=changelog changelog ``` -**Type Selection:** -- Analyze changed files to determine type -- Use emoji reference from `${PAI_DIR}/ai-docs/templates/emoji-commit-ref.yaml` - -| Changed Files | Type | Emoji | -|--------------|------|-------| -| New feature files | feat | ✨ | -| Bug fixes | fix | 🐛 | -| Documentation only | docs | 📝 | -| Test files only | test | ✅ | -| Config/tooling | chore | 🔧 | -| Refactoring | refactor | ♻️ | -| Performance | perf | ⚡ | -| Style/formatting | style | 💄 | +Rewrites only the `## [Unreleased]` section from commits since the last tag (all commits when untagged); never invents a version or tag. If `CHANGELOG.md` changed: -15. Execute commit: - ```bash - git commit {{if NO_VERIFY}}--no-verify{{endif}} -m "$(cat <<'EOF' - (): +- Fresh local commit → `git add CHANGELOG.md && git commit --amend --no-edit`, then re-push with `--force-with-lease` (the amend changed the tip SHA). +- Already-pushed/shared commit → **never rewrite published history**; stage it to ride the next commit instead. - +Tool missing → say so in one line and continue (non-blocking). - Co-Authored-By: AOJDevStudio - EOF - )" - ``` +## 5. Dependency audit (non-blocking) -16. If `CUSTOM_MESSAGE` provided, use it instead of auto-generated: - ```bash - git commit {{if NO_VERIFY}}--no-verify{{endif}} -m "CUSTOM_MESSAGE" - ``` +Invoke the **DependencyAudit** workflow. It reports open Dependabot alerts by severity or degrades to a one-line reason (disabled, 403, non-GitHub remote, no gh). It never blocks, delays, or aborts the commit. -17. Display commit summary: - ```bash - git log --oneline -1 - git diff --stat HEAD~1 - ``` +## 6. CI + hooks presence (non-blocking) ---- - -### Phase 4: Auto-Push to Remote - -**Skip if `NO_PUSH` is true.** - -18. Check if remote tracking branch exists: - ```bash - git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null - ``` - -19. If tracking branch exists, auto-push: - ```bash - git push - ``` - -20. If no tracking branch, set upstream and push: - ```bash - git push -u origin $(git rev-parse --abbrev-ref HEAD) - ``` - -21. Display push confirmation: - ```bash - echo "✅ Pushed to remote: $(git rev-parse --abbrev-ref --symbolic-full-name @{u})" - ``` - -**Default behavior:** Push is ALWAYS performed automatically unless `--no-push` flag is provided. The workflow completes the full commit→push cycle without user confirmation. - ---- - -### Phase 5: Changelog Summary (GATED) - -**⚠️ This phase ALWAYS runs. There is no flag to skip it.** Changelog awareness is non-negotiable. - -22. Run changelog dry-run to show accumulated unreleased changes: - ```bash - changelog --auto --dry-run 2>&1 - ``` - -23. If the command succeeds, display the output: - ``` - 📋 Unreleased Changes (since last tag) - ───────────────────────────────────── - [dry-run output] - - 💡 To update CHANGELOG.md: changelog --auto - 💡 To create a release: use /GitWorkflow release - ``` - -24. If no tags exist in the repo: - ``` - 📋 No tags found — changelog will cover all commits. - 💡 Create your first release: changelog 0.1.0 --auto - ``` - -25. If `changelog` command is not found: - ``` - ⚠️ Changelog CLI not found at ~/.local/bin/changelog - ``` - Continue (non-blocking). - -**Why this is gated:** Without changelog awareness, you lose track of what's accumulating between releases. This summary costs ~1 second and provides critical repo context. - ---- - -## Flags - -| Flag | Description | -|------|-------------| -| `--no-verify` | Skip pre-commit hooks and validation | -| `--no-submodules` | Skip submodule processing | -| `--no-push` | Skip auto-push (commit only, do not push to remote) | -| `--message "..."` or `-m "..."` | Use custom commit message | - ---- +If `.github/workflows/` lacks a PR-gate/release workflow, or no hook manager is configured (`lefthook.yml` / `.husky/` / `.pre-commit-config.yaml` / `core.hooksPath` all absent), surface the gap once and offer CISetup. Both present → stay silent. If lefthook is configured but not installed, run `lefthook install`. ## Report ``` ## Commit Complete -**Strategy:** STRATEGY_MODE (auto-detected) -**Submodules:** X submodules processed -**Files:** Y files committed -**Pushed:** ✅ origin/BRANCH_NAME (or ⏸️ Skipped with --no-push) -**Changelog:** 📋 Unreleased changes shown +**Strategy:** +**Submodules:** +**Pushed:** ✅ origin/ (or ⏸️ --no-push) +**Changelog:** ✍️ Unreleased section updated (or ⏸️ tool not found) +**Security:** 🛡️ +**Repo hygiene:** ✅ CI + hooks present (or 🧩 gap — /GitWorkflow CISetup) -**Commit:** - - -**Stats:** - - -**Remote:** - +**Commit:** +**Stats:** ``` ---- - -## Error Handling +## Error handling | Error | Action | |-------|--------| -| No staged changes | Auto-stage modified files, or warn if working tree clean | -| Pre-commit hook fails | Show error, abort commit (unless --no-verify) | -| Submodule push fails | Warn user, continue with parent commit | -| Large file detected | Warn user, suggest adding to .gitignore | -| Sensitive file staged | Block commit, show warning | -| Changelog tool not found | Warn user, continue (non-blocking) | -| Changelog dry-run fails | Warn user, continue (non-blocking) | +| Clean working tree | Say so; nothing to commit | +| Pre-commit hook fails | Show the error, abort (unless `--no-verify`) | +| Submodule push fails | Warn, continue with the parent commit | +| Sensitive file staged | Block, show which | +| Changelog tool/write failure | One line, continue (non-blocking) | +| Audit or hygiene check failure of any kind | One line, continue (non-blocking) | diff --git a/skills/gitworkflow/workflows/DependencyAudit.md b/skills/gitworkflow/workflows/DependencyAudit.md new file mode 100644 index 0000000..038ddd5 --- /dev/null +++ b/skills/gitworkflow/workflows/DependencyAudit.md @@ -0,0 +1,32 @@ +# Dependency Audit Workflow + +Surface open Dependabot alerts for the current repo, grouped by severity, with remediation offered. **Advisory only** — invoked by every Commit and runnable standalone, it never blocks, delays, or aborts anything: every missing precondition degrades to one clear line and the caller continues. + +## Behavior contract + +Query `gh api repos/{owner}/{repo}/dependabot/alerts -f state=open --paginate`, capturing the exit status separately so API errors degrade instead of aborting. Outcomes: + +| Outcome | Line (then continue) | +|---------|----------------------| +| Alerts found | severity rollup + detail list (below) | +| None | `✅ No open Dependabot alerts.` | +| 403 | `ℹ️ … disabled or token lacks the security_events scope.` (fix: `gh auth refresh -s security_events`) | +| 404 | `ℹ️ … not available for this repo.` | +| gh missing / unauthenticated / non-GitHub remote / any other error | one-line reason, skip | + +## Report (alerts present) + +Headline the critical/high counts; list alerts most-severe first, one line each: `[SEVERITY] package — advisory summary (url)`. Then list open Dependabot security PRs (`gh pr list --author "app/dependabot"`) so the user can act immediately. + +Remediation is **offered, never auto-acted**: enable/tune Dependabot via CISetup's dependency categories; merge listed security PRs only with explicit consent; re-run standalone via `/GitWorkflow audit`. + +``` +## Dependency Audit +**Repo:** owner/repo +**Open alerts:** N (🔴 critical: A · 🟠 high: B · 🟡 medium: C · ⚪ low: D) +**Alerts:** +**Open Dependabot PRs:** <#N title — url | none> +**Status:** ℹ️ Advisory only — the surrounding workflow proceeds regardless. +``` + +On any skip, the report collapses to the single skip line plus the Status line. diff --git a/skills/gitworkflow/workflows/DeployWorkflow.md b/skills/gitworkflow/workflows/DeployWorkflow.md index 7b4e610..c719713 100644 --- a/skills/gitworkflow/workflows/DeployWorkflow.md +++ b/skills/gitworkflow/workflows/DeployWorkflow.md @@ -1,148 +1,20 @@ # Deploy GitHub Actions Workflow -Deploy a `.github/workflows/` file (or related config) to the default branch from an isolated branch, without merging an unrelated feature branch. +Land `.github/` files on the default branch from an isolated branch, without dragging unrelated feature work along. Use when a workflow must go live before the feature branch it was authored on is ready. -## When to Use +## Invariants (the whole point) -- The user is on a feature branch with unrelated work and wants a workflow live on `main` *now* -- A workflow needs to be merged before the feature branch it was created on is ready -- Any situation where GitHub Actions files must land on the default branch independently +- The isolated branch is cut from **`origin/`**, never from the feature branch. +- The final commit contains **only** the named `.github/` files — verify with `git diff --cached --name-only` before committing. +- The original branch's uncommitted work is preserved (stash before switching, pop after) and the checkout returns to it when done. +- The PR **waits for automated reviews to settle** before merging — even config-only changes draw actionable feedback (label handling, YAML syntax, permission scopes). Reviewers post 60–180s after the PR opens; poll checks to completion rather than one long sleep, per the CIMerge settle rules. -## Variables +## Flow -```bash -WORKFLOW_FILES: # space-separated paths under .github/ (e.g., ".github/workflows/foo.yml .github/labeler-config.json") -SOURCE_COMMIT: # commit hash on the current branch that contains the workflow files (optional) -ISOLATED_BRANCH: # name for the new branch (e.g., "feat/deploy-issue-labeler") -``` +1. Stash, branch from the fetched remote default branch. +2. Bring the files over — cherry-pick `--no-commit` from the source commit, or checkout the paths from the original branch — then strip anything unrelated that rode along until only the target files are staged. +3. Commit (`feat(ci): deploy — isolated from feature work`), push, open the PR against the default branch. +4. Settle, address feedback, squash-merge with branch delete (branch protection blocks → report the URL and stop). +5. Restore: back to the original branch, pop the stash (conflicts → resolve; the stash survives as `stash@{0}`). -## Workflow - -### Phase 1: Prepare - -Save the user's current branch and working state, then create a clean isolated branch from `origin/defaultBranch`: - -```bash -ORIGINAL_BRANCH=$(git branch --show-current) - -# Stash any uncommitted changes so we can switch branches safely -git stash push -m "wip: deploy-workflow stash" - -# Create isolated branch from the latest remote default branch -DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name' 2>/dev/null || git remote show origin | sed -n '/HEAD branch/s/.*: //p') -git fetch origin "$DEFAULT_BRANCH" -git checkout -b "$ISOLATED_BRANCH" "origin/$DEFAULT_BRANCH" -``` - -### Phase 2: Extract Workflow Files - -#### Option A — Cherry-pick from existing commit - -If the workflow files already exist in a commit on the feature branch: - -```bash -git cherry-pick "$SOURCE_COMMIT" --no-commit -``` - -#### Option B — Copy from working tree - -If the files are in the working tree but not yet committed: - -```bash -# Copy each file from the stashed/original working tree -git checkout "$ORIGINAL_BRANCH" -- $WORKFLOW_FILES -``` - -### Phase 3: Clean - -Remove any unrelated files that came along (e.g., from a messy cherry-pick or branch state): - -```bash -# Unstage anything not in WORKFLOW_FILES -git reset HEAD -# Re-stage only the workflow files -git add $WORKFLOW_FILES -# Discard everything else -git checkout -- . -git clean -fd -``` - -Verify the commit contains *only* workflow files: - -```bash -git diff --cached --name-only -``` - -### Phase 4: Commit and Push - -```bash -git commit -m "feat(ci): deploy WORKFLOW_NAME - -- Deployed from isolated branch to avoid merging unrelated feature work" -git push -u origin "$ISOLATED_BRANCH" -``` - -### Phase 5: Merge to Default Branch - -Open a PR and wait for automated review feedback before merging. Even config-only changes can receive actionable review comments (e.g., stale label handling, YAML syntax, permission scopes). - -```bash -gh pr create \ - --base "$DEFAULT_BRANCH" \ - --head "$ISOLATED_BRANCH" \ - --title "feat(ci): deploy WORKFLOW_NAME" \ - --body "Isolated deployment of GitHub Actions workflow." - -PR_NUMBER=$(gh pr list --head "$ISOLATED_BRANCH" --json number -q '.[0].number') -``` - -**Wait for reviews:** - -```bash -sleep 240 -``` - -> **Mandatory minimum:** 240 seconds. Automated reviewers (Codex, CodeRabbit, GitGuardian) often post 60–180 seconds after the PR is opened. - -**Check for review feedback before merging:** - -```bash -REVIEW_DECISION=$(gh pr view "$PR_NUMBER" --json reviewDecision -q '.reviewDecision') -COMMENTS=$(gh pr view "$PR_NUMBER" --json reviews --jq '.reviews[] | select(.state == "COMMENTED" or .state == "CHANGES_REQUESTED") | {author: .author.login, state: .state}') - -echo "Review decision: $REVIEW_DECISION" -echo "Comments: $COMMENTS" -``` - -If `CHANGES_REQUESTED` or substantive `COMMENTED` reviews exist, address them before merging. Otherwise: - -```bash -gh pr merge "$PR_NUMBER" --squash --delete-branch -``` - -### Phase 6: Restore User State - -Return to the original branch and restore working tree: - -```bash -git checkout "$ORIGINAL_BRANCH" -git stash pop -``` - ---- - -## Validation Rules - -- [ ] Only files under `.github/` are in the final commit -- [ ] The isolated branch is based on `origin/$DEFAULT_BRANCH`, not the feature branch -- [ ] Uncommitted changes on the original branch are preserved via stash -- [ ] The workflow file syntax is valid (optional: run `actionlint` if available) - -## Error Handling - -| Error | Action | -|-------|--------| -| Cherry-pick includes unrelated files | Reset, then `git checkout ORIGINAL_BRANCH -- $WORKFLOW_FILES` | -| Stash pop conflicts | Resolve manually; the stash remains available as `stash@{0}` | -| PR merge blocked by branch protection | Report PR URL and stop; user must merge manually | -| Workflow file has YAML syntax errors | Run `actionlint` or push a fix commit to the isolated branch | +**Complete when:** the workflow file is on the default branch, the checkout is back on the original branch with its uncommitted work restored, and nothing else changed. diff --git a/skills/gitworkflow/workflows/IssueAnalysis.md b/skills/gitworkflow/workflows/IssueAnalysis.md index 47b7896..cb26f11 100644 --- a/skills/gitworkflow/workflows/IssueAnalysis.md +++ b/skills/gitworkflow/workflows/IssueAnalysis.md @@ -1,144 +1,41 @@ -# IssueAnalysis Workflow +# Issue Analysis Workflow -Pulls every open issue, identifies in-flight claims across worktrees, splits the backlog around a release cut (beta/mvp/v1/sprint-N), assigns issues to coding-agent worktrees, and applies a 5-label routing scheme so each agent can ask `gh issue list --label "agent:"` and only see its lane. +Pull the open backlog, detect in-flight claims across worktrees, split the backlog around a release cut, assign issues to agents, and apply label-lane routing so each agent sees only its lane via `gh issue list --label "agent:"`. -**Default behavior:** Dry-run — emits the table and label plan but does not write. Pass `--apply` to actually create labels and edit issues. +**Default: dry-run** — emit the table and label plan, write nothing. `--apply` creates labels and edits issues. Other flags: `--cut ` (default `beta`), `--agents a,b,c` (default: detect from `git worktree list`), `--yes` (skip prompts). -## Variables +## Discovery (read-only, parallel) -``` -ANALYSIS_OPTIONS: $ARGUMENTS -CUT_NAME: {{extract --cut from OPTIONS, default: "beta"}} -AGENTS: {{extract --agents=a,b,c from OPTIONS, default: detect from `git worktree list`}} -REPO: {{extract --repo from OPTIONS, default: derive from `git remote -v`}} -APPLY: {{if contains OPTIONS "--apply"}}true{{else}}false{{endif}} -SKIP_PROMPT: {{if contains OPTIONS "--yes"}}true{{else}}false{{endif}} -``` +Worktrees (agent names from the `-` path convention — non-matching paths → ask), recent branches, open PRs, the full open-issue backlog, and last comments on unclaimed issues. Every open PR + active branch is a **claim signal** tying an issue to a worktree; a single PR can bundle several issues, so read its body's `Closes #N` references. Claimed issues are locked; the rest is the unclaimed pool. -## Workflow +## Cut definition -### Phase 0: Discovery +Ask (unless `--yes`) for a one-sentence north-star: *"Beta = I open the app, it tells me what changed, and the numbers are correct."* An issue is a cut-blocker iff resolving it is a precondition for that sentence. No sentence given → fallback: `bug`-labeled or on an existing milestone's critical path = blocker, rest deferred. -Run in parallel — these are read-only: +## Assignment heuristics (unclaimed blockers) -1. `git worktree list` → enumerate sibling worktrees, infer agent names from path suffix (`-claude` → `claude`, `-codex` → `codex`, etc.). If only one worktree exists, ask the user for the agent list. -2. `git branch --sort=-committerdate | head -20` → recently active branches across worktrees. -3. `gh pr list --state open --json number,title,headRefName,author,createdAt` → in-flight PRs. -4. `gh issue list --state open --limit 100 --json number,title,labels,assignees,milestone,updatedAt` → full open backlog. -5. For any issue without a clear claim, fetch its last comment to detect manual claims (`gh issue view N --json comments,assignees`). +1. **Warm context** — the agent whose recent commits touch the same directories. +2. **File-boundary disjointness** — issues with overlapping paths never run in the same round across agents (merge conflicts). +3. **Triage inheritance** — an issue with an agent-authored plan stays with that agent. +4. **Load balance** the remainder. Surface the deciding heuristic in a `(why)` column when non-obvious. -Cross-reference results: every open PR + active non-main branch is a **claim signal** that ties an in-flight issue to a worktree. Record these as locked claims; the rest of the backlog is unclaimed. +## Rounds -### Phase 1: Cut definition +R1 = in-flight PRs and their issues; no new work for an agent until its R1 lands. R2 = one blocker per agent, file-disjoint with R1. R3 = bug-fix soak only; when the blocker label count hits zero, ship the cut tag. -Print the open backlog grouped by label. Ask the user (skip if `--yes`): +## Output (three blocks) -> What defines `` for this repo? Paste a 1-sentence north-star check. -> Example: "Beta = I open the app, the briefing tells me what changed, and the numbers are correct." +1. Routing table: issue → agent → round → labels. +2. ASCII route map (R1 → R2 → R3 lanes per agent, CUT TAG at the end). +3. Per-agent cheat-line: `gh issue list --repo --label "agent:,-blocker" --state open`. -Use the answer as the cut filter. Walk each open issue and decide cut-blocker vs deferred based on whether resolving it is a precondition for the cut sentence to be true. +## Apply (`--apply` only) -If the user provides no sentence, fall back to: **anything labeled `bug` or on the critical path of an existing milestone is cut-blocker; everything else is deferred.** +Create labels — `agent:` per *detected* agent only (never invent names), `-blocker` red `DC2626`, `post-` gray `6B7280`; palette rotation for agents: `0EA5E9`, `F97316`, `A855F7`, `10B981`, ask beyond four. Then label the issues and verify the blocker count matches the table. Dry-run instead prints the exact commands to paste. -### Phase 2: Categorize +If the repo uses a Projects v2 board, resolve owner and project number at runtime and add issues to it; unresolvable → `Project: none resolved`, skip. Never guess. -Build the routing table with these columns: +## Gotchas (verified) -| Column | Source | -|--------|--------| -| Issue # | `gh issue list` | -| Title (≤40 chars) | truncated | -| Agent | from claim signal OR file-boundary heuristic (next phase) | -| Round | 1 = in flight, 2 = ready next, 3 = soak-only | -| Label set | computed | - -### Phase 3: Assign unclaimed issues to agents - -For each unclaimed cut-blocker, propose an agent based on: - -1. **Warm context** — if an agent recently touched files in the same directory (last 10 commits on its branch), assign there. -2. **File boundary disjointness** — if two issues touch overlapping paths, do not put them in the same round across agents (would conflict at merge). -3. **Triage history** — if an issue already has a TDD plan written by a specific agent, that agent inherits it. -4. **Load balancing** — split remaining issues evenly across agents per round. - -Surface the heuristic that drove each assignment in a `(why)` column when the choice isn't obvious. - -### Phase 4: Round ordering - -- **Round 1**: in-flight PRs + their issues. No new work assigned to an agent until its R1 lands. -- **Round 2**: cut-blocker queue, one issue per agent, picked so file boundaries stay disjoint with R1. -- **Round 3**: bug-fix soak only. Once `-blocker` returns 0 open, ship the cut tag. - -### Phase 5: Output - -Print three blocks in order: - -1. **Routing table** — issue → agent → round → labels (markdown table) -2. **Route map** — ASCII Gantt-style: - ``` - ┌─ R1 ────────┐ ┌─ R2 ──────┐ ┌─ R3 ─────────┐ - →│ │ → │ │ → │ bug-fix only │ - →│ │ → │ │ → │ bug-fix only │ → CUT TAG - →│ │ → │ │ → │ bug-fix only │ - └────────────┘ └───────────┘ └──────────────┘ - ``` -3. **Per-agent cheat-line**, e.g.: - ``` - gh issue list --repo --label "agent:claude,-blocker" --state open - ``` - -### Phase 6: Apply (only if `APPLY=true`) - -1. Create the 5 labels (idempotent — `gh label create` errors if a label exists; ignore the error): - - `agent:` for each agent (cyan/orange/purple/green rotation) - - `-blocker` (red `DC2626`) - - `post-` (gray `6B7280`) - -2. Apply labels to issues. **Run as separate `gh issue edit` calls — do NOT pipe through a multi-line shell loop**, because some PreToolUse safety hooks reject heredoc-style scripts. One call per issue keeps the audit trail clean. - -3. Verify: print `gh issue list --label "-blocker" --state open --json number,title --jq length` and confirm the count matches the table. - -If `APPLY=false`, print the exact `gh label create` and `gh issue edit` commands the user would need to run, so they can paste-and-go. - -## Examples - -**Example 1: Default (dry-run, beta cut, auto-detect agents)** -``` -User: "/git-workflow --issue-analysis" -→ Detects worktrees: claude, codex, pi -→ Pulls 24 open issues, 2 open PRs -→ Asks for cut sentence; user answers -→ Prints routing table + route map + cheat-lines -→ Stops (no labels written) -``` - -**Example 2: Apply with custom cut name** -``` -User: "/git-workflow --issue-analysis --cut mvp --apply" -→ Creates labels: agent:claude, agent:codex, agent:pi, mvp-blocker, post-mvp -→ Edits 24 issues with appropriate labels -→ Verifies counts and prints final table -``` - -**Example 3: Single-agent repo** -``` -User: "analyze the issues for this repo" -→ Only one worktree found, so prompts: "List your agents (comma-separated):" -→ User: "human" -→ Skips agent: labels (only one assignee), still creates cut-blocker / post-cut split -``` - -## When to invoke this workflow - -- User says: "analyze the issues", "route issues across worktrees", "label issues for the beta cut", "plan the cut", "who should work on what", "what's left for beta/mvp/v1". -- User passes flag: `--issue-analysis` to GitWorkflow. -- A new chunk of issues just landed (e.g. after `/to-issues`) and the user wants them slotted into the existing cut plan — re-run with `--apply` to fill in only the new issues. - -## Gotchas - -- **Multi-line shell scripts can trip safety hooks.** Apply labels with one `gh issue edit` per call. Do not chain with `&` + `wait` inside a heredoc. -- **`gh label create` is not idempotent.** It errors on duplicate names. Wrap with `|| true` or check existing labels first via `gh label list --json name --jq '.[].name'`. -- **Worktree path → agent name** assumes the convention `-`. If a worktree path doesn't match, fall back to asking the user. -- **In-flight PR ≠ owned issues automatically.** A single PR can bundle multiple issues. Read the PR body for `Closes #N` / `Fixes #N` references and tie those issues to the PR's worktree. -- **Cut name is parameterized; default is `beta`.** Don't hard-code "beta" anywhere — use the `` template variable so the same workflow ships mvp / v1 / sprint-3 cuts unchanged. -- **Color rotation for >4 agents.** Default palette covers 4: cyan `0EA5E9`, orange `F97316`, purple `A855F7`, green `10B981`. Beyond that, the workflow asks the user for colors. +- **One `gh issue edit` per call** — multi-line shell loops and heredocs trip PreToolUse safety hooks, and per-issue calls keep the audit trail clean. +- **`gh label create` is not idempotent** — it errors on duplicates; `|| true` or check `gh label list` first. diff --git a/skills/gitworkflow/workflows/PullRequest.md b/skills/gitworkflow/workflows/PullRequest.md index 80d4b3c..c6f3a01 100755 --- a/skills/gitworkflow/workflows/PullRequest.md +++ b/skills/gitworkflow/workflows/PullRequest.md @@ -1,305 +1,56 @@ # Pull Request Workflow -Create and manage pull requests with GitHub CLI, repo-aware target branch detection, and PR-template / issue-link compliance. +Create PRs with repo-aware base detection and PR-template / issue-link compliance, then hand off to CIMerge. -## Variables +## 1. Base branch and pre-checks -```bash -PR_TITLE: $ARGUMENTS or auto-generated -CURRENT_BRANCH: $(git branch --show-current) -DEFAULT_BRANCH: detected from GitHub / origin HEAD -INTEGRATION_BRANCH: develop when present, otherwise DEFAULT_BRANCH -TARGET_BRANCH: derived from branch type + repo shape -ISSUE_LINK: actual closing keyword line (e.g. Closes #123) or explicit no-issue-required marker -``` - -## Workflow - -### 1. Pre-PR Checks - -```bash -CURRENT_BRANCH=$(git branch --show-current) -DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef -q '.defaultBranchRef.name' 2>/dev/null || git remote show origin | sed -n '/HEAD branch/s/.*: //p') -if git show-ref --verify --quiet refs/heads/develop || git ls-remote --exit-code --heads origin develop >/dev/null 2>&1; then - INTEGRATION_BRANCH=develop -else - INTEGRATION_BRANCH="$DEFAULT_BRANCH" -fi - -case "$CURRENT_BRANCH" in - feature/*) TARGET_BRANCH="$INTEGRATION_BRANCH" ;; - release/*|hotfix/*) TARGET_BRANCH="$DEFAULT_BRANCH" ;; - *) TARGET_BRANCH="$DEFAULT_BRANCH" ;; -esac - -# Ensure branch is pushed -git push -u origin "$CURRENT_BRANCH" - -# Check for unpushed commits -git log "origin/$CURRENT_BRANCH"..HEAD --oneline - -# Check for merge conflicts with target -git fetch origin -git merge-base --is-ancestor "origin/$TARGET_BRANCH" HEAD || echo "May have conflicts" -``` - -Stop if: -- the working tree is dirty in a way that would make the PR misleading, -- the branch obviously targets the wrong base branch, -- or the repo requires issue linkage and no valid issue path exists yet. - ---- - -### 2. Detect Repo PR Requirements - -Inspect the repo before generating the PR body: - -```bash -TEMPLATE_FILE="" -for f in .github/PULL_REQUEST_TEMPLATE.md .github/pull_request_template.md; do - [ -f "$f" ] && TEMPLATE_FILE="$f" && break -done - -rg -n "Closes #|Fixes #|Resolves #|No issue required|closing keyword|auto-close|issue link" \ - .github CONTRIBUTING.md docs/CONTRIBUTING.md 2>/dev/null || true -``` - -Rules: - -- If `TEMPLATE_FILE` exists, mirror its sections and wording. -- If the repo has a metadata check like Keepfolio's `PR issue link`, the PR body must contain either: - - a real closing keyword: `Closes #123`, `Fixes #123`, or `Resolves #123`, or - - an explicit no-issue marker exactly matching the repo template, such as `- [x] No issue required ...`. -- Never leave placeholders like `Closes #`, `#ISSUE_NUM`, or `Closes #123 (if applicable)` without replacing them. - ---- - -### 3. Determine the Issue Link Strategy - -Every PR must resolve "what GH issue does this address?" before creation. Work through this order and stop at the first hit — don't skip to "ask the user" without trying detection first. - -**Preferred order:** - -1. **User-supplied** — issue number the user gave you in the request. -2. **Auto-detect from branch name** — extract trailing/leading issue number: - ```bash - ISSUE_NUM=$(echo "$CURRENT_BRANCH" | grep -oE '(^|[/_#-])([0-9]{1,6})([/_-]|$)' | grep -oE '[0-9]+' | head -1) - ``` -3. **Auto-detect from commit messages** — scan for closing keywords already authored: - ```bash - git log "origin/$TARGET_BRANCH"..HEAD --pretty=%B | grep -oE '(Fixes|Closes|Resolves|Refs) #[0-9]+' | head -1 - ``` -4. **Open issues assigned to user** — fall back to listing for human pick: - ```bash - gh issue list --assignee @me --state open --json number,title --limit 20 - ``` -5. **No-issue path** — if the repo template allows `No issue required` and this is docs-only / dependency-only / housekeeping, mark that checkbox explicitly. Do not invent this path if the template doesn't offer it. -6. **Stop and ask** — if the repo requires an issue and none of the above resolves it, stop and tell the user to open or specify the issue first. - -**Validate the issue exists** (steps 1–4) before using it: - -```bash -gh issue view "$ISSUE_NUM" --json number,title,state >/dev/null 2>&1 \ - || { echo "Issue #$ISSUE_NUM not found in this repo — re-detect or ask user"; exit 1; } -``` - -Sanity check after choosing: - -```bash -printf '%s -' "$ISSUE_LINK" -# Must be one of: -# Closes #123 -# Fixes #123 -# Resolves #123 -# Refs #123 (related, does not close) -# - [x] No issue required ... (only when template allows) -``` - ---- - -### 4. Generate PR Content - -Analyze commits to generate the summary: - -```bash -# Get commits in this branch -git log "origin/$TARGET_BRANCH"..HEAD --oneline - -# Get changed files -git diff "origin/$TARGET_BRANCH" --name-only -``` - -Build the PR body so it satisfies both the repo template and any issue-link checks. - -**Template for repos like Keepfolio:** - -```md -## Summary -- Key change 1 -- Key change 2 -- Key change 3 - -## Linked issues -Closes #123 - -- [ ] No issue required (docs-only, dependency-only, or housekeeping) +- Integration branch = `develop` when the repo has one (local or remote), else the default branch. +- `feature/*` targets the integration branch; `release/*` and `hotfix/*` target the default branch. +- Push the branch with tracking. Stop if the tree is dirty in a way that would make the PR misleading, the branch obviously targets the wrong base, or the repo requires issue linkage and none can be resolved (below). -## Verification -- bun run typecheck -- cd app && bun run test -``` - -**No-issue-required variant:** - -```md -## Summary -- Documentation cleanup -- No product behavior changed - -## Linked issues -- [x] No issue required (docs-only, dependency-only, or housekeeping) - -## Verification -- bun run lint -``` - ---- - -### 5. Create Pull Request - -Prefer `--body-file` over giant inline heredocs when repo templates matter. - -```bash -cat >/tmp/pr-body.md <<'EOF' -## Summary -- Key change 1 -- Key change 2 +## 2. Repo requirements -## Linked issues -Closes #123 +Read `.github/PULL_REQUEST_TEMPLATE.md` (either case) and any contributing docs for issue-link rules. When a template exists, mirror its sections and wording exactly. When the repo enforces an issue-link check, the body must contain either a real closing keyword (`Closes #123` / `Fixes #123` / `Resolves #123`) or the repo's exact no-issue marker checked (`- [x] No issue required …`). **Never leave placeholders** like `Closes #` or `Closes #123 (if applicable)`. -- [ ] No issue required (docs-only, dependency-only, or housekeeping) +## 3. Issue link resolution (stop at the first hit) -## Verification -- test command 1 -- test command 2 -EOF +1. User-supplied issue number. +2. Number embedded in the branch name. +3. Closing keyword already authored in a commit message on the branch. +4. `gh issue list --assignee @me` — offer the list for a human pick. +5. The repo's no-issue path, only when the template offers it AND this is genuinely docs/dependency/housekeeping. +6. Stop and ask — the repo requires an issue and nothing resolved one. -gh pr create \ - --base "$TARGET_BRANCH" \ - --title "$PR_TITLE" \ - --body-file /tmp/pr-body.md -``` - ---- - -### 6. Verify PR Metadata Immediately - -Do not assume GitHub parsed the body the way you intended. - -```bash -gh pr view --json number,url,body,baseRefName,closingIssuesReferences -``` - -Checks: +Validate any detected issue actually exists in this repo before using it. -- If `baseRefName` is wrong, fix the PR before doing anything else. -- If the repo expects an issue-closing keyword and `closingIssuesReferences` is empty, the body is malformed or missing the real issue line — fix it immediately. -- If using the no-issue-required path, make sure the checkbox is `[x]`, not `[ ]`. +## 4. Body and creation -Fix in place when needed: +Body sections: **Summary** (key changes), **Linked issues** (the resolved line from step 3), **Verification** (the concrete commands actually run — truthful and copy-pastable), screenshots when UI changed. Prefer `--body-file` over inline heredocs when templates matter. -```bash -gh pr edit --body-file /tmp/pr-body.md -``` +## 5. Verify what GitHub parsed (required) ---- +Immediately after creation, read back `baseRefName`, `body`, and `closingIssuesReferences` — do not assume GitHub parsed the body as intended. Wrong base → fix before anything else. Closing-keyword expected but `closingIssuesReferences` empty → the body is malformed; fix in place. No-issue path → confirm the box is `[x]`. -### 7. Add Labels and Reviewers (Optional) +## 6. Sidebar metadata (required — filled or explicitly reported) -```bash -# Add labels -gh pr edit --add-label "feature,needs-review" +A PR isn't fully created while the sidebar sits empty. Source metadata from the linked issue when present, repo conventions otherwise: -# Request reviewers -gh pr edit --add-reviewer username1,username2 - -# Assign to yourself -gh pr edit --add-assignee @me -``` +- **Assignee:** the author/runner (`@me` on single-user repos). +- **Labels:** copy the issue's labels minus issue-only workflow controls (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`); add agent/automation labels only when they already exist in the repo. +- **Project:** the linked issue's own project membership is the source of truth; resolve owner and project number at runtime, never hardcode. Unresolvable → report `Project: none resolved`. +- **Milestone:** copy from the linked issue when set. +- **Reviewers:** only from explicit sources (user instruction, repo docs, CODEOWNERS — which routes review even when the sidebar looks empty). Never invent reviewers; none known → report `Reviewers: none configured`. ---- +Finish with one read-back of number/labels/assignees/milestone/projects/reviewers/closingIssues as the evidence. -### 8. Continue to CI Monitoring (Default: Yes) +## 7. Continue to CIMerge (default: yes) -After creating the PR, **automatically proceed to the CI Monitor & Merge workflow** unless the user explicitly opts out. This is the natural continuation. +Creating the PR flows straight into `workflows/CIMerge.md` unless the user said "just create the PR" / "don't merge yet" — then stop and report: ``` -→ PR created. Monitoring CI and automated reviews... -→ Read: workflows/CIMerge.md and execute Phase 2A -``` - -If the user says "just create the PR" or "don't merge yet", stop here and report without continuing. - ---- - -## PR Body Guidance - -**Summary:** -- Bullet points of key changes -- High-level overview of what the PR accomplishes - -**Linked issues:** -- Use `Closes #NUM`, `Fixes #NUM`, or `Resolves #NUM` when tied to an issue -- Use the repo's exact `No issue required` checkbox text when that path is allowed -- Avoid placeholders - -**Verification:** -- Concrete local commands actually run -- Keep it truthful and copy-pastable - -**Screenshots/Videos:** -- Add when UI changes need visual proof - ---- - -## Merge Strategies - -**Squash and Merge** (recommended for features): -- Combines all commits into one -- Keeps target branch history clean -- Use when: Many small WIP commits in feature branches - -**Merge Commit** (recommended for releases): -- Preserves complete history -- Shows all individual commits -- Use when: Release commits are already well-organized - -**Rebase and Merge** (use with caution): -- Replays commits on top of base branch -- Linear history -- Use when: Very experienced with git and repo policy allows it - ---- - -## Report (if stopping after PR creation) - -```md ## Pull Request Created - -**Title:** PR_TITLE -**Branch:** CURRENT_BRANCH → TARGET_BRANCH -**URL:** [PR link from gh output] - -**Issue Link:** ISSUE_LINK - -**Summary:** -- X commits -- Y files changed -- Z insertions, W deletions - -**Next Steps:** -- CI monitoring available: /GitWorkflow merge -- Or monitor manually at the PR URL +**Title:** … **Branch:** → **URL:** … +**Issue link:** +**Summary:** +**Next:** /GitWorkflow merge ``` diff --git a/skills/gitworkflow/workflows/Release.md b/skills/gitworkflow/workflows/Release.md index e5ea7b0..2ac7131 100755 --- a/skills/gitworkflow/workflows/Release.md +++ b/skills/gitworkflow/workflows/Release.md @@ -1,204 +1,42 @@ # Release Workflow -Manage releases with semantic versioning and changelog generation. +Semantic-versioned releases with changelog generation. Two postures, XOR — this section is the single source for the release-model facts. -## Variables +## Posture (single source of truth) -``` -VERSION: $ARGUMENTS (e.g., 1.2.0) -RELEASE_TYPE: {{major|minor|patch}} - auto-detected from commits -``` - -## Workflow - -### 1. Determine Version Bump - -Analyze commits since last release to suggest version bump: - -```bash -# Get commits since last tag -git log $(git describe --tags --abbrev=0)..HEAD --oneline -``` - -**Version Bump Rules:** - -| Commit Type | Version Bump | Example | -|-------------|--------------|---------| -| `BREAKING CHANGE:` in footer | MAJOR | v1.0.0 → v2.0.0 | -| `feat:` commits | MINOR | v1.0.0 → v1.1.0 | -| `fix:`, `perf:`, `docs:` | PATCH | v1.0.0 → v1.0.1 | - -```bash -# Check for breaking changes -git log $(git describe --tags --abbrev=0)..HEAD | grep -i "BREAKING CHANGE" - -# Check for features -git log $(git describe --tags --abbrev=0)..HEAD --grep="^✨ feat" - -# Check for fixes -git log $(git describe --tags --abbrev=0)..HEAD --grep="^🐛 fix" -``` - ---- - -### 2. Create Release Branch +**Default: auto-release on merge to `main`** via `release-auto.yml` (scaffolded by CISetup). Every merge computes the bump from commits, skips cleanly when only trivial types changed, updates `CHANGELOG.md`, tags, and **self-publishes the GitHub Release in the same run**. -```bash -# Create release branch from develop -git checkout develop -git pull origin develop -git checkout -b release/vVERSION -``` - ---- - -### 3. Update Version Files - -Update version in project files: +- **Why self-publish:** a tag pushed by the default `GITHUB_TOKEN` does NOT retrigger tag-triggered workflows (GitHub's anti-recursion rule) — so `release-auto.yml` can never hand off to a separate `release.yml`. +- **XOR:** `release-auto.yml` and the tag-triggered `release.yml`/`release-notes.yml` are mutually exclusive. Installing both double-publishes the moment a human or PAT pushes a tag. One release model per repo; CISetup enforces the choice. -**Node.js:** -```bash -npm version VERSION --no-git-tag-version -# or -pnpm version VERSION --no-git-tag-version -``` +**Fallback: manual cuts** (deliberate ship moments, release-branch QA) — the flow below, with a tag-triggered Layer-2 workflow (`release.yml` builds binaries with the Release body from the matching `CHANGELOG.md` section; `release-notes.yml` is changelog-only for libs/services). -**Python:** -```bash -# Update __version__ in __init__.py or pyproject.toml -``` +## Version bump rules (house policy — identical logic in `release-auto.yml`) -**Rust:** -```bash -# Update version in Cargo.toml -``` +Subjects match emoji-prefixed (`✨ feat: …`) and plain (`feat:`) forms alike: -Commit version bump: -```bash -git commit -am "🔖 release: bump version to VERSION" -``` - ---- - -### 4. Generate Changelog (MANDATORY — Uses `changelog` CLI) - -**⚠️ This step is GATED. You MUST use the `changelog` CLI tool. Do NOT manually construct changelogs.** - -Generate changelog from conventional commits using the automated tool: - -```bash -# Preview what will be generated (dry-run first) -changelog VERSION --auto --dry-run - -# Generate and update CHANGELOG.md (non-interactive for automation) -changelog VERSION --auto --force -``` +| Signal since last tag | Bump | +|-----------------------|------| +| `BREAKING CHANGE` in body, or `!` before the colon | MAJOR — except current major `0` → MINOR (0.x convention) | +| `feat` | MINOR | +| `fix` / `perf` | PATCH | +| only docs/style/refactor/test/chore/ci/build | **no release** — skip cleanly, no tag | +| no tags yet + releasable commits | initial version (default `v0.1.0`) | -The `changelog` CLI tool (`~/.local/bin/changelog`) automatically: -- Analyzes all commits since the last git tag -- Groups by type: Added, Fixed, Changed, Deprecated, Removed, Security -- Detects breaking changes for MAJOR version bumps -- Extracts PR numbers from commit messages -- Creates backup of existing CHANGELOG.md -- Updates version comparison links at the bottom -- Follows Keep a Changelog format +## Manual flow -**If changelog tool is not available**, fall back to manual generation: - -```bash -# List all features since last release -git log $(git describe --tags --abbrev=0)..HEAD --grep="^✨ feat" --oneline - -# List all fixes -git log $(git describe --tags --abbrev=0)..HEAD --grep="^🐛 fix" --oneline - -# List breaking changes -git log $(git describe --tags --abbrev=0)..HEAD --grep="BREAKING CHANGE" --oneline -``` - -Commit changelog: -```bash -git add CHANGELOG.md -git commit -m "📝 docs: update changelog for vVERSION" -``` - ---- - -### 5. Push Release Branch - -```bash -git push -u origin release/vVERSION -``` - ---- - -### 6. Finalize Release - -After testing and approval: - -```bash -# Merge to main -git checkout main -git pull origin main -git merge --no-ff release/vVERSION - -# Tag the release -git tag -a vVERSION -m "Release vVERSION" - -# Push main with tags -git push origin main --tags - -# Merge back to develop -git checkout develop -git pull origin develop -git merge --no-ff release/vVERSION -git push origin develop - -# Clean up -git branch -d release/vVERSION -git push origin --delete release/vVERSION -``` - ---- - -## Semantic Versioning Guide - -Format: `vMAJOR.MINOR.PATCH` (e.g., v1.2.3) - -**MAJOR** (v1.0.0 → v2.0.0): -- Breaking API changes -- Incompatible changes to public interfaces -- Removal of deprecated features -- Major architectural changes - -**MINOR** (v1.0.0 → v1.1.0): -- New features (backwards compatible) -- New functionality added -- Deprecations (but not removals) - -**PATCH** (v1.0.0 → v1.0.1): -- Bug fixes -- Security patches -- Performance improvements -- Documentation updates - ---- +1. **Determine the bump** from commits since the last tag per the table; confirm with the user when the signal is ambiguous. +2. **Release branch** `release/vX.Y.Z` from the integration branch; bump version files; commit `🔖 release: bump version to X.Y.Z`. +3. **Changelog (GATED — tool-contract):** `changelog VERSION --auto --dry-run` to preview, then `changelog VERSION --auto --force`. Never hand-construct the changelog while the tool exists — it groups by Keep-a-Changelog sections, detects breaking changes, extracts PR numbers, backs up, and maintains the comparison links. Commit as `📝 docs: update changelog for vVERSION`. (Tool unavailable → assemble the section manually from the commit log, same section grouping.) +4. **Push the release branch** for testing/approval. +5. **Finalize** per the Branch workflow's release-finish: merge to default, annotated tag, push with tags, conditional develop back-merge, delete the branch. +6. **Verify Layer 2 fired:** `gh run list --workflow= --limit 1` and `gh release view ` — a tag without a Release object means the repo has no Layer-2 workflow; run CISetup and pick one release model. ## Report ``` ## Release Created - -**Version:** vVERSION -**Type:** RELEASE_TYPE bump -**Tag:** vVERSION -**Branch:** release/vVERSION - -**Changelog:** -[summary of changes] - -**Next Steps:** -1. Test release branch -2. Get approval -3. Run: Finish release workflow +**Version:** vX.Y.Z ( bump) **Tag:** vX.Y.Z +**Changelog:**
+**Release:** ``` diff --git a/skills/gitworkflow/workflows/Submodule.md b/skills/gitworkflow/workflows/Submodule.md index e53cf2d..413f654 100755 --- a/skills/gitworkflow/workflows/Submodule.md +++ b/skills/gitworkflow/workflows/Submodule.md @@ -1,242 +1,31 @@ # Submodule Workflow -Manage git submodules: add, update, remove, and sync repositories as submodules. +Structural submodule changes: add, update, remove, status, sync. Content changes inside dirty submodules belong to the Commit workflow (its Phase 1 commits submodules first, then the parent pointer). -## Usage +## add ` [path]` -``` -/GitWorkflow submodule add [path] # Add a repo as submodule -/GitWorkflow submodule update # Update all submodules to latest -/GitWorkflow submodule remove # Remove a submodule -/GitWorkflow submodule status # Show submodule status -/GitWorkflow submodule sync # Sync submodule URLs from .gitmodules -``` - -## Variables - -``` -SUBMODULE_ACTION: {{first word of ARGUMENTS}} -SUBMODULE_URL: {{extract URL from ARGUMENTS}} -SUBMODULE_PATH: {{extract path from ARGUMENTS, or derive from URL}} -``` - ---- - -## Action: add - -Add an external repository as a submodule. - -### Workflow - -1. **Validate inputs:** - - Check URL is provided - - If path not provided, derive from URL: `repo-name` from `github.com/org/repo-name.git` - -2. **Check for conflicts:** - ```bash - # Ensure path doesn't already exist - test -e "$SUBMODULE_PATH" && echo "ERROR: Path already exists" && exit 1 - - # Ensure not already a submodule - git config --file .gitmodules --get "submodule.$SUBMODULE_PATH.url" && echo "ERROR: Already a submodule" - ``` - -3. **Add submodule:** - ```bash - git submodule add $SUBMODULE_URL $SUBMODULE_PATH - ``` - -4. **Initialize and fetch:** - ```bash - git submodule update --init --recursive $SUBMODULE_PATH - ``` - -5. **Update .gitignore if needed:** - - Check if path was in .gitignore - - If so, remove it (submodules should be tracked) - - Use `AskUserQuestion` to confirm removal - -6. **Display result:** - ``` - ✅ Submodule added: $SUBMODULE_PATH - - Remote: $SUBMODULE_URL - Commit: $(cd $SUBMODULE_PATH && git rev-parse --short HEAD) - - Files staged: - - .gitmodules - - $SUBMODULE_PATH - - 💡 Run `/git:commit` to commit the submodule addition - ``` - -### Path Conventions - -| Repo Type | Suggested Path | -|-----------|---------------| -| Related project repos | `repos/` | -| Shared libraries | `packages/` or `libs/` | -| Documentation repos | `docs/` | -| Tools/scripts | `tools/` | - ---- - -## Action: update - -Update all submodules to their latest remote commits. - -### Workflow - -1. **Fetch latest from all remotes:** - ```bash - git submodule foreach --recursive 'git fetch origin' - ``` - -2. **Check for updates:** - ```bash - git submodule foreach --recursive 'git log HEAD..origin/$(git rev-parse --abbrev-ref HEAD) --oneline' - ``` - -3. **If updates available, ask user:** - - Use `AskUserQuestion`: "Update submodules to latest?" - - Options: "Yes, update all", "Let me choose which ones", "Cancel" - -4. **Update submodules:** - ```bash - git submodule update --remote --merge - ``` +Path defaults to the repo name from the URL. Refuse when the path exists or is already a submodule. After `git submodule add` + `--init --recursive`: if the path was gitignored, offer (AskUserQuestion) to remove that ignore line — submodules must be tracked. Report the staged `.gitmodules` + path and point at the Commit workflow. -5. **Display results:** - ```bash - git submodule status - ``` +House path conventions: related repos → `repos/`; shared libraries → `packages/` or `libs/`; docs → `docs/`; tools → `tools/`. -6. **Stage and prompt for commit:** - ``` - 💡 Submodule pointers updated. Run `/git:commit` to commit the updates. - ``` +## update ---- +Fetch all submodule remotes, show what's ahead, and ask before updating (`all / choose / cancel`). Then `git submodule update --remote --merge`, show the resulting status, and point at Commit for the pointer update. -## Action: remove +## remove `` -Remove a submodule from the repository. +**Confirm with the user first** — this deletes the directory. Then the full three-step (partial removal leaves a haunted repo): -### Workflow - -1. **Validate submodule exists:** - ```bash - git config --file .gitmodules --get "submodule.$SUBMODULE_PATH.url" - ``` - -2. **Confirm with user:** - - Use `AskUserQuestion`: "Remove submodule at $SUBMODULE_PATH? This will delete the directory." - - Show current commit being tracked - -3. **Remove submodule:** - ```bash - # De-init the submodule - git submodule deinit -f $SUBMODULE_PATH - - # Remove from .git/modules - rm -rf .git/modules/$SUBMODULE_PATH - - # Remove from working tree and index - git rm -f $SUBMODULE_PATH - ``` - -4. **Display result:** - ``` - ✅ Submodule removed: $SUBMODULE_PATH - - 💡 Run `/git:commit` to commit the removal - ``` - ---- - -## Action: status - -Show detailed status of all submodules. - -### Workflow - -1. **Check for submodules:** - ```bash - test -f .gitmodules || echo "No submodules in this repository" - ``` - -2. **Display status table:** - ```bash - git submodule status --recursive - ``` - -3. **Interpret and display:** - - | Prefix | Meaning | - |--------|---------| - | ` ` (space) | Clean, at recorded commit | - | `+` | Submodule has new commits (need to commit parent) | - | `-` | Not initialized (run `git submodule update --init`) | - | `U` | Merge conflict | - -4. **Check for dirty content:** - ```bash - git submodule foreach 'git status --porcelain' - ``` - -5. **Display formatted output:** - ``` - ## Submodules Status - - | Submodule | Status | Commit | Dirty | - |-----------|--------|--------|-------| - | acp-church-media | clean | abc1234 | No | - | repos/daemon-mcp | ahead | def5678 | Yes (3 files) | - | repos/playlist-transcripts | clean | ghi9012 | No | - - 💡 Dirty submodules need: `cd && git commit` or `/git:commit` (handles automatically) - 💡 Ahead submodules need: parent commit to update pointer - ``` - ---- - -## Action: sync - -Sync submodule remote URLs after editing .gitmodules. - -### Workflow - -1. **Sync URLs:** - ```bash - git submodule sync --recursive - ``` - -2. **Display synced URLs:** - ```bash - git submodule foreach 'echo "$name: $(git remote get-url origin)"' - ``` - ---- - -## Error Handling - -| Error | Action | -|-------|--------| -| URL invalid | Validate URL format, suggest HTTPS or SSH | -| Path exists (not submodule) | Ask to convert or choose different path | -| Network error | Retry with SSH if HTTPS fails | -| Submodule not found | Show available submodules from .gitmodules | -| Permission denied | Check SSH keys, suggest HTTPS fallback | - ---- +```bash +git submodule deinit -f +rm -rf .git/modules/ +git rm -f +``` -## Integration with Commit Workflow +## status -The Commit workflow (Phase 0) automatically handles dirty submodules: +`git submodule status --recursive` plus a dirty-content sweep, rendered as a table (submodule / status / commit / dirty). Signals: space = clean at recorded commit, `+` = ahead (parent commit needed), `-` = uninitialized, `U` = merge conflict. -1. Detects modified content in submodules -2. Commits changes inside submodule first -3. Pushes submodule to its remote -4. Then updates parent repo's submodule pointer +## sync -This Submodule workflow handles **structural changes** (add/remove/sync), while Commit handles **content changes**. +`git submodule sync --recursive` after `.gitmodules` URL edits; echo each submodule's resolved remote as verification. diff --git a/skills/harness-audit/SKILL.md b/skills/harness-audit/SKILL.md index dd1499c..0ac2b2a 100644 --- a/skills/harness-audit/SKILL.md +++ b/skills/harness-audit/SKILL.md @@ -1,7 +1,9 @@ --- name: harness-audit -description: Audit a repository for autonomous-agent harness readiness and Symphony-style unattended ticket execution readiness across cold-start docs, rules, API documentation policy/ADRs, lint, hooks, tests, PR automation, repo skills, garbage-collection cadence, workflow contracts, evidence, observability, and smoke-ticket evals. Use for "harness audit", "agent-ready repo", "harness readiness", "make this repo agent-friendly", "API docs policy", "Symphony readiness", "prepare this repo for Symphony", "ticket-level agent automation", or surgical fixes for top harness gaps. -disable-model-invocation: true +description: Audit a repository for autonomous-agent harness readiness and Symphony-style unattended ticket execution readiness across cold-start docs, rules, API documentation policy/ADRs, lint, hooks, tests, PR automation, repo skills, garbage-collection cadence, workflow contracts, evidence, observability, and smoke-ticket evals. USE WHEN "harness audit", "agent-ready repo", "harness readiness", "make this repo agent-friendly", "API docs policy", "Symphony readiness", "prepare this repo for Symphony", "ticket-level agent automation", or surgical fixes for top harness gaps. +metadata: + category: agent-quality + lanes: [claude, codex, pi] --- # Harness Audit diff --git a/skills/harness-audit/references/fix-patterns.md b/skills/harness-audit/references/fix-patterns.md index c4e611c..0c7f237 100644 --- a/skills/harness-audit/references/fix-patterns.md +++ b/skills/harness-audit/references/fix-patterns.md @@ -250,9 +250,9 @@ reviews: - On `pull_request`, spawn Pi, Claude, or Codex with persona prompt - Posts comment via `gh pr comment` only when explicitly approved and credentials are available -Path A is simpler if CodeRabbit is acceptable. Path B is better if Ossie wants persona-specific reviewers (reliability persona, security persona, etc.) or to avoid CodeRabbit cost. +Path A is simpler if CodeRabbit is acceptable. Path B is better if the user wants persona-specific reviewers (reliability persona, security persona, etc.) or to avoid CodeRabbit cost. -Default: Path A unless audit found Ossie already runs CodeRabbit elsewhere and is dissatisfied. +Default: Path A unless audit found the user already runs CodeRabbit elsewhere and is dissatisfied. Verify: validate config syntax and file placement locally. Treat CodeRabbit GitHub App installation as a hard gate: verify it is installed for the repo/org, or print a clear `App not installed — config is dormant` warning and leave PR review automation as partial. Only open a draft PR with an intentional issue when the user explicitly authorizes external GitHub-side verification; otherwise document the exact manual verification command/steps. ``` diff --git a/skills/harness-audit/references/stack-rust.md b/skills/harness-audit/references/stack-rust.md index f441359..a274225 100644 --- a/skills/harness-audit/references/stack-rust.md +++ b/skills/harness-audit/references/stack-rust.md @@ -21,9 +21,7 @@ Covers: Rust 1.70+, Cargo workspaces, embedded Rust, web (axum/actix), Tauri. ## Pre-commit pattern -Do not rely on committing `.git/hooks/` directly; it is not version-controlled. Put the hook in `scripts/git-hooks/pre-commit` and add `make install-hooks` or an equivalent installer that symlinks/copies it into `.git/hooks/pre-commit`. - -`scripts/git-hooks/pre-commit`: +`.git/hooks/pre-commit`: ```sh #!/usr/bin/env bash set -euo pipefail @@ -39,19 +37,6 @@ cargo clippy --workspace --all-targets -- -D warnings # For large: skip tests in pre-commit, rely on pre-push or CI ``` -Example installer: - -```make -install-hooks: - mkdir -p .git/hooks - chmod +x scripts/git-hooks/pre-commit - @if [ -e .git/hooks/pre-commit ] && [ ! -L .git/hooks/pre-commit ]; then \ - echo ".git/hooks/pre-commit exists and is not a symlink; refusing to overwrite"; \ - exit 1; \ - fi - ln -sf ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit -``` - For larger workspaces (>30s `cargo build`), use `cargo nextest run` and only test the workspace member containing staged files. ## Test wrapper diff --git a/skills/harness-audit/references/stack-swift.md b/skills/harness-audit/references/stack-swift.md index ba98a60..742d68e 100644 --- a/skills/harness-audit/references/stack-swift.md +++ b/skills/harness-audit/references/stack-swift.md @@ -64,9 +64,7 @@ esac ## Pre-commit pattern -Do not rely on committing `.git/hooks/` directly; it is not version-controlled. Put the hook in `scripts/git-hooks/pre-commit` and add `make install-hooks` or an equivalent installer that symlinks/copies it into `.git/hooks/pre-commit`. - -`scripts/git-hooks/pre-commit`: +`.git/hooks/pre-commit`: ```sh #!/usr/bin/env bash set -euo pipefail @@ -83,20 +81,9 @@ fi # xcodebuild build -project MyApp.xcodeproj -scheme MyApp -quiet ``` -Example installer: - -```make -install-hooks: - mkdir -p .git/hooks - chmod +x scripts/git-hooks/pre-commit - @if [ -e .git/hooks/pre-commit ] && [ ! -L .git/hooks/pre-commit ]; then \ - echo ".git/hooks/pre-commit exists and is not a symlink; refusing to overwrite"; \ - exit 1; \ - fi - ln -sf ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit -``` +`chmod +x .git/hooks/pre-commit` after creating. -Run `make install-hooks` after cloning. +For team-wide enforcement (since `.git/hooks/` isn't tracked), put the script in `scripts/git-hooks/pre-commit` and add a `make install-hooks` target that symlinks them. ## CI pattern (GitHub Actions, macOS) @@ -131,7 +118,7 @@ If the project uses SwiftUI iOS 26+ APIs (Liquid Glass, etc.), pin Xcode 26+: ## Repo skills worth seeding -For Swift projects, common high-value repo skill entries (`.agents/skills/`, `.pi/skills/`, or `.claude/skills/` depending on the harness): +For Swift projects, common high-value `.claude/skills/` entries: - `add-swift-file` — wraps `scripts/add-swift-file.rb`, takes file path + target list - `check-design` — audits view code against the project's design rules