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
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ inputs:
description: Apply changes instead of dry-running
required: false
default: 'false'
onboard-only:
description: Create only a missing default ruleset after privileged preflight; never modify existing protections
required: false
default: 'false'
allow-drift:
description: Report drift without failing the plan; operational errors still fail
required: false
Expand Down Expand Up @@ -48,6 +52,7 @@ runs:
TARGET_ORGANIZATION: ${{ inputs.organization }}
CONFIG_PATH: ${{ inputs.config }}
APPLY: ${{ inputs.apply }}
ONBOARD_ONLY: ${{ inputs.onboard-only }}
ALLOW_DRIFT: ${{ inputs.allow-drift }}
PUBLIC_DISCOVERY: ${{ inputs.public-discovery }}
working-directory: ${{ github.action_path }}
Expand All @@ -66,6 +71,9 @@ runs:
if [[ "$APPLY" == "true" ]]; then
args+=(--apply)
fi
if [[ "$ONBOARD_ONLY" == "true" ]]; then
args+=(--onboard-only)
fi
if [[ "$ALLOW_DRIFT" == "true" ]]; then
args+=(--allow-drift)
fi
Expand Down
51 changes: 51 additions & 0 deletions src/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type GovernanceConfig,
} from './config.ts';
import type { GovernanceClient } from './governance.ts';
import { onboardRepository, type OnboardingClient } from './onboarding.ts';
import {
planOrganization,
planRepository,
Expand Down Expand Up @@ -39,6 +40,7 @@ export async function runCli(
const repositoryArgument = option(args, '--repo');
const configPath = option(args, '--config');
const apply = args.includes('--apply');
const onboardOnly = args.includes('--onboard-only');
const allowDrift = args.includes('--allow-drift');
const publicDiscovery = args.includes('--public-discovery');
const token = environment.GITHUB_TOKEN;
Expand All @@ -53,6 +55,11 @@ export async function runCli(
return 2;
}

if (onboardOnly && (!apply || publicDiscovery)) {
output.error('--onboard-only requires --apply and an installation-scoped token');
return 2;
}

if (!token) {
output.error('GITHUB_TOKEN is required');
return 2;
Expand All @@ -79,6 +86,50 @@ export async function runCli(
topics: string[];
}) => resolveRepositoryMetadata(config, repository);

if (onboardOnly) {
// The onboarding mode is deliberately narrower than regular --apply.
// It creates only a missing common default; existing rulesets and metadata
// are never updated. Fail if caller configuration is ambiguous.
const defaults = config.defaults;
if (defaults?.policies?.length !== 1 || (defaults.rulesets?.length ?? 0) !== 0) {
output.error('Safe onboarding requires exactly one named default policy');
return 2;
}
const defaultPolicy = config.policies?.[defaults.policies[0]!];
if (!defaultPolicy) {
output.error('Default policy is not defined');
return 2;
}
if (!('classicProtection' in client) || typeof client.classicProtection !== 'function') {
output.error('A privileged branch-protection preflight is required for onboarding');
return 2;
}
const provisioner = client as OnboardingClient;
let repos;
if (repositoryArgument) {
const parts = repositoryArgument.split('/');
if (parts.length !== 2 || !parts[0] || !parts[1]) {
output.error('--repo must use OWNER/REPO');
return 2;
}
repos = [await client.getRepository(parts[0], parts[1])];
} else {
repos = await client.listManagedRepositories(organization!);
}
let reviewNeeded = false;
for (const repo of repos) {
const selected = (await resolveRulesets(repo)).find((rule) => rule.name === defaultPolicy.name);
if (!selected) {
output.error(`Missing default policy for ${repo.owner}/${repo.name}`);
return 2;
}
const result = await onboardRepository(provisioner, repo, selected);
output.log(`${result.status.toUpperCase()} ${result.repository}: ${result.reason}`);
if (result.status === 'review') reviewNeeded = true;
}
return reviewNeeded ? 1 : 0;
}

if (repositoryArgument) {
const [owner, repository, ...extra] = repositoryArgument.split('/');
if (!owner || !repository || extra.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion src/config-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ function validateRule(value: unknown, path: string): RulesetRule {
const record = expectRecord(value, path);
const type = expectString(record.type, `${path}.type`);

if (type === 'deletion' || type === 'non_fast_forward') {
if (type === 'deletion' || type === 'non_fast_forward' || type === 'required_signatures') {
rejectUnknownKeys(record, path, ['type']);
return { type };
}
Expand Down
25 changes: 24 additions & 1 deletion src/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type GitHubRepository = {
description?: unknown;
homepage?: unknown;
topics?: unknown;
default_branch?: unknown;
owner?: {
login?: unknown;
};
Expand Down Expand Up @@ -94,6 +95,7 @@ export class GitHubClient
description: typeof description === 'string' ? description : null,
homepage: typeof homepage === 'string' && homepage !== '' ? homepage : null,
topics: Array.isArray(topics) ? topics as string[] : [],
...(typeof data.default_branch === 'string' ? { defaultBranch: data.default_branch } : {}),
};
}

Expand Down Expand Up @@ -136,6 +138,7 @@ export class GitHubClient
description: typeof description === 'string' ? description : null,
homepage: typeof homepage === 'string' && homepage !== '' ? homepage : null,
topics: Array.isArray(topics) && topics.every((topic) => typeof topic === 'string') ? topics as string[] : [],
...(typeof repository.default_branch === 'string' ? { defaultBranch: repository.default_branch } : {}),
});
}
}
Expand Down Expand Up @@ -207,6 +210,26 @@ export class GitHubClient
return true;
}

/** Privileged preflight for safe first-time onboarding. Never delete legacy settings. */
async classicProtection(
owner: string,
repository: string,
branch: string,
): Promise<'absent' | 'present' | 'uninitialized'> {
const base = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/branches/${encodeURIComponent(branch)}`;
const branchResponse = await this.fetchImpl(this.url(base), { headers: this.headers() });
if (branchResponse.status === 404) return 'uninitialized';
if (!branchResponse.ok) {
throw new Error(`Cannot verify default branch for ${owner}/${repository}: HTTP ${branchResponse.status}`);
}
const protectionResponse = await this.fetchImpl(this.url(`${base}/protection`), { headers: this.headers() });
if (protectionResponse.status === 404) return 'absent';
if (!protectionResponse.ok) {
throw new Error(`Cannot inspect classic branch protection for ${owner}/${repository}: HTTP ${protectionResponse.status}`);
}
return 'present';
}

async list(
owner: string,
repository: string,
Expand Down Expand Up @@ -392,7 +415,7 @@ function parseRule(value: unknown): RulesetRule {
throw new Error('Invalid ruleset rule from GitHub');
}

if (value.type === 'deletion' || value.type === 'non_fast_forward') {
if (value.type === 'deletion' || value.type === 'non_fast_forward' || value.type === 'required_signatures') {
return { type: value.type };
}

Expand Down
78 changes: 78 additions & 0 deletions src/onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

import type { GovernanceClient } from './governance.ts';
import type { RepositoryMetadata } from './repository-classifier.ts';
import type { RepositoryRuleset } from './types.ts';
import { rulesetsEqual } from './ruleset-normalizer.ts';

export interface OnboardingClient extends GovernanceClient {
classicProtection(
owner: string,
repository: string,
branch: string,
): Promise<'absent' | 'present' | 'uninitialized'>;
}

export type OnboardingResult = {
repository: string;
status: 'created' | 'unchanged' | 'review' | 'skipped';
reason: string;
};

/**
* Only install a missing, already-classified organization default ruleset.
* Never update existing rulesets, repository metadata, or legacy protection.
*/
export async function onboardRepository(
client: OnboardingClient,
repo: RepositoryMetadata,
expected: RepositoryRuleset,
): Promise<OnboardingResult> {
const repository = `${repo.owner}/${repo.name}`;
if (repo.archived || repo.visibility !== 'public') {
return { repository, status: 'skipped', reason: 'only public, non-archived repositories are eligible' };
}
if (!repo.defaultBranch || !/^[A-Za-z0-9._/-]+$/.test(repo.defaultBranch)) {
return { repository, status: 'review', reason: 'default branch could not be verified' };
}
// Inspect legacy protection even on repositories whose common ruleset is
// already present: overlapping classic protection can defeat its bypass.
const classic = await client.classicProtection(repo.owner, repo.name, repo.defaultBranch);
if (classic !== 'absent') {
return {
repository,
status: 'review',
reason: classic === 'present' ? 'classic branch protection requires manual migration' : 'default branch not initialized',
};
}
const current = await client.list(repo.owner, repo.name);
// A conditional direct-push exception (e.g. the Nextcloud translation bot)
// must be honored by EVERY active rule protecting the default branch.
const directPushActors = expected.bypass_actors.filter(
(actor) => actor.actor_type === 'User' && actor.bypass_mode === 'always',
);
for (const rule of current) {
if (rule.name === expected.name || rule.enforcement !== 'active') continue;
if (directPushActors.some((actor) => !rule.bypass_actors.some(
(candidate) => candidate.actor_type === actor.actor_type &&
candidate.actor_id === actor.actor_id && candidate.bypass_mode === 'always',
))) {
return { repository, status: 'review', reason: `active ruleset ${rule.name} does not permit required direct-push actor` };
}
}
const matching = current.find((rule) => rule.name === expected.name);
if (matching) {
if (matching.bypass_actors_redacted || !rulesetsEqual(matching, expected)) {
return { repository, status: 'review', reason: 'existing common ruleset differs or has hidden bypass actors' };
}
return { repository, status: 'unchanged', reason: 'common ruleset already present' };
}
// A repository with any other ruleset needs human inspection, including the
// case of another active CI/DCO policy that might block a translation bot.
if (current.length > 0) {
return { repository, status: 'review', reason: 'existing additional rulesets require review' };
}
await client.create(repo.owner, repo.name, expected);
return { repository, status: 'created', reason: 'default organization ruleset created' };
}
1 change: 1 addition & 0 deletions src/repository-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export type RepositoryMetadata = {
description: string | null;
homepage: string | null;
topics: string[];
defaultBranch?: string;
};
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type RequiredStatusChecksRule = {
};

export type SimpleRule = {
type: 'deletion' | 'non_fast_forward';
type: 'deletion' | 'non_fast_forward' | 'required_signatures';
};

export type RulesetRule = PullRequestRule | RequiredStatusChecksRule | SimpleRule;
Expand Down
40 changes: 40 additions & 0 deletions tests/cli-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,46 @@ describe('runCli', () => {
expect(messages.some((m) => m.startsWith('DRIFT '))).toBe(false);
});

it('refuses onboarding without explicit privileged apply', async () => {
const errors: string[] = [];
const code = await runCli(
['--repo', 'ExampleOrg/project', '--onboard-only'],
{ GITHUB_TOKEN: 'token' },
() => { throw new Error('GitHub must not be contacted'); },
{ log: () => undefined, error: (message) => errors.push(message) },
configLoader,
);
expect(code).toBe(2);
expect(errors).toEqual(['--onboard-only requires --apply and an installation-scoped token']);
});

it('never creates product-specific CI rules during default-only onboarding', async () => {
const events: string[] = [];
class OnboardFake extends FakeClient {
override async getRepository(owner: string, repository: string): Promise<RepositoryMetadata> {
return { ...await super.getRepository(owner, repository), defaultBranch: 'main' };
}
async classicProtection() { events.push('preflight'); return 'absent' as const; }
override async create(...args: unknown[]) {
const ruleset = args[2] as RepositoryRuleset;
events.push(`create:${ruleset.name}`);
}
override async update() { throw new Error('must not update existing policy'); }
override async updateRepositoryMetadata() { throw new Error('must not change metadata'); }
}
const messages: string[] = [];
const result = await runCli(
['--repo', 'ExampleOrg/project', '--onboard-only', '--apply'],
{ GITHUB_TOKEN: 'token' },
() => new OnboardFake(),
{ log: (message) => messages.push(message), error: () => undefined },
configLoader,
);
expect(result).toBe(0);
expect(events).toEqual(['preflight', 'create:Protect branches']);
expect(messages).toEqual(['CREATED ExampleOrg/project: default organization ruleset created']);
});

it('uses apply mode only when explicitly requested', async () => {
const messages: string[] = [];

Expand Down
6 changes: 6 additions & 0 deletions tests/config-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ describe('validateGovernanceConfig', () => {
);
});

it('accepts an explicit signed-commit ruleset', () => {
const config = structuredClone(validConfig) as any;
config.policies.protected.rules.push({ type: 'required_signatures' });
expect(validateGovernanceConfig(config)).toEqual(config);
});

it('rejects unsupported rule types', () => {
const config = structuredClone(validConfig) as any;
config.policies.protected.rules = [{ type: 'creation' }];
Expand Down
Loading
Loading