diff --git a/action.yml b/action.yml index f5a9167..2dc3a73 100644 --- a/action.yml +++ b/action.yml @@ -6,11 +6,14 @@ description: Plan or apply repository rulesets with the shared LibreCode governa inputs: token: - description: Repository-scoped GitHub token with administration permission + description: GitHub token with administration read permission for plan, write for apply required: true repository: - description: Repository in OWNER/REPO form - required: true + description: Repository in OWNER/REPO form (exclusive with organization) + required: false + organization: + description: Plan or apply governance across public repositories in an organization + required: false config: description: Path to caller governance config required: true @@ -18,6 +21,10 @@ inputs: description: Apply changes instead of dry-running required: false default: 'false' + allow-drift: + description: Report drift without failing the plan; operational errors still fail + required: false + default: 'false' runs: using: composite @@ -34,17 +41,38 @@ runs: env: GITHUB_TOKEN: ${{ inputs.token }} TARGET_REPOSITORY: ${{ inputs.repository }} + TARGET_ORGANIZATION: ${{ inputs.organization }} CONFIG_PATH: ${{ inputs.config }} APPLY: ${{ inputs.apply }} + ALLOW_DRIFT: ${{ inputs.allow-drift }} working-directory: ${{ github.action_path }} run: | - args=( - --repo "$TARGET_REPOSITORY" - --config "$CONFIG_PATH" - ) + args=(--config "$CONFIG_PATH") + if [[ -n "$TARGET_REPOSITORY" && -n "$TARGET_ORGANIZATION" ]]; then + echo '::error::Specify repository or organization, not both' + exit 2 + fi + if [[ -n "$TARGET_ORGANIZATION" ]]; then + args+=(--org "$TARGET_ORGANIZATION") + else + args+=(--repo "$TARGET_REPOSITORY") + fi if [[ "$APPLY" == "true" ]]; then args+=(--apply) fi + if [[ "$ALLOW_DRIFT" == "true" ]]; then + args+=(--allow-drift) + fi - node src/cli.ts "${args[@]}" + 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' + { + echo '### Repository governance drift' + echo 'Review these repositories before approving an apply:' + echo '```text' + grep '^DRIFT ' "$RUNNER_TEMP/github-governance-result.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/src/cli-runner.ts b/src/cli-runner.ts index 44fed4f..2d49a58 100644 --- a/src/cli-runner.ts +++ b/src/cli-runner.ts @@ -39,6 +39,7 @@ export async function runCli( const repositoryArgument = option(args, '--repo'); const configPath = option(args, '--config'); const apply = args.includes('--apply'); + const allowDrift = args.includes('--allow-drift'); const token = environment.GITHUB_TOKEN; if ((organization ? 1 : 0) + (repositoryArgument ? 1 : 0) !== 1) { @@ -87,7 +88,7 @@ export async function runCli( : await planRepository(client, metadata, desiredRulesets, desiredMetadata); writePlans([plan], apply, output); - return !apply && hasDrift([plan]) ? 1 : 0; + return !apply && !allowDrift && hasDrift([plan]) ? 1 : 0; } const plans = apply @@ -95,7 +96,7 @@ export async function runCli( : await planOrganization(client, organization!, resolveRulesets, resolveMetadata); writePlans(plans, apply, output); - return !apply && hasDrift(plans) ? 1 : 0; + return !apply && !allowDrift && hasDrift(plans) ? 1 : 0; } function writePlans( diff --git a/tests/cli-runner.test.ts b/tests/cli-runner.test.ts index 3c88303..5017bf2 100644 --- a/tests/cli-runner.test.ts +++ b/tests/cli-runner.test.ts @@ -153,6 +153,53 @@ describe('runCli', () => { expect(messages).toContain(' - create: Protect branches'); }); + it('reports organization drift without failing when explicitly allowed', async () => { + const messages: string[] = []; + const code = await runCli( + ['--org', 'ExampleOrg', '--allow-drift'], + { GITHUB_TOKEN: 'token' }, + () => new FakeClient(), + { + log: (message) => messages.push(message), + error: () => undefined, + }, + configLoader, + ); + expect(code).toBe(0); + expect(messages).toContain('DRIFT ExampleOrg/project'); + }); + + it('reports repository drift without failing when explicitly allowed', async () => { + const messages: string[] = []; + const code = await runCli( + ['--repo', 'ExampleOrg/project', '--allow-drift'], + { GITHUB_TOKEN: 'token' }, + () => new FakeClient(), + { + log: (message) => messages.push(message), + error: () => undefined, + }, + configLoader, + ); + expect(code).toBe(0); + expect(messages).toContain('DRIFT ExampleOrg/project'); + }); + + it('does not hide API failures when drift is allowed', async () => { + class FailedClient extends FakeClient { + override async list(): Promise { + throw new Error('GitHub API unavailable'); + } + } + await expect(runCli( + ['--org', 'ExampleOrg', '--allow-drift'], + { GITHUB_TOKEN: 'token' }, + () => new FailedClient(), + { log: () => undefined, error: () => undefined }, + configLoader, + )).rejects.toThrow('GitHub API unavailable'); + }); + it('uses apply mode only when explicitly requested', async () => { const messages: string[] = [];