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
1 change: 1 addition & 0 deletions docs/expected-repository-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion packages/wbfy/src/generators/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)}
`
Expand Down
46 changes: 37 additions & 9 deletions packages/wbfy/src/generators/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -219,13 +221,20 @@ function parseOrgReusableWorkflowCall(
}

export async function generateWorkflows(rootConfig: PackageConfig): Promise<void> {
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
Expand Down Expand Up @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions packages/wbfy/src/generators/workflowRunnerPolicy.ts
Original file line number Diff line number Diff line change
@@ -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()
),
});
Comment on lines +10 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In GitHub Actions, a strategy block can be defined without a matrix (for example, to configure fail-fast or max-parallel options). Making matrix required in the Zod schema will cause workflowSchema.parse to throw a validation error and crash wbfy on such valid workflows. Making matrix optional prevents these unexpected crashes.

Suggested change
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()
),
});
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() }).optional(),
})
.nullable()
),
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 52-repository private workflow audit contains 17 strategy blocks, all with matrix; none needs this alternative shape. The repository instructions require handling only observed organization cases and fixing unsupported custom workflows rather than adding compatibility branches. This validator intentionally fails before writes when a custom workflow is outside the supported shape.


export async function assertPrivateWorkflowRunners(config: PackageConfig, workflowsPath: string): Promise<void> {
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.`
);
}
}
}
Comment on lines +28 to +63

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are several robustness and correctness improvements that can be made here:

  1. Error Handling: If a workflow file contains invalid YAML syntax, yaml.load will throw an error, crashing the entire wbfy execution. Wrapping the file reading and parsing in a try-catch block and using safeParse allows wbfy to gracefully skip invalid files or log a warning instead of crashing.
  2. Matrix Key Regex: The current regex /^\$\{\{\s*matrix\.(\w+)\s*\}\}$/u uses \w+ which does not match hyphens. Matrix keys in GitHub Actions frequently contain hyphens (e.g., runner-type or os-version). Changing this to [\w-]+ ensures correct matching.
  3. Scalar Matrix Values: A matrix key can sometimes map to a scalar value (e.g., a single string) instead of an array. Checking if the value is an array and wrapping it in an array if not makes the candidate resolution more robust.
  for (const { name: fileName } of entries.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))) {
    let parsedYaml: unknown;
    try {
      parsedYaml = yaml.load(await fs.readFile(path.join(workflowsPath, fileName), 'utf8'));
    } catch {
      continue;
    }
    const parsed = workflowSchema.safeParse(parsedYaml);
    if (!parsed.success) continue;
    const workflow = parsed.data;

    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?.matrix) {
        const key = match[1]!;
        const matrix = job.strategy.matrix;
        const matrixValue = matrix[key];
        if (matrixValue !== undefined) {
          const values = Array.isArray(matrixValue) ? matrixValue : [matrixValue];
          candidates.push(...values);
        }
        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.`
        );
      }
    }
  }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping invalid YAML or schema failures would bypass the requested fail-fast runner check, so those errors must propagate. The 52-repository audit has no hyphenated runner matrix keys; unsupported custom selections must be fixed explicitly under the repository's canonical-format rule. GitHub documents matrix axes as arrays, not scalar values: https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations . No observed caller requires the proposed scalar fallback.

}

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';
}
4 changes: 4 additions & 0 deletions packages/wbfy/test/unit/packageConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down
66 changes: 66 additions & 0 deletions packages/wbfy/test/unit/workflowRunnerPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading