diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index affaa06..c6da275 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 58944de..54003c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c812e9..424a3d2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index af5e799..ab3c4b9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index ec0d3bc..7384ac1 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -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 diff --git a/src/archetypes.ts b/src/archetypes.ts index d5e0e6d..dddd798 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -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}` @@ -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 @@ -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 @@ -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()} `; } @@ -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 @@ -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)} diff --git a/src/github/provision.ts b/src/github/provision.ts index 2a12708..3559e15 100644 --- a/src/github/provision.ts +++ b/src/github/provision.ts @@ -6,6 +6,7 @@ interface GitHubRepo { full_name: string; private: boolean; visibility: string; + allow_auto_merge?: boolean; } interface GitHubOwner { @@ -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; @@ -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" @@ -235,6 +273,17 @@ async function getOrganizationSettings( return client.api("GET", `/orgs/${owner}`); } +async function getOptionalOrganizationSettings( + client: GitHubClient, + owner: string +): Promise { + try { + return await getOrganizationSettings(client, owner); + } catch { + return undefined; + } +} + async function getRepo( client: GitHubClient, owner: string, @@ -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) @@ -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) + ) { + 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)}.` @@ -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) { @@ -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", diff --git a/tests/github-provision.test.ts b/tests/github-provision.test.ts index 6fb87fd..f3fbda4 100644 --- a/tests/github-provision.test.ts +++ b/tests/github-provision.test.ts @@ -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: { @@ -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: { diff --git a/tests/render.test.ts b/tests/render.test.ts index a55acdc..1ca51e7 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -41,6 +41,7 @@ describe("renderManagedFiles", () => { expect(prTemplate?.contents).toContain("## Governing Issue"); expect(prTemplate?.contents).toContain("## Validation"); expect(prTemplate?.contents).toContain("## Bootstrap Governance"); + expect(prTemplate?.contents).toContain("fallback merge-readiness policy applies"); expect(prTemplate?.contents).toContain("## Notes"); expect(files.some((file) => file.path === "CLAUDE.md")).toBe(false); @@ -94,13 +95,15 @@ describe("renderManagedFiles", () => { expect(readme?.contents).toContain("# Bootstrap"); expect(readme?.contents).toContain("- Repository: `acme/bootstrap`"); expect(readme?.contents).toContain("require approval from someone other than the most recent pusher"); + expect(readme?.contents).toContain("fallback merge-readiness policy"); expect(onboarding?.contents).toContain("- Product name: `Bootstrap`"); expect(onboarding?.contents).toContain("- Repository: `acme/bootstrap`"); expect(onboarding?.contents).toContain( "require one approval, code owner review, and approval from someone other than the most recent pusher" ); expect(onboarding?.contents).toContain("PR Fast CI validates the required PR description sections"); - expect(onboarding?.contents).toContain("merge via automation after checks pass"); + expect(onboarding?.contents).toContain("allow auto-merge` are enabled when the GitHub plan supports them"); + expect(onboarding?.contents).toContain("Fallback merge readiness requires"); }); it("documents required PR template enforcement in generated agent instructions", () => {