diff --git a/docs/expected-repository-rules.md b/docs/expected-repository-rules.md index cabdc15d4..302784a37 100644 --- a/docs/expected-repository-rules.md +++ b/docs/expected-repository-rules.md @@ -63,6 +63,7 @@ A repository that deviates from these rules is fixed manually (or by re-running - Squash-only merges with `PR_TITLE` messages, auto-merge enabled, and head-branch deletion on merge. - A `Protect main` ruleset (WillBooster-owned repositories only, excluding `reusable-workflows`) requires the `test / test` and `semantic-pr / semantic-pr` status checks — plus `test-rust / test-rust` when the repository contains a `Cargo.toml` — and forbids force-pushes and deletion. +- Private repositories use self-hosted runners. Reusable caller `runs_on` overrides use a JSON label array containing `self-hosted`; invalid overrides are removed by wbfy. Custom jobs declare self-hosted labels directly or through a simple matrix reference. Unverifiable custom selections must be fixed before wbfy runs. The only approved GitHub-hosted exception is `WillBooster/cheerlings`'s `build-desktop-apps.yml` / `build` job selecting `windows-latest`. Unknown repository visibility skips workflow generation. - CI calls the organization's own `reusable-workflows` repository (`WillBooster/…` or `WillBoosterLab/…`) at `@main`; caller jobs grant only the token permissions their callee needs (`actions: read` plus `contents: read` for Rust tests, `pull-requests: read` plus `statuses: write` for semantic PR checks, and `pull-requests: write` for close-comment updates); repository secrets follow the shared contract (`FNOX_AGE_KEY` for private repositories, `PUBLIC_FNOX_AGE_KEY` for public WillBooster repositories, `TAKUMI_GUARD_TOKEN`, and `VERDACCIO_TOKEN` only for consumers or publishers of `@willbooster-private/*` packages). - Reusable-workflow callers map only secrets declared by the selected callee revision. Callers on `@main` never pass `NPM_TOKEN` except to the release workflow, and use `GCP_SA_KEY_JSON_FOR_FIREBASE` and `DISCORD_WEBHOOK_URL_FOR_RELEASE` rather than their superseded names. Deploy callers never pass `--json` to `fly deploy` in `deploy_command`, and sync callers' `sync_params_without_dest` carries only the parameters without a `sync ` prefix. - A fixed label taxonomy (`d1`–`d5`, `p1`–`p4`, `r: …`, `s: …`, `t: …`) replaces GitHub's default labels. diff --git a/packages/wbfy/src/generators/agents.ts b/packages/wbfy/src/generators/agents.ts index e1d612716..62da952f9 100644 --- a/packages/wbfy/src/generators/agents.ts +++ b/packages/wbfy/src/generators/agents.ts @@ -124,6 +124,9 @@ function generateAgentInstruction( const issueTemplateInstruction = rootConfig.isWillBoosterRepo ? `\n- When creating an issue:\n${ISSUE_TEMPLATE_RULES.replaceAll(/^/gm, ' ')}` : ''; + const runnerInstruction = rootConfig.isWillBoosterRepo + ? '\n- Private repositories use self-hosted CI runners. Keep OS/size constraints in an explicit self-hosted label array; fix missing runner capabilities instead of switching to GitHub-hosted runners. The sole approved exception is the Windows desktop build in WillBooster/cheerlings.' + : ''; const projectName = rootConfig.packageJson?.name || path.basename(path.resolve(rootConfig.dirPath)); const baseContent = ` ## Project Information @@ -145,7 +148,7 @@ ${TEST_WRITING_RULES} - In any explanatory text (commit messages, PR descriptions, documentation, code comments, etc.), describe only the current implementation: drop any statement naming an identifier, feature, or concept you cannot confirm exists in the final diff or the current codebase (e.g., one added and later removed or renamed along the way). Whenever documentation or comments no longer match the current implementation (removed options, deprecated usage, outdated behavior), delete or rewrite them, even in files you are not otherwise changing. Mention a past state only where it is needed to understand why the current design is as it is, or when explicitly asked; files that record history by design (e.g., a changelog) are exempt${requirementsExemption}. - Use heredoc for multi-line command input (e.g., \`git commit -F -\`, \`gh pr create --body-file -\`, \`gh issue create --body-file -\`). - Put temporary files in \`.tmp\`; use \`/tmp\` only for files that must live outside the repo. -- \`AGENTS.md\`, \`CLAUDE.md\`, \`GEMINI.md\`, \`.cursor/rules/general.mdc\`, and \`.gemini/styleguide.md\` are generated from \`AGENTS_EXTRA.md\` and overwritten on every \`wbfy\` run; to change agent instructions, edit only \`AGENTS_EXTRA.md\`.${miseInstruction}${isolatedInstallInstruction}${fnoxInstruction}${cloudflareInstruction}${railwayInstruction}${playwrightTestServerInstruction} +- \`AGENTS.md\`, \`CLAUDE.md\`, \`GEMINI.md\`, \`.cursor/rules/general.mdc\`, and \`.gemini/styleguide.md\` are generated from \`AGENTS_EXTRA.md\` and overwritten on every \`wbfy\` run; to change agent instructions, edit only \`AGENTS_EXTRA.md\`.${miseInstruction}${isolatedInstallInstruction}${fnoxInstruction}${cloudflareInstruction}${railwayInstruction}${playwrightTestServerInstruction}${runnerInstruction} ${generateAgentCodingStyle(rootConfig, allConfigs)} ` diff --git a/packages/wbfy/src/generators/workflow.ts b/packages/wbfy/src/generators/workflow.ts index 350467a66..14fb6ad15 100644 --- a/packages/wbfy/src/generators/workflow.ts +++ b/packages/wbfy/src/generators/workflow.ts @@ -6,6 +6,7 @@ import path from 'node:path'; import merge from 'deepmerge'; import * as yaml from 'js-yaml'; +import { z } from 'zod'; import { logger } from '../logger.js'; import { hasFnoxSyncFailed, resolveFnoxCiAgeKeySecretName } from './fnoxToml.js'; @@ -15,6 +16,7 @@ import { combineMerge } from '../utils/mergeUtil.js'; import { moveToBottom, sortKeys } from '../utils/objectUtil.js'; import { repoResolvesPrivatePackages } from '../utils/privatePackages.js'; import { promisePool } from '../utils/promisePool.js'; +import { assertPrivateWorkflowRunners } from './workflowRunnerPolicy.js'; interface Workflow { name?: string; @@ -219,13 +221,20 @@ function parseOrgReusableWorkflowCall( } export async function generateWorkflows(rootConfig: PackageConfig): Promise { + if (!rootConfig.isRepoVisibilityKnown) { + console.warn('Skipped workflow generation because repository visibility is unknown.'); + return; + } + const workflowsPath = path.resolve(rootConfig.dirPath, '.github', 'workflows'); + if (!isReusableWorkflowsRepo(rootConfig.repository) && (await fsUtil.isConfinedWritablePath(workflowsPath))) { + await assertPrivateWorkflowRunners(rootConfig, workflowsPath); + } return logger.functionIgnoringException('generateWorkflow', async () => { if (isReusableWorkflowsRepo(rootConfig.repository)) { // Don't touch reusable-workflows repo because it hosts upstream workflow definitions. return; } - const workflowsPath = path.resolve(rootConfig.dirPath, '.github', 'workflows'); // With .github or .github/workflows symlinked outside the repository, writeYaml's guards // already refuse the writes, but readdir/rm below would still enumerate and DELETE files // outside the repository — so require the directory to resolve inside it before any @@ -866,16 +875,35 @@ function normalizeJob(config: PackageConfig, job: Job, kind: KnownKind): void { if (config.doesContainDockerfile && !job.with.ci_label && kind.startsWith('test')) { job.with.ci_label = 'large'; } - // Because github.event.repository.private is always true if job is scheduled - if (kind === 'release' || kind.startsWith('test') || kind.startsWith('deploy')) { - if (config.isPublicRepo) { + const acceptsRunnerInput = ['test', 'test-rust', 'deploy', 'release', 'run-script'].includes( + orgWorkflowCall?.workflowName ?? '' + ); + if (config.isRepoVisibilityKnown) { + if (config.isPublicRepo && acceptsRunnerInput) { job.with.github_hosted_runner = true; + } else { + delete job.with.github_hosted_runner; + } + if (!config.isPublicRepo && job.with.runs_on !== undefined) { + const labels = z + .string() + .transform((value, ctx) => { + try { + return JSON.parse(value) as unknown; + } catch { + ctx.addIssue({ code: 'custom', message: 'Expected JSON runner labels' }); + return z.NEVER; + } + }) + .pipe(z.array(z.string())) + .safeParse(job.with.runs_on); + if (labels.success && labels.data.includes('self-hosted')) { + job.with.runs_on = JSON.stringify(labels.data); + } else { + console.warn(`Removed runs_on from ${job.uses}: private repositories require a self-hosted label array.`); + delete job.with.runs_on; + } } - // An existing github_hosted_runner on a PRIVATE repository is preserved on purpose: the input - // exists precisely so a private caller can opt into GitHub-hosted runners, and wbfy must not - // revert that manual choice (only the other kinds below never take the input). - } else { - delete job.with.github_hosted_runner; } if (Object.keys(job.with).length > 0) { diff --git a/packages/wbfy/src/generators/workflowRunnerPolicy.ts b/packages/wbfy/src/generators/workflowRunnerPolicy.ts new file mode 100644 index 000000000..18fccf0fc --- /dev/null +++ b/packages/wbfy/src/generators/workflowRunnerPolicy.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import * as yaml from 'js-yaml'; +import { z } from 'zod'; + +import type { PackageConfig } from '../packageConfig.js'; + +const runnerSchema = z.union([z.string(), z.array(z.string())]); +const workflowSchema = z.object({ + jobs: z.record( + z.string(), + z + .object({ + 'runs-on': runnerSchema.optional(), + strategy: z.object({ matrix: z.record(z.string(), z.unknown()) }).optional(), + }) + .nullable() + ), +}); + +export async function assertPrivateWorkflowRunners(config: PackageConfig, workflowsPath: string): Promise { + if (config.isPublicRepo || !config.isRepoVisibilityKnown) return; + const entries = await fs.readdir(workflowsPath, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return []; + throw error; + }); + for (const { name: fileName } of entries.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))) { + const workflow = workflowSchema.parse(yaml.load(await fs.readFile(path.join(workflowsPath, fileName), 'utf8'))); + for (const [jobName, job] of Object.entries(workflow.jobs)) { + if (!job?.['runs-on']) continue; + const runner = job['runs-on']; + if (isSelfHosted(runner)) continue; + const match = typeof runner === 'string' && /^\$\{\{\s*matrix\.(\w+)\s*\}\}$/u.exec(runner); + const candidates: unknown[] = []; + if (match && job.strategy) { + const key = match[1]!; + const matrix = job.strategy.matrix; + const values = z.array(z.unknown()).safeParse(matrix[key]); + if (values.success) candidates.push(...values.data); + const include = z.array(z.record(z.string(), z.unknown())).safeParse(matrix.include); + if (include.success) + candidates.push(...include.data.map((row) => row[key]).filter((value) => value !== undefined)); + } else { + candidates.push(runner); + } + const allowed = + candidates.length > 0 && + candidates.every( + (candidate) => + isSelfHosted(candidate) || + (config.repository?.toLowerCase() === 'github:willbooster/cheerlings' && + fileName === 'build-desktop-apps.yml' && + jobName === 'build' && + candidate === 'windows-latest') + ); + if (!allowed) { + throw new Error( + `${fileName}: jobs.${jobName}.runs-on must select self-hosted runners in a private repository. Fix the workflow before running wbfy.` + ); + } + } + } +} + +function isSelfHosted(runner: unknown): boolean { + const labels = runnerSchema.safeParse(runner); + if (!labels.success) return false; + if (Array.isArray(labels.data)) return labels.data.includes('self-hosted'); + return labels.data === 'self-hosted'; +} diff --git a/packages/wbfy/test/unit/packageConfig.test.ts b/packages/wbfy/test/unit/packageConfig.test.ts index 526778462..34b136866 100644 --- a/packages/wbfy/test/unit/packageConfig.test.ts +++ b/packages/wbfy/test/unit/packageConfig.test.ts @@ -82,6 +82,8 @@ jobs: const config = await getPackageConfig(packageDirPath); if (!config) throw new Error('unreachable'); + config.isRepoVisibilityKnown = true; + config.isPublicRepo = true; expect(config?.cargoTomlDirPaths).toEqual([]); await generateWorkflows(config); await promisePool.promiseAll(); @@ -114,6 +116,8 @@ jobs: const config = await getPackageConfig(packageDirPath); if (!config) throw new Error('unreachable'); + config.isRepoVisibilityKnown = true; + config.isPublicRepo = true; await generateWorkflows(config); await promisePool.promiseAll(); diff --git a/packages/wbfy/test/unit/workflowRunnerPolicy.test.ts b/packages/wbfy/test/unit/workflowRunnerPolicy.test.ts new file mode 100644 index 000000000..55a949603 --- /dev/null +++ b/packages/wbfy/test/unit/workflowRunnerPolicy.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { YAML } from 'bun'; +import { expect, test } from 'bun:test'; +import { z } from 'zod'; + +import { generateWorkflows } from '../../src/generators/workflow.js'; +import { promisePool } from '../../src/utils/promisePool.js'; +import { withTempWorkflowsRepo } from '../helpers/callerWorkflow.js'; +import { createConfig } from '../helpers/testConfig.js'; + +const callerSchema = z.object({ + jobs: z.record(z.string(), z.object({ with: z.record(z.string(), z.unknown()).optional() })), +}); + +test('rewriting private callers removes hosted overrides without losing custom runner constraints', async () => { + await withTempWorkflowsRepo('wbfy-private-runners-', async (dirPath, workflowsPath) => { + const labels = ['self-hosted', 'macOS', 'large']; + const original = { + jobs: { + test: { + uses: 'WillBooster/reusable-workflows/.github/workflows/test.yml@main', + with: { + github_hosted_runner: true, + runs_on: JSON.stringify('ubuntu-22.04'), + custom_test_command: 'bun run test/ci', + }, + }, + custom: { + uses: 'WillBooster/reusable-workflows/.github/workflows/run-script.yml@main', + with: { github_hosted_runner: true, runs_on: JSON.stringify(labels) }, + }, + }, + }; + const filePath = path.join(workflowsPath, 'custom.yml'); + fs.writeFileSync(filePath, YAML.stringify(original)); + const config = createConfig({ dirPath, isRoot: true, isPublicRepo: false }); + await generateWorkflows(config); + await promisePool.promiseAll(); + const written = fs.readFileSync(filePath, 'utf8'); + const { jobs } = callerSchema.parse(YAML.parse(written)); + for (const job of Object.values(jobs)) expect(job.with?.github_hosted_runner).toBeUndefined(); + expect(jobs.test?.with?.runs_on).toBeUndefined(); + expect(jobs.test?.with?.custom_test_command).toBe(original.jobs.test.with.custom_test_command); + expect(JSON.parse(String(jobs.custom?.with?.runs_on))).toEqual(labels); + await generateWorkflows(config); + await promisePool.promiseAll(); + expect(fs.readFileSync(filePath, 'utf8')).toBe(written); + }); +}); + +test('private custom runner violations stop generation before any workflow is rewritten', async () => { + await withTempWorkflowsRepo('wbfy-private-custom-', async (dirPath, workflowsPath) => { + const filePath = path.join(workflowsPath, 'custom.yaml'); + const content = `jobs:\n build:\n strategy:\n matrix:\n runner: [ubuntu-latest, macos-latest]\n runs-on: \${{ matrix.runner }}\n steps:\n - run: echo build\n`; + fs.writeFileSync(filePath, content); + await assert.rejects( + generateWorkflows(createConfig({ dirPath, isRoot: true, isPublicRepo: false })), + /jobs\.build\.runs-on/u + ); + expect(fs.readFileSync(filePath, 'utf8')).toBe(content); + expect(fs.readdirSync(workflowsPath)).toEqual(['custom.yaml']); + }); +});