From 5acac38dfb350a87c1f8ae1c84831b7c82ee0a5e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:22:43 +0000 Subject: [PATCH 1/2] feat: support read-only public organization planning Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- action.yml | 8 +++++++ src/cli-runner.ts | 10 ++++++-- src/cli.ts | 3 ++- src/github-client.ts | 16 ++++++++----- tests/cli-runner.test.ts | 41 ++++++++++++++++++++++++++++++++ tests/github-client.test.ts | 47 +++++++++++++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 9 deletions(-) diff --git a/action.yml b/action.yml index 2dc3a73..77f045d 100644 --- a/action.yml +++ b/action.yml @@ -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 @@ -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") @@ -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 diff --git a/src/cli-runner.ts b/src/cli-runner.ts index 2d49a58..d96794b 100644 --- a/src/cli-runner.ts +++ b/src/cli-runner.ts @@ -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 { @@ -40,6 +40,7 @@ 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) { @@ -47,12 +48,17 @@ export async function runCli( 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; diff --git a/src/cli.ts b/src/cli.ts index 6aa6a7e..75afd83 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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, diff --git a/src/github-client.ts b/src/github-client.ts index 4d5df6b..092d345 100644 --- a/src/github-client.ts +++ b/src/github-client.ts @@ -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( @@ -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( + `/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; diff --git a/tests/cli-runner.test.ts b/tests/cli-runner.test.ts index 5017bf2..ab28c40 100644 --- a/tests/cli-runner.test.ts +++ b/tests/cli-runner.test.ts @@ -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[] = []; diff --git a/tests/github-client.test.ts b/tests/github-client.test.ts index 7aee692..3d2d84c 100644 --- a/tests/github-client.test.ts +++ b/tests/github-client.test.ts @@ -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[] = [ { From 3a02c8635a589f7213abd422a4d8b250a92e7ced Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:24:08 +0000 Subject: [PATCH 2/2] test: enforce Node native TypeScript compatibility Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- action.yml | 2 +- tsconfig.build.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 77f045d..aecf811 100644 --- a/action.yml +++ b/action.yml @@ -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) diff --git a/tsconfig.build.json b/tsconfig.build.json index 7168055..ab6d018 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,7 +3,8 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "declaration": true + "declaration": true, + "erasableSyntaxOnly": true }, "include": ["src/**/*.ts"], "exclude": ["tests/**/*.ts"]