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: 9 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: Plan or apply repository rulesets with the shared LibreCode governa

inputs:
token:
description: GitHub token with administration read permission for plan, write for apply
description: Read-only token for public planning, administration write token for Apply
required: true
repository:
description: Repository in OWNER/REPO form (exclusive with organization)
Expand All @@ -25,6 +25,10 @@ inputs:
description: Report drift without failing the plan; operational errors still fail
required: false
default: 'false'
public-discovery:
description: Discover public organization repositories without GitHub App installation access; plan only
required: false
default: 'false'

runs:
using: composite
Expand All @@ -45,6 +49,7 @@ runs:
CONFIG_PATH: ${{ inputs.config }}
APPLY: ${{ inputs.apply }}
ALLOW_DRIFT: ${{ inputs.allow-drift }}
PUBLIC_DISCOVERY: ${{ inputs.public-discovery }}
working-directory: ${{ github.action_path }}
run: |
args=(--config "$CONFIG_PATH")
Expand All @@ -64,6 +69,9 @@ runs:
if [[ "$ALLOW_DRIFT" == "true" ]]; then
args+=(--allow-drift)
fi
if [[ "$PUBLIC_DISCOVERY" == "true" ]]; then
args+=(--public-discovery)
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
Expand Down
10 changes: 8 additions & 2 deletions src/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export type GovernanceConfigLoader = (
export async function runCli(
args: string[],
environment: CliEnvironment,
clientFactory: (token: string) => GovernanceClient,
clientFactory: (token: string, publicDiscovery: boolean) => GovernanceClient,
output: CliOutput,
configLoader: GovernanceConfigLoader = loadGovernanceConfig,
): Promise<number> {
Expand All @@ -40,19 +40,25 @@ export async function runCli(
const configPath = option(args, '--config');
const apply = args.includes('--apply');
const allowDrift = args.includes('--allow-drift');
const publicDiscovery = args.includes('--public-discovery');
const token = environment.GITHUB_TOKEN;

if ((organization ? 1 : 0) + (repositoryArgument ? 1 : 0) !== 1) {
output.error('Specify exactly one of --org OWNER or --repo OWNER/REPO');
return 2;
}

if (publicDiscovery && (!organization || apply)) {
output.error('--public-discovery requires --org and cannot be combined with --apply');
return 2;
}

if (!token) {
output.error('GITHUB_TOKEN is required');
return 2;
}

const client = clientFactory(token);
const client = clientFactory(token, publicDiscovery);
const config = await configLoader(configPath);
const resolveRulesets = (repository: {
owner: string;
Expand Down
3 changes: 2 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { GitHubClient } from './github-client.ts';
const code = await runCli(
process.argv.slice(2),
{ GITHUB_TOKEN: process.env.GITHUB_TOKEN },
(token) => new GitHubClient(token),
(token, publicDiscovery) =>
new GitHubClient(token, fetch, 'https://api.github.com', publicDiscovery ? 'public' : 'installation'),
{
log: console.log,
error: console.error,
Expand Down
16 changes: 10 additions & 6 deletions src/github-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,18 @@ export class GitHubClient
private readonly token: string;
private readonly fetchImpl: FetchLike;
private readonly apiUrl: string;
private readonly discovery: 'installation' | 'public';

constructor(
token: string,
fetchImpl: FetchLike = fetch,
apiUrl = 'https://api.github.com',
discovery: 'installation' | 'public' = 'installation',
) {
this.token = token;
this.fetchImpl = fetchImpl;
this.apiUrl = apiUrl;
this.discovery = discovery;
}

async getRepository(
Expand Down Expand Up @@ -100,12 +103,13 @@ export class GitHubClient
const result: RepositoryMetadata[] = [];

for (let page = 1; ; page += 1) {
const response = await this.requestJson<{
repositories?: GitHubRepository[];
}>(
`/installation/repositories?per_page=100&page=${page}`,
);
const repositories = response.repositories ?? [];
const repositories = this.discovery === 'public'
? await this.requestJson<GitHubRepository[]>(
`/orgs/${encodeURIComponent(organization)}/repos?type=public&per_page=100&page=${page}`,
)
: (await this.requestJson<{ repositories?: GitHubRepository[] }>(
`/installation/repositories?per_page=100&page=${page}`,
)).repositories ?? [];

for (const repository of repositories) {
const owner = repository.owner?.login;
Expand Down
41 changes: 41 additions & 0 deletions tests/cli-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,47 @@ describe('runCli', () => {
)).rejects.toThrow('GitHub API unavailable');
});

it('rejects public discovery during apply to preserve installation-scoped writes', async () => {
const errors: string[] = [];
const code = await runCli(
['--org', 'ExampleOrg', '--apply', '--public-discovery'],
{ GITHUB_TOKEN: 'token' },
() => { throw new Error('Must not contact GitHub'); },
{ log: () => undefined, error: (message) => errors.push(message) },
configLoader,
);
expect(code).toBe(2);
expect(errors).toEqual([
'--public-discovery requires --org and cannot be combined with --apply',
]);
});

it('rejects public discovery for a repository-scoped run', async () => {
const errors: string[] = [];
const code = await runCli(
['--repo', 'ExampleOrg/project', '--public-discovery'],
{ GITHUB_TOKEN: 'token' },
() => { throw new Error('Must not contact GitHub'); },
{ log: () => undefined, error: (message) => errors.push(message) },
configLoader,
);
expect(code).toBe(2);
expect(errors).toHaveLength(1);
});

it('passes the read-only discovery selection only to public organization plans', async () => {
const modes: boolean[] = [];
const code = await runCli(
['--org', 'ExampleOrg', '--allow-drift', '--public-discovery'],
{ GITHUB_TOKEN: 'token' },
(_token, discovery) => { modes.push(discovery); return new FakeClient(); },
{ log: () => undefined, error: () => undefined },
configLoader,
);
expect(code).toBe(0);
expect(modes).toEqual([true]);
});

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

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

it('discovers public repositories with a read-only token, excluding archived and private entries', async () => {
const requests: ExpectedRequest[] = [
{
url: 'https://api.github.test/orgs/ExampleOrg/repos?type=public&per_page=100&page=1',
response: Response.json([
{ name: 'project', owner: { login: 'ExampleOrg' }, visibility: 'public', archived: false, description: 'Example', homepage: null, topics: [] },
{ name: 'archived', owner: { login: 'ExampleOrg' }, visibility: 'public', archived: true },
{ name: 'not-public', owner: { login: 'ExampleOrg' }, visibility: 'private', archived: false },
]),
},
];
const client = new GitHubClient('read-token', fakeFetch(requests), 'https://api.github.test', 'public');
await expect(client.listManagedRepositories('ExampleOrg')).resolves.toEqual([
{ owner: 'ExampleOrg', name: 'project', visibility: 'public', archived: false, description: 'Example', homepage: null, topics: [] },
]);
expect(requests).toHaveLength(0);
});

it('paginates public organization discovery without querying the installation', async () => {
const item = (i: number) => ({
name: `repo-${i}`, owner: { login: 'ExampleOrg' },
visibility: 'public', archived: false, description: null, homepage: null, topics: [],
});
const requests: ExpectedRequest[] = [
{
url: 'https://api.github.test/orgs/ExampleOrg/repos?type=public&per_page=100&page=1',
response: Response.json(Array.from({ length: 100 }, (_, i) => item(i))),
},
{
url: 'https://api.github.test/orgs/ExampleOrg/repos?type=public&per_page=100&page=2',
response: Response.json([item(100)]),
},
];
const client = new GitHubClient('read-token', fakeFetch(requests), 'https://api.github.test', 'public');
expect(await client.listManagedRepositories('ExampleOrg')).toHaveLength(101);
expect(requests).toHaveLength(0);
});

it('fails closed when public discovery is forbidden', async () => {
const requests: ExpectedRequest[] = [{
url: 'https://api.github.test/orgs/ExampleOrg/repos?type=public&per_page=100&page=1',
response: new Response('', { status: 403 }),
}];
const client = new GitHubClient('read-token', fakeFetch(requests), 'https://api.github.test', 'public');
await expect(client.listManagedRepositories('ExampleOrg')).rejects.toThrow('HTTP 403');
});

it('returns false only for a 404 content probe', async () => {
const requests: ExpectedRequest[] = [
{
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true
"declaration": true,
"erasableSyntaxOnly": true
},
"include": ["src/**/*.ts"],
"exclude": ["tests/**/*.ts"]
Expand Down
Loading