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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tag>..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
Expand All @@ -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 `.<name>-incomplete-<rand>` 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
Expand Down
24 changes: 22 additions & 2 deletions src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down
66 changes: 66 additions & 0 deletions tests/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading