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
10 changes: 5 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@ runs:
fi

node src/cli.ts "${args[@]}" | tee "$RUNNER_TEMP/github-governance-result.txt"
if [[ "$ALLOW_DRIFT" == "true" ]] && grep -q '^DRIFT ' "$RUNNER_TEMP/github-governance-result.txt"; then
echo '::warning::Governance drift detected; review the plan before any manual apply'
if [[ "$ALLOW_DRIFT" == "true" ]] && grep -Eq '^(DRIFT|UNVERIFIED) ' "$RUNNER_TEMP/github-governance-result.txt"; then
echo '::warning::Governance has drift or bypass actors that need privileged verification'
{
echo '### Repository governance drift'
echo 'Review these repositories before approving an apply:'
echo '### Repository governance plan'
echo 'DRIFT means confirmed differences; UNVERIFIED means GitHub concealed bypass actors.'
echo '```text'
grep '^DRIFT ' "$RUNNER_TEMP/github-governance-result.txt"
grep -E '^(DRIFT|UNVERIFIED) ' "$RUNNER_TEMP/github-governance-result.txt"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
15 changes: 12 additions & 3 deletions src/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,20 +112,29 @@ function writePlans(
): void {
for (const plan of plans) {
const changes = plan.changes.filter(
(change) => change.action !== 'unchanged',
(change) => change.action === 'create' || change.action === 'update',
);
const unverified = plan.changes.filter(
(change) => change.action === 'unverified',
);

const metadataDrift = plan.metadata?.action === 'update';

if (changes.length === 0 && !metadataDrift) {
output.log(`OK ${plan.repository}`);
output.log(`${unverified.length > 0 ? 'UNVERIFIED' : 'OK'} ${plan.repository}`);
for (const change of unverified) {
output.log(` - bypass actors hidden: ${change.current.name}`);
}
continue;
}

output.log(`${apply ? 'APPLIED' : 'DRIFT'} ${plan.repository}`);
for (const change of changes) {
output.log(` - ${change.action}: ${change.desired.name}`);
}
for (const change of unverified) {
output.log(` - bypass actors hidden: ${change.current.name}`);
}
if (metadataDrift) {
output.log(` - update metadata: ${plan.metadata!.fields.join(', ')}`);
}
Expand All @@ -137,7 +146,7 @@ function hasDrift(
): boolean {
return plans.some(
(plan) =>
plan.changes.some((change) => change.action !== 'unchanged') ||
plan.changes.some((change) => change.action === 'create' || change.action === 'update') ||
plan.metadata?.action === 'update',
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ function parseExistingRuleset(value: unknown): ExistingRepositoryRuleset {
throw new Error('Invalid ruleset response from GitHub');
}

const bypassActorsVisible = Array.isArray(value.bypass_actors);
const bypassActors = Array.isArray(value.bypass_actors)
? value.bypass_actors.map(parseBypassActor)
: [];
Expand All @@ -346,6 +347,7 @@ function parseExistingRuleset(value: unknown): ExistingRepositoryRuleset {
target,
enforcement,
bypass_actors: bypassActors,
...(bypassActorsVisible ? {} : { bypass_actors_redacted: true }),
conditions: {
ref_name: {
include: stringArray(conditions.ref_name.include),
Expand Down
6 changes: 6 additions & 0 deletions src/governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export async function syncRepository(
desiredRulesets,
desiredMetadata,
);
const unverified = plan.changes.find(
(change) => change.action !== 'create' && change.current.bypass_actors_redacted,
);
if (unverified && unverified.action !== 'create') {
throw new Error(`Cannot apply ruleset with hidden bypass actors: ${unverified.current.name}`);
}
const desired = plan.changes.flatMap((change) =>
change.action === 'unchanged' ? [change.current] : [change.desired],
);
Expand Down
19 changes: 17 additions & 2 deletions src/ruleset-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { rulesetsEqual } from './ruleset-normalizer.ts';

export type ExistingRepositoryRuleset = RepositoryRuleset & {
id: number;
// GitHub hides bypass_actors unless the caller can write the ruleset.
bypass_actors_redacted?: boolean;
};

export interface RepositoryRulesetClient {
Expand All @@ -31,7 +33,8 @@ export type RulesetChange =
current: ExistingRepositoryRuleset;
desired: RepositoryRuleset;
}
| { action: 'unchanged'; current: ExistingRepositoryRuleset };
| { action: 'unchanged'; current: ExistingRepositoryRuleset }
| { action: 'unverified'; current: ExistingRepositoryRuleset; desired: RepositoryRuleset };

export function planRepositoryRulesets(
existing: ExistingRepositoryRuleset[],
Expand All @@ -46,7 +49,12 @@ export function planRepositoryRulesets(
return { action: 'create', desired };
}

if (rulesetsEqual(current, desired)) {
if (current.bypass_actors_redacted) {
// Compare only the fields GitHub actually exposed; bypass must be verified by Apply.
if (rulesetsEqual({ ...current, bypass_actors: desired.bypass_actors }, desired)) {
return { action: 'unverified', current, desired };
}
} else if (rulesetsEqual(current, desired)) {
return { action: 'unchanged', current };
}

Expand Down Expand Up @@ -76,6 +84,13 @@ export async function reconcileRepositoryRulesets(
desiredRulesets,
);

const redacted = changes.find(
(change) => change.action !== 'create' && change.current.bypass_actors_redacted,
);
if (redacted && redacted.action !== 'create') {
throw new Error(`Cannot apply with hidden bypass actors: ${redacted.current.name}`);
}

const result: ReconcileResult = {
created: [],
updated: [],
Expand Down
19 changes: 19 additions & 0 deletions tests/cli-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,25 @@ describe('runCli', () => {
expect(modes).toEqual([true]);
});

it('reports bypass verification as incomplete without producing false drift', async () => {
const current: ExistingRepositoryRuleset = {
...structuredClone(protectedBranches),
id: 11, bypass_actors: [], bypass_actors_redacted: true,
};
const messages: string[] = [];
const code = await runCli(
['--org', 'ExampleOrg'],
{ GITHUB_TOKEN: 'token' },
() => new FakeClient([current]),
{ log: (message) => messages.push(message), error: () => undefined },
configLoader,
);
expect(code).toBe(0);
expect(messages).toContain('UNVERIFIED ExampleOrg/project');
expect(messages).toContain(' - bypass actors hidden: Protect branches');
expect(messages.some((m) => m.startsWith('DRIFT '))).toBe(false);
});

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

Expand Down
19 changes: 19 additions & 0 deletions tests/github-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,25 @@ describe('GitHubClient', () => {
});
});

it('marks bypass actors hidden by public API access instead of assuming there are none', async () => {
const requests: ExpectedRequest[] = [
{
url: 'https://api.github.test/repos/ExampleOrg/project/rulesets?per_page=100',
response: Response.json([{ id: 7, source_type: 'Repository' }]),
},
{
url: 'https://api.github.test/repos/ExampleOrg/project/rulesets/7',
response: Response.json({
...protectedBranchesFixture, id: 7, bypass_actors: null,
}),
},
];
const client = new GitHubClient('read-token', fakeFetch(requests), 'https://api.github.test');
expect(await client.list('ExampleOrg', 'project')).toMatchObject([
{ id: 7, bypass_actors: [], bypass_actors_redacted: true },
]);
});

it('creates and updates repository rulesets with JSON bodies', async () => {
const requests: ExpectedRequest[] = [
{
Expand Down
46 changes: 46 additions & 0 deletions tests/ruleset-reconciler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { describe, expect, it } from 'vitest';
import { protectedBranchesFixture } from './ruleset-fixtures.js';
import {
planRepositoryRulesets,
reconcileRepositoryRulesets,
type ExistingRepositoryRuleset,
type RepositoryRulesetClient,
Expand Down Expand Up @@ -92,6 +93,51 @@ describe('reconcileRepositoryRulesets', () => {
]);
});

it('reports a ruleset as unverified when only its bypass actors are redacted', () => {
const current: ExistingRepositoryRuleset = {
...structuredClone(protectedBranchesFixture),
id: 17, bypass_actors: [], bypass_actors_redacted: true,
};
expect(planRepositoryRulesets([current], [protectedBranchesFixture])).toEqual([
{ action: 'unverified', current, desired: protectedBranchesFixture },
]);
});

it('keeps reporting observable drift when bypass actors are redacted', () => {
const current: ExistingRepositoryRuleset = {
...structuredClone(protectedBranchesFixture),
id: 17, bypass_actors: [], bypass_actors_redacted: true,
enforcement: 'disabled',
};
expect(planRepositoryRulesets([current], [protectedBranchesFixture])[0]).toMatchObject({
action: 'update', id: 17,
});
});

it('fails closed even when visible ruleset fields drift and bypass is redacted', async () => {
const client = new FakeClient([{
...structuredClone(protectedBranchesFixture),
enforcement: 'disabled',
id: 18, bypass_actors: [], bypass_actors_redacted: true,
}]);
await expect(reconcileRepositoryRulesets(
client, 'ExampleOrg', 'project', [protectedBranchesFixture],
)).rejects.toThrow('hidden bypass actors');
expect(client.updated).toEqual([]);
});

it('never writes a ruleset when bypass actor visibility is insufficient', async () => {
const client = new FakeClient([{
...structuredClone(protectedBranchesFixture),
id: 17, bypass_actors: [], bypass_actors_redacted: true,
}]);
await expect(reconcileRepositoryRulesets(
client, 'ExampleOrg', 'project', [protectedBranchesFixture],
)).rejects.toThrow('hidden bypass actors');
expect(client.created).toEqual([]);
expect(client.updated).toEqual([]);
});

it('does not delete unrelated rulesets', async () => {
const unrelated: ExistingRepositoryRuleset = {
...structuredClone(protectedBranchesFixture),
Expand Down
Loading