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
44 changes: 36 additions & 8 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,25 @@ 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
apply:
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
Expand All @@ -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
5 changes: 3 additions & 2 deletions src/cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -87,15 +88,15 @@ 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
? await syncOrganization(client, organization!, resolveRulesets, resolveMetadata)
: 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(
Expand Down
47 changes: 47 additions & 0 deletions tests/cli-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExistingRepositoryRuleset[]> {
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[] = [];

Expand Down
Loading