/** * Repo-level invariant: the on-demand Electron CI workflow and its reusable * build workflow MUST NOT publish to npm, create a GitHub Release, push * tags, or otherwise affect the public update channel of installed users. * * The CI dev build's version slug (`-ci...`) is * a SemVer prerelease ranked strictly below the base stable version, so * `electron-updater` with default `allowPrerelease: false` would not offer * it as an update. Defense-in-depth: this lint enforces that the workflows * themselves contain no publishing or release-creating actions, even by * accident in a future PR. * * If this test fails, remove the offending action from the workflow. If * you genuinely need to publish from CI, do it in publish.yml (which is * gated on a tag push or explicit dispatch with a version input). * * See change: add-ci-electron-on-demand-build (proposal.md Safety section, * design.md Decision 4). */ import { describe, it } from "vitest"; import fs from "node:fs"; import path from "node:path"; import url from "node:url"; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "..", "..", "..", ".."); const CI_ELECTRON_PATH = path.join(REPO_ROOT, ".github", "workflows", "ci-electron.yml"); const REUSABLE_PATH = path.join(REPO_ROOT, ".github", "workflows", "_electron-build.yml"); // Patterns whose presence indicates a side-effect we forbid in these // workflows. The reusable workflow is the SHARED build definition, so a // publishing action there would silently leak into the release flow too — // but we want it kept clean for clarity and to keep the no-side-effects // invariant easy to reason about. const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ { pattern: /softprops\/action-gh-release/, reason: "creates GitHub Releases — must only happen in publish.yml", }, { pattern: /actions\/create-release/, reason: "creates GitHub Releases — must only happen in publish.yml", }, { pattern: /\bnpm\s+publish\b/, reason: "publishes to npm — must only happen in publish.yml's publish job", }, { pattern: /\bgit\s+push\s+origin\s+v\d/, reason: "pushes a version tag — must only happen in publish.yml's prepare job", }, { pattern: /\bgit\s+tag\s+["']?v\d/, reason: "creates a version tag — must only happen in publish.yml's prepare job", }, ]; /** * Strip YAML full-line comments before scanning. Comments are legitimately * allowed to discuss what's forbidden ("No `npm publish`") without being * the forbidden thing itself. We strip lines whose first non-whitespace * char is `#`. Inline trailing comments are preserved (rare in this codebase * and risky to strip because of `run: |` shell blocks where `#` is a real * shell comment leader). */ function stripYamlComments(content: string): string { return content .split("\n") .filter((line) => !/^\s*#/.test(line)) .join("\n"); } function assertNoForbidden(filePath: string, content: string): void { const stripped = stripYamlComments(content); for (const { pattern, reason } of FORBIDDEN_PATTERNS) { const m = stripped.match(pattern); if (m) { throw new Error( `${path.basename(filePath)} contains forbidden pattern ${pattern} ` + `(${reason}). Matched: ${JSON.stringify(m[0])}. ` + `See change: add-ci-electron-on-demand-build (design.md Decision 4).`, ); } } } describe("ci-electron.yml — no side effects on registries or update channels", () => { it("ci-electron.yml contains no forbidden publishing/release actions", () => { const content = fs.readFileSync(CI_ELECTRON_PATH, "utf8"); assertNoForbidden(CI_ELECTRON_PATH, content); }); it("_electron-build.yml (shared) contains no forbidden publishing/release actions", () => { // The reusable workflow is consumed by BOTH publish.yml (release flow) // AND ci-electron.yml (on-demand). Keeping it free of publishing actions // means publishing stays cleanly in publish.yml's own jobs, never // accidentally inherited by the on-demand path. const content = fs.readFileSync(REUSABLE_PATH, "utf8"); assertNoForbidden(REUSABLE_PATH, content); }); it("ci-electron.yml only fires on workflow_dispatch (no push/pr/schedule)", () => { const content = fs.readFileSync(CI_ELECTRON_PATH, "utf8"); // Match the top-level `on:` block — must contain `workflow_dispatch:` // and MUST NOT contain `push:`, `pull_request:`, or `schedule:`. const onMatch = content.match(/^on:\s*\n((?:\s+\S.*\n)+?)(?=^\S|\n^[a-z])/m); if (!onMatch) { throw new Error("ci-electron.yml has no top-level `on:` block"); } const onBlock = onMatch[1]; if (!/workflow_dispatch:/.test(onBlock)) { throw new Error( "ci-electron.yml MUST trigger on workflow_dispatch only. Found `on:` block:\n" + onBlock, ); } for (const trigger of ["push:", "pull_request:", "schedule:", "release:"]) { if (new RegExp(`^\\s+${trigger}`, "m").test(onBlock)) { throw new Error( `ci-electron.yml MUST NOT trigger on '${trigger}'. v1 is dispatch-only ` + "to keep the no-side-effects invariant easy to reason about. " + "If a different trigger is genuinely needed, update the spec first. " + "See change: add-ci-electron-on-demand-build.\nFound `on:` block:\n" + onBlock, ); } } }); });