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
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Closes #

- [ ] Changes are scoped to the linked issue
- [ ] Contributor or PR guidance changes are reflected in `CONTRIBUTING.md`, `.github/PULL_REQUEST_TEMPLATE.md`, and `docs/bootstrap/onboarding.md` when applicable
- [ ] Auto-merge is enabled, or GitHub plan-limit evidence is recorded and the fallback merge-readiness policy applies
- [ ] No real secrets, runtime auth, or machine-local env files are committed

## Merge Automation
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- Worker agents should act from assigned or explicitly enabled issues, not free-roaming backlog grabs.
- If an agent authors a PR, that same agent may not approve it. This is a hard rule.
- Healthy PRs should converge toward auto-merge once required checks are green or intentionally skipped, approvals are satisfied, and no blocking review state remains.
- When GitHub plan limits make auto-merge unavailable for a private repo, use the fallback merge-readiness policy: required checks pass or are intentionally skipped, approvals and conversation resolution are satisfied, no blocking review state remains, and a maintainer performs the merge manually.
- PRs should link and close their governing issue where possible so issue state remains the durable work contract.

## Local Conventions
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ Contributions should start from a GitHub issue that is assigned or explicitly en
- Link the governing issue with a closing keyword when the PR should close it.
- PR authors may not approve their own PRs.
- A healthy PR should converge toward auto-merge after required checks pass or are intentionally skipped, approvals are satisfied, and no blocking review state remains.
- When GitHub plan limits make auto-merge unavailable for a private repo, use the fallback merge-readiness policy: required checks pass or are intentionally skipped, approvals and conversation resolution are satisfied, no blocking review state remains, and a maintainer performs the merge manually.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ bootstrap reconcile --workspace-root ~/src --apply-repo --create-pr --report boo

If `github.organization` is set and `OMT-Global` is an organization, `bootstrap apply github` also reconciles org defaults for new repos. It also syncs `github.issueLabels` for issue routing, risk, status, and review gates.

Confirm branch protection points at the `CI Gate` status. and require approval from someone other than the most recent pusher.
Confirm branch protection points at the `CI Gate` status and require approval from someone other than the most recent pusher. When GitHub plan limits make auto-merge unavailable for a private repo, use the fallback merge-readiness policy: required checks pass or are intentionally skipped, approvals and conversation resolution are satisfied, no blocking review state remains, and a maintainer performs the merge manually.

## Contributor And PR Guidance

Expand Down
3 changes: 2 additions & 1 deletion docs/bootstrap/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr
- Confirm branch protection points at the `CI Gate` status.
- Confirm `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` are present as the required contributor and PR guidance surfaces.
- Confirm the pull request template is present and PR Fast CI validates the required PR description sections before CI Gate can pass.
- Confirm `delete branch on merge` and `allow auto-merge` are enabled so reviewed PRs merge via automation after checks pass.
- Confirm `delete branch on merge` and `allow auto-merge` are enabled when the GitHub plan supports them; otherwise record the plan-limit evidence and use the fallback merge-readiness policy.
- Fallback merge readiness requires passing or intentionally skipped required checks, satisfied approvals, resolved conversations, no blocking review state, and a manual maintainer merge.

## Org Governance

Expand Down
20 changes: 18 additions & 2 deletions src/archetypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ function requiredStatusCheckConfirmation(manifest: BootstrapManifest): string {
: `Confirm branch protection points at the expected required status checks: ${requiredStatusChecksDisplay(manifest)}.`;
}

function requiredStatusCheckConfirmationLead(manifest: BootstrapManifest): string {
return requiredStatusCheckConfirmation(manifest).replace(/\.$/, "");
}

function autoMergeReadinessPolicy(): string {
return "When GitHub plan limits make auto-merge unavailable for a private repo, use the fallback merge-readiness policy: required checks pass or are intentionally skipped, approvals and conversation resolution are satisfied, no blocking review state remains, and a maintainer performs the merge manually.";
}

function autoMergeOnboardingConfirmation(): string {
return "Confirm `delete branch on merge` and `allow auto-merge` are enabled when the GitHub plan supports them; otherwise record the plan-limit evidence and use the fallback merge-readiness policy.";
}

function additionalWorkflowLines(manifest: BootstrapManifest): string[] {
return manifest.ci.additionalWorkflows.map(
(workflow) => `- \`${workflow.path}\`: ${workflow.purpose}`
Expand Down Expand Up @@ -194,6 +206,7 @@ function repoAgents(manifest: BootstrapManifest): string {
- Worker agents should act from assigned or explicitly enabled issues, not free-roaming backlog grabs.
- If an agent authors a PR, that same agent may not approve it. This is a hard rule.
- Healthy PRs should converge toward auto-merge once required checks are green or intentionally skipped, approvals are satisfied, and no blocking review state remains.
- ${autoMergeReadinessPolicy()}
- PRs should link and close their governing issue where possible so issue state remains the durable work contract.

## Local Conventions
Expand Down Expand Up @@ -304,7 +317,7 @@ function repoReadme(manifest: BootstrapManifest): string {
? `If \`github.organization\` is set and \`${manifest.project.owner}\` is an organization, \`bootstrap apply github\` also reconciles org defaults for new repos.`
: ""} It also syncs \`github.issueLabels\` for issue routing, risk, status, and review gates.

${requiredStatusCheckConfirmation(manifest)} and require approval from someone other than the most recent pusher.
${requiredStatusCheckConfirmationLead(manifest)} and require approval from someone other than the most recent pusher. ${autoMergeReadinessPolicy()}

## Contributor And PR Guidance

Expand Down Expand Up @@ -360,6 +373,7 @@ function contributingDoc(manifest: BootstrapManifest): string {
- Link the governing issue with a closing keyword when the PR should close it.
- PR authors may not approve their own PRs.
- A healthy PR should converge toward auto-merge after required checks pass or are intentionally skipped, approvals are satisfied, and no blocking review state remains.
- ${autoMergeReadinessPolicy()}
`;
}

Expand All @@ -383,6 +397,7 @@ function pullRequestTemplate(manifest: BootstrapManifest): string {

- [ ] Changes are scoped to the linked issue
- [ ] Contributor or PR guidance changes are reflected in \`CONTRIBUTING.md\`, \`.github/PULL_REQUEST_TEMPLATE.md\`, and \`docs/bootstrap/onboarding.md\` when applicable
- [ ] Auto-merge is enabled, or GitHub plan-limit evidence is recorded and the fallback merge-readiness policy applies
- [ ] No real secrets, runtime auth, or machine-local env files are committed

## Merge Automation
Expand Down Expand Up @@ -1361,7 +1376,8 @@ ${indentBlock(projectIdentityLines(manifest), 4)}
- ${requiredStatusCheckConfirmation(manifest)}
- Confirm \`CONTRIBUTING.md\` and \`.github/PULL_REQUEST_TEMPLATE.md\` are present as the required contributor and PR guidance surfaces.
- Confirm the pull request template is present and PR Fast CI validates the required PR description sections before ${primaryRequiredStatusCheck(manifest)} can pass.
- Confirm \`delete branch on merge\` and \`allow auto-merge\` are enabled so reviewed PRs merge via automation after checks pass.
- ${autoMergeOnboardingConfirmation()}
- Fallback merge readiness requires passing or intentionally skipped required checks, satisfied approvals, resolved conversations, no blocking review state, and a manual maintainer merge.

${indentBlock(organizationGovernanceSection(manifest), 4)}
${indentBlock(additionalWorkflowSection(manifest), 4)}
Expand Down
76 changes: 75 additions & 1 deletion src/github/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface GitHubRepo {
full_name: string;
private: boolean;
visibility: string;
allow_auto_merge?: boolean;
}

interface GitHubOwner {
Expand All @@ -14,6 +15,9 @@ interface GitHubOwner {
}

interface GitHubOrganizationSettings {
plan?: {
name?: string;
};
default_repository_permission: string;
members_can_create_repositories: boolean;
members_can_create_public_repositories: boolean;
Expand Down Expand Up @@ -142,6 +146,40 @@ function isEnvironmentProtectionPlanLimit(error: unknown): boolean {
return message.includes("Failed to create the environment protection rule. Please ensure the billing plan supports");
}

function isPrivateVisibility(visibility: BootstrapManifest["project"]["visibility"] | string): boolean {
return visibility === "private" || visibility === "internal";
}

function organizationPlanDisablesPrivateRepoAutoMerge(
manifest: BootstrapManifest,
owner: GitHubOwner,
organization?: GitHubOrganizationSettings
): boolean {
return (
manifest.github.autoMerge &&
owner.type === "Organization" &&
isPrivateVisibility(manifest.project.visibility) &&
organization?.plan?.name?.toLowerCase() === "free"
);
}

function repoDisablesAutoMerge(manifest: BootstrapManifest, repo: GitHubRepo | undefined): boolean {
return Boolean(
manifest.github.autoMerge &&
repo &&
isPrivateVisibility(manifest.project.visibility) &&
repo.allow_auto_merge === false
);
}

function autoMergeFallbackAction(): PlannedGitHubAction {
return {
id: "auto-merge-plan-limited",
description:
"Use fallback merge readiness because GitHub auto-merge is unavailable for this private repository on the current plan: required checks must pass or be intentionally skipped, approvals and conversation resolution must be satisfied, no blocking review state may remain, and a maintainer performs the merge manually."
};
}

function environmentBranchPolicy(
manifest: BootstrapManifest,
environmentName: "dev" | "stage" | "prod"
Expand Down Expand Up @@ -235,6 +273,17 @@ async function getOrganizationSettings(
return client.api<GitHubOrganizationSettings>("GET", `/orgs/${owner}`);
}

async function getOptionalOrganizationSettings(
client: GitHubClient,
owner: string
): Promise<GitHubOrganizationSettings | undefined> {
try {
return await getOrganizationSettings(client, owner);
} catch {
return undefined;
}
}

async function getRepo(
client: GitHubClient,
owner: string,
Expand Down Expand Up @@ -289,8 +338,14 @@ export async function planGitHub(
const repo = await getRepo(client, manifest.project.owner, manifest.project.name);
const owner = await getOwner(client, manifest.project.owner);

let organization: GitHubOrganizationSettings | undefined;
if (owner.type === "Organization" && hasOrganizationPolicy(manifest)) {
const organization = await getOrganizationSettings(client, manifest.project.owner);
organization = await getOrganizationSettings(client, manifest.project.owner);
} else if (owner.type === "Organization") {
organization = await getOptionalOrganizationSettings(client, manifest.project.owner);
}

if (owner.type === "Organization" && hasOrganizationPolicy(manifest) && organization) {
actions.push({
id: organizationNeedsUpdate(manifest, organization) ? "organization" : "organization-sync",
description: organizationNeedsUpdate(manifest, organization)
Expand All @@ -310,6 +365,12 @@ export async function planGitHub(
? `Update repo settings for ${manifest.project.owner}/${manifest.project.name}.`
: `Create repo ${manifest.project.owner}/${manifest.project.name}.`
});
if (
repoDisablesAutoMerge(manifest, repo) ||
organizationPlanDisablesPrivateRepoAutoMerge(manifest, owner, organization)
Comment thread
jmcte marked this conversation as resolved.
) {
actions.push(autoMergeFallbackAction());
}
actions.push({
id: "branch-protection",
description: `Ensure ${manifest.project.defaultBranch} requires ${manifest.github.requiredApprovals} approval(s), last-push approval, code owners, stale-review dismissal, linear history, and status checks ${requiredStatusChecksLabel(manifest)}.`
Expand Down Expand Up @@ -338,6 +399,11 @@ export async function applyGitHub(
const owner = await getOwner(client, manifest.project.owner);
const actions: PlannedGitHubAction[] = [];

let organization: GitHubOrganizationSettings | undefined;
if (owner.type === "Organization") {
organization = await getOptionalOrganizationSettings(client, manifest.project.owner);
}

if (owner.type === "Organization" && hasOrganizationPolicy(manifest)) {
const payload = organizationPayload(manifest);
if (payload) {
Expand Down Expand Up @@ -403,6 +469,14 @@ export async function applyGitHub(
});
}

const syncedRepo = await getRepo(client, manifest.project.owner, manifest.project.name);
if (
repoDisablesAutoMerge(manifest, syncedRepo) ||
organizationPlanDisablesPrivateRepoAutoMerge(manifest, owner, organization)
) {
actions.push(autoMergeFallbackAction());
}

try {
await client.api(
"PUT",
Expand Down
137 changes: 137 additions & 0 deletions tests/github-provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,102 @@ describe("GitHub provisioning", () => {
);
});

it("plans fallback merge readiness when GitHub Free cannot auto-merge private repos", async () => {
const manifest = normalizeManifest({
project: {
name: "example",
owner: "acme",
visibility: "private"
},
archetype: {
kind: "generic-empty"
},
github: {
autoMerge: true
}
});

const actions = await planGitHub(
manifest,
{
isAvailable: async () => true,
isAuthenticated: async () => true,
tryApi: async (method: string, endpoint: string) => {
if (endpoint === "/repos/acme/example") {
return {
name: "example",
full_name: "acme/example",
private: true,
visibility: "private",
allow_auto_merge: false
};
}
return undefined;
},
api: async (method: string, endpoint: string) => {
if (endpoint === "/users/acme") {
return { login: "acme", type: "Organization" };
}
if (endpoint === "/orgs/acme") {
return { plan: { name: "free" } };
}
return {};
}
} as never
);

const fallback = actions.find((action) => action.id === "auto-merge-plan-limited");
expect(fallback?.description).toContain("fallback merge readiness");
expect(fallback?.description).toContain("maintainer performs the merge manually");
});

it("does not plan fallback merge readiness when making a private repo public", async () => {
const manifest = normalizeManifest({
project: {
name: "example",
owner: "acme",
visibility: "public"
},
archetype: {
kind: "generic-empty"
},
github: {
autoMerge: true
}
});

const actions = await planGitHub(
manifest,
{
isAvailable: async () => true,
isAuthenticated: async () => true,
tryApi: async (method: string, endpoint: string) => {
if (endpoint === "/repos/acme/example") {
return {
name: "example",
full_name: "acme/example",
private: true,
visibility: "private",
allow_auto_merge: false
};
}
return undefined;
},
api: async (method: string, endpoint: string) => {
if (endpoint === "/users/acme") {
return { login: "acme", type: "Organization" };
}
if (endpoint === "/orgs/acme") {
return { plan: { name: "free" } };
}
return {};
}
} as never
);

expect(actions.map((action) => action.id)).not.toContain("auto-merge-plan-limited");
});

it("falls back to bare environments when private-repo protection rules are unsupported", async () => {
const manifest = normalizeManifest({
project: {
Expand Down Expand Up @@ -257,6 +353,47 @@ describe("GitHub provisioning", () => {
]);
});

it("reports fallback merge readiness after apply when auto-merge remains disabled", async () => {
const manifest = normalizeManifest({
project: {
name: "example",
owner: "acme",
visibility: "private"
},
archetype: {
kind: "generic-empty"
},
github: {
createRepo: false,
autoMerge: true
}
});

const client = {
isAvailable: async () => true,
isAuthenticated: async () => true,
tryApi: async () => ({
name: "example",
full_name: "acme/example",
private: true,
visibility: "private",
allow_auto_merge: false
}),
api: async (method: string, endpoint: string) => {
if (endpoint === "/users/acme") {
return { login: "acme", type: "Organization" };
}
if (endpoint === "/orgs/acme") {
return { plan: { name: "free" } };
}
return {};
}
};

const actions = await applyGitHub(manifest, client as never);
expect(actions.map((action) => action.id)).toContain("auto-merge-plan-limited");
});

it("defaults GitHub review protection to require approval from someone other than the last pusher", () => {
const manifest = normalizeManifest({
project: {
Expand Down
Loading
Loading