diff --git a/README.md b/README.md index 52d99df..352e15b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Part of: **Human-Controlled AI Systems** — scaffolding is the easy half. What - **MCP Registry entry** — `io.github.starter-series/create-starter`, OIDC-verified namespace, npm tarball cross-checked. - **`audit_release`** — detects matched starter, version vs last-tag drift, CHANGELOG drift vs merged PRs (`git log ..HEAD`), publish-workflow kind (release-please / publish-on-tag / auto-release). - **`audit_cd`** — probes npm, PyPI, Open VSX, VS Marketplace, AMO, GitHub Releases for per-destination publish drift (in-sync / needs-publish / local-stale / not-found / unsupported). -- **`audit_security`** — checks 8 CI hygiene items: gitleaks (with pin check), CodeQL, dependency audit, license check, `--ignore-scripts`, Dependabot grouped, secret-scanning hint, claude-code-security-review Action. This repo passes 8/8 HARDENED. +- **`audit_security`** — checks 9 items: 8 core CI primitives (gitleaks with pin check, CodeQL, dependency audit, license check, `--ignore-scripts`, Dependabot grouped, secret-scanning hint, claude-code-security-review Action) plus the optional repo-author `claude-security-guidance.md`. The 8 core checks gate the HARDENED verdict; this repo passes 8/8 core. - **Graduation guide** — `docs/graduation-from-vibe-coding.md` (+ Korean): five-step path from Lovable/Bolt/v0 exports to GitHub Actions + a real deploy target, using the three audit primitives. ## Planned @@ -27,7 +27,7 @@ Part of: **Human-Controlled AI Systems** — scaffolding is the easy half. What - **One binary, two surfaces.** CLI and MCP stdio share one scaffolding engine. Argv decides which surface answers. No duplicated logic for "the same thing called from a human vs an agent". - **Atomic on failure.** Extraction happens in a sibling `.-incomplete-` directory and only renames into the final path on success. Network failure, corrupt archive, partial write — none of them leaves a half-scaffolded directory behind. - **Audit is first-class.** Templates ship a security baseline (gitleaks pinned to SHA, CodeQL, Dependabot grouped, `--ignore-scripts`, claude-code-security-review). The three audit commands check whether a downstream repo still matches that bar — turning the baseline from a one-time scaffold into an ongoing gate. -- **Eat your own dogfood.** This repo passes `audit_security` 8/8 HARDENED. If the tool that audits other repos can't pass its own check, the bar isn't real. +- **Eat your own dogfood.** This repo passes `audit_security` 8/8 core checks (HARDENED); the 9th is the optional `claude-security-guidance.md`. If the tool that audits other repos can't pass its own bar, the bar isn't real. - **Read-only outside its sandbox.** Downloads are capped (50 MB, 30 s timeout, 3 retries). Relative output paths cannot escape cwd; absolute paths are accepted only as explicit user intent. `git init` failure is logged but non-fatal. ## Non-goals diff --git a/src/audit.ts b/src/audit.ts index 4e8164a..c5548ab 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -125,11 +125,31 @@ function detectPublishWorkflow(repoPath: string): PublishWorkflowReport { return { files: [], likelyKind: "missing" }; } + // Detect publish/release workflows by CONTENT, not just filename. Many repos + // name their CD workflow `cd.yml` (or `cd-ios.yml`, `cd-firefox.yml`), which + // no `release|publish|deploy` filename keyword matches — a filename-only + // filter false-negatives them and yields a bogus "no publish workflow" + // blocker. Keep the filename keywords as a fast path; otherwise fall back to + // recognized publish/release actions and commands in the file body. + const filenameHit = (f: string) => + /(?:release|publish|deploy)/i.test(f) || /(?:^|[-_])cd(?:[-_.]|$)/i.test(f); + const publishContent = + /(?:\b(?:npm|pnpm|yarn)\s+publish\b|\bvsce\s+publish\b|\bovsx\s+publish\b|\beas\s+submit\b|gh-action-pypi-publish|action-gh-release|\bgh\s+release\s+create\b|\btwine\s+upload\b|wrangler[^\n]*\bdeploy\b|docker\/build-push-action)/i; + const candidates: { file: string; content: string }[] = []; for (const f of entries) { - if (!/(release|publish|deploy)/i.test(f)) continue; const content = safeReadText(join(dir, f)); - if (content) candidates.push({ file: f, content }); + if (!content) continue; + // Match publish signals against non-comment lines only, so a workflow that + // merely *mentions* a publish action in a comment (e.g. update-changelog.yml + // explaining the release flow) is not misdetected as a publisher. + const codeOnly = content + .split("\n") + .filter((l) => !/^\s*#/.test(l)) + .join("\n"); + if (filenameHit(f) || publishContent.test(codeOnly)) { + candidates.push({ file: f, content }); + } } if (candidates.length === 0) return { files: [], likelyKind: "missing" }; diff --git a/tests/audit.test.ts b/tests/audit.test.ts index 0c75cd2..49822e8 100644 --- a/tests/audit.test.ts +++ b/tests/audit.test.ts @@ -204,6 +204,72 @@ describe("auditRelease — publish workflow detection", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("detects a cd.yml publish workflow (filename has no release/publish/deploy keyword)", async () => { + const dir = makeRepo(); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "x", version: "1.0.0" }), + ); + mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(dir, ".github", "workflows", "cd.yml"), + `name: CD\non:\n workflow_dispatch: {}\njobs:\n publish:\n steps:\n - run: npm publish --provenance --access public\n`, + ); + const r = await auditRelease(dir); + assert.notEqual(r.publishWorkflow.likelyKind, "missing"); + assert.equal(r.publishWorkflow.likelyKind, "publish-manual"); + assert.deepEqual(r.publishWorkflow.files, ["cd.yml"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("detects a publish workflow by content alone (no filename keyword)", async () => { + const dir = makeRepo(); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "x", version: "1.0.0" }), + ); + mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); + // Filename matches none of release|publish|deploy|cd; detection must come + // from the action-gh-release step in the body. + writeFileSync( + join(dir, ".github", "workflows", "ship.yml"), + `name: Ship\non:\n release:\n types: [published]\njobs:\n go:\n steps:\n - uses: softprops/action-gh-release@v2\n`, + ); + const r = await auditRelease(dir); + assert.notEqual(r.publishWorkflow.likelyKind, "missing"); + assert.deepEqual(r.publishWorkflow.files, ["ship.yml"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not detect a workflow that only mentions a publish action in a comment", async () => { + const dir = makeRepo(); + try { + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "x", version: "1.0.0" }), + ); + mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); + // A changelog-mirror workflow that *explains* the release flow in a + // comment must not be misclassified as a publisher (regression: a bare + // `action-gh-release` mention in a comment used to match). + writeFileSync( + join(dir, ".github", "workflows", "update-changelog.yml"), + `name: Update CHANGELOG\n# Released repos use softprops/action-gh-release with generate_release_notes.\non:\n release:\n types: [published]\njobs:\n go:\n steps:\n - run: git push\n`, + ); + const r = await auditRelease(dir); + assert.equal(r.publishWorkflow.likelyKind, "missing"); + assert.deepEqual(r.publishWorkflow.files, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("auditRelease — format", () => {