Skip to content

fix(cli): fall back to PR when default branch is protected#2201

Open
waynesun09 wants to merge 1 commit into
mainfrom
fix-protected-branch-fallback
Open

fix(cli): fall back to PR when default branch is protected#2201
waynesun09 wants to merge 1 commit into
mainfrom
fix-protected-branch-fallback

Conversation

@waynesun09

@waynesun09 waynesun09 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #1689

Summary

When fullsend admin install or fullsend github setup encounters a protected default branch (422 on ref update), the CLI now falls back to creating a feature branch (fullsend/scaffold-install) and opening a PR with the scaffold files instead of failing with an opaque error.

Covers both install paths:

  • Per-repo (fullsend github setup owner/repo, fullsend admin install owner/repo) — fallback in applyPerRepoScaffold
  • Per-org (fullsend admin install org) — fallback in WorkflowsLayer.Install() for the .fullsend config repo

Changes:

  • Adds ErrBranchProtected sentinel error and CommitFilesToBranch to forge.Client interface
  • Refactors CommitFiles in the GitHub client to detect 422 at the ref-update step as a branch protection failure
  • Adds PR-based fallback in both per-repo and per-org install paths
  • Variables and secrets are set regardless of which path is taken (they don't depend on scaffold files being merged)
  • Idempotent: re-runs tolerate existing branches and PRs gracefully

Test plan

  • go test ./internal/forge/... ./internal/cli/... ./internal/layers/... -count=1 — all pass
  • 8 unit tests for per-repo fallback: happy path, existing branch, duplicate PR, vars/secrets independence, branch creation failure, commit failure, PR creation failure, branch up-to-date
  • 7 unit tests for per-org fallback: happy path, existing branch, branch creation failure, commit failure, PR creation failure, duplicate PR, branch up-to-date
  • E2E tests pass

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

E2E tests are running

Authorization passed for this commit. See the E2E Tests workflow for results.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown

Site preview

Preview: https://d0e5886d-site.fullsend-ai.workers.dev

Commit: c03bd360a25ea27b46e52e54a78024afae44d375

@fullsend-ai-review

Copy link
Copy Markdown

🤖 Review · Started 1:12 AM UTC
Commit: 9be2798 · View workflow run →

@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.52941% with 31 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/forge/fake.go 0.00% 18 Missing ⚠️
internal/forge/github/github.go 36.36% 7 Missing ⚠️
internal/layers/commit.go 89.18% 3 Missing and 1 partial ⚠️
internal/forge/forge.go 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@waynesun09 waynesun09 force-pushed the fix-protected-branch-fallback branch from 9be2798 to cc39241 Compare June 12, 2026 01:14
@waynesun09 waynesun09 added the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 1:17 AM UTC · Completed 1:29 AM UTC
Commit: cc39241 · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go:757 — All 422 responses on the ref-update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions beyond branch protection (invalid SHA, fast-forward conflict, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body (APIError.Message or APIError.Errors) for branch-protection-specific signals (e.g., "protected branch" in the error message) before wrapping as ErrBranchProtected. Only return the sentinel when the response clearly indicates branch protection.

Low

  • [race condition / branch staleness] internal/layers/commit.go:24 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or contain outdated base content. Practical impact is limited since scaffold files are idempotent boilerplate and the PR requires human review before merging.
    Remediation: When the scaffold branch already exists, delete and recreate it from the current default branch HEAD, or reset its ref before committing.

  • [error-handling-idiom] internal/layers/commit.go:26 — Error checking uses strings.Contains(err.Error(), "already exists") for both CreateBranch (line 26) and CreateChangeProposal (line 42) errors instead of typed error comparison. The codebase uses errors.Is() with sentinel errors for error classification (ErrNotFound, ErrBranchProtected). There is existing precedent for this pattern in e2e/admin/lock.go:252, but it is inconsistent with the sentinel pattern introduced in this same PR.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern.

  • [authorization] internal/layers/commit.go — The PR fallback path uses a hardcoded branch name (fullsend/scaffold-install). Concurrent install operations targeting the same repo would race on the same branch. An attacker with push access could pre-create the branch with malicious content, though this requires elevated access and the PR still requires human review before merging.

Info

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback when encountering protected branches) rather than fixing broken existing behavior. Per COMMITS.md, fix is for bug fixes while feat covers new user-facing functionality. However, this is debatable since it addresses the inability to install on protected branches (issue install: fall back to PR when default branch push is rejected #1689).
    Remediation: Consider changing PR title to feat(cli): fall back to PR when default branch is protected.
Previous run

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go:757 — All 422 responses on the ref-update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions (invalid SHA, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body (APIError.Message or APIError.Errors) for branch-protection-specific signals (e.g., "protected branch") before wrapping as ErrBranchProtected. Only return the sentinel when the response clearly indicates branch protection.

  • [race condition / branch staleness] internal/cli/admin.go:1035 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or include unintended diff with the current default branch.
    Remediation: When the scaffold branch already exists, delete and recreate it from the current default branch HEAD, or reset its ref before committing, so the scaffold PR always has a clean diff.

  • [error-handling-idiom] internal/cli/admin.go:1035 — Error checking uses strings.Contains(branchErr.Error(), "already exists") instead of typed error comparison. The codebase uses errors.Is() for error classification (e.g., ErrNotFound, and this PR's own ErrBranchProtected/IsBranchProtected). The same fragile pattern appears for CreateChangeProposal errors at line 1056.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern introduced in this PR.

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback when encountering protected branches) rather than fixing broken existing behavior. Per COMMITS.md, fix is for bug fixes visible to users while feat covers new user-facing functionality. The previous behavior was an unsupported scenario (opaque error), and this PR adds a new code path to handle it gracefully.
    Remediation: Change PR title to feat(cli): fall back to PR when default branch is protected.

Low

  • [error handling / test coupling] internal/forge/fake.go:143 — The FakeClient shares CommitFilesChanged between CommitFiles and CommitFilesToBranch. This coupling is fragile — if the FakeClient implementation order changes, tests could break in subtle ways. Current tests work because CommitFiles returns the error before checking the flag.

  • [incomplete fallback] internal/cli/admin.go:1048 — When the fallback creates a PR, variables and secrets are set immediately before the PR is merged. If the PR is closed without merging, the repo has fullsend vars/secrets but no workflow files. However, vars/secrets without workflow files are inert and re-running the install command is idempotent.

  • [interface-expansion-scope] internal/forge/forge.goCommitFilesToBranch is added to forge.Client alongside CreateFileOnBranch and CreateOrUpdateFileOnBranch. A godoc comment explaining when to use each branch-writing method (atomic multi-file via Git Trees API vs. single-file via Contents API) would help future contributors.

  • [naming-consistency] internal/cli/admin.go:1033 — Branch name "fullsend/scaffold-install" is defined as a block-scoped constant. Other similar constants (forge.ConfigRepoName, forge.PerRepoGuardVar) are at package or file level for discoverability.

Info

  • [scope-completeness] The PR handles protected branches for per-repo install only. Per-org install flows that commit to the .fullsend config repo could encounter the same issue, though the config repo is a separate repo less likely to have branch protection.

  • [documentation-clarity] internal/cli/admin.go:1026 — The new PR creation flow doesn't document what happens when the scaffold branch already exists with outdated content. See the branch-staleness finding above.

  • [interface-expansion] internal/forge/forge.goCommitFilesToBranch expands the forge API surface. All forge.Client implementations must be updated. The internal/ path signals this is not for external consumption.

  • [error-contract-expansion] internal/forge/forge.go — New exported error symbols (ErrBranchProtected, IsBranchProtected) follow the existing ErrNotFound/IsNotFound pattern. Backward compatible.

  • [interface-compatibility] internal/forge/forge.go — Change is backward compatible for consumers but is a compile-time breaking change for implementers of forge.Client. The internal/ path makes this acceptable.

Previous run (2)

Review

Findings

Medium

  • [fail-open] internal/forge/github/github.go — All 422 responses on the ref update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions (invalid SHA, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error behind a path the operator may not investigate.
    Remediation: Inspect the 422 response body for branch-protection-specific signals (e.g., "protected branch" in the error message or a specific error code). Only wrap as ErrBranchProtected when the response clearly indicates branch protection.

  • [error-handling-idiom] internal/cli/admin.go:1038 — Error checking uses strings.Contains(branchErr.Error(), "already exists") instead of typed error comparison. The codebase uses errors.Is() for error classification (e.g., ErrNotFound, and this PR's own ErrBranchProtected). While there is precedent in e2e/admin/lock.go:252, the inconsistency with the sentinel pattern introduced in this same PR is notable. The same fragile pattern appears for CreateChangeProposal errors.
    Remediation: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected/IsBranchProtected pattern introduced in this PR.

  • [race condition / branch staleness] internal/cli/admin.go:1035 — When CreateBranch returns "already exists" (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content, and the resulting PR may have merge conflicts or include unintended diff with the current default branch.
    Remediation: When the branch already exists, consider force-updating its ref to the current default branch HEAD before committing, or document that the scaffold PR may require a manual rebase if the default branch has diverged.

  • [interface-expansion-scope] internal/forge/forge.goCommitFilesToBranch is added to forge.Client, which already has CreateFileOnBranch and CreateOrUpdateFileOnBranch. The relationship between these three branch-writing methods needs clarification to avoid interface bloat (the interface already has 40+ methods).
    Remediation: Clarify whether existing methods could be composed for the same result. If CommitFilesToBranch is necessary, document its relationship to existing methods in the interface comments.

  • [error handling / edge case] internal/cli/admin.go:1048 — The FakeClient shares CommitFilesChanged between CommitFiles and CommitFilesToBranch. In TestApplyPerRepoScaffold_ProtectedBranch_BranchUpToDate, the interaction between Errors["CommitFiles"] and CommitFilesChanged means the shared control only affects CommitFilesToBranch (since CommitFiles returns the error before checking the flag). This coupling is fragile and should be verified or separated.
    Remediation: Consider adding a separate CommitFilesToBranchChanged field or explicitly documenting that CommitFilesChanged controls both methods.

Low

  • [naming-convention] internal/cli/admin.go:1033 — Branch name "fullsend/scaffold-install" is a magic string literal. The codebase uses package-level constants for similar values (DefaultMintURL, forge.ConfigRepoName, etc.).

  • [naming-convention] internal/cli/admin.go:1048 — Message "Merge the PR to activate fullsend workflows" lacks specificity about which PR. Consider fmt.Sprintf("Merge PR #%d to activate fullsend workflows", proposal.Number).

  • [test-inadequate] internal/cli/admin_test.go:2079TestApplyPerRepoScaffold_ProtectedBranch_ExistingBranch asserts CommittedFilesToBranch length but does not verify the branch name or message fields.

  • [code-organization] internal/cli/admin.go:1024 — Variable commitMsg is extracted but only used once; the rest of the function inlines fmt.Sprintf() for messages.

Info

  • [commit-message-accuracy] PR title uses fix(cli) but this introduces new behavior (automatic PR fallback) rather than fixing broken behavior. Consider whether feat(cli) is more accurate per COMMITS.md.

  • [scope-completeness] The PR handles protected branches for per-repo install only. Per-org install flows that commit to the .fullsend config repo could hit the same issue — inconsistent handling would create confusing UX.

  • [interface-expansion] CommitFilesToBranch expands the forge API surface to allow writes to arbitrary branches. No injection risk in this specific usage since the branch name is a constant.

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

Comment thread internal/cli/admin.go Outdated
if !strings.Contains(branchErr.Error(), "already exists") {
printer.StepFail("Failed to create scaffold branch")
return fmt.Errorf("creating scaffold branch: %w", branchErr)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] error-handling-idiom

Error checking uses strings.Contains for already exists instead of typed error comparison. The codebase uses errors.Is() and this same PR introduces the ErrBranchProtected sentinel pattern, making the inconsistency notable.

Suggested fix: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks, following the ErrBranchProtected pattern introduced in this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — an ErrAlreadyExists sentinel would be cleaner. However, this is a broader refactor: CreateBranch returns 422 with "Reference already exists" and CreateChangeProposal returns 422 with "A pull request already exists" — both would need the sentinel, and the forge interface is forge-agnostic (can't depend on github.APIError). The string check is pragmatic for this PR. Filed as a follow-up improvement.

Comment thread internal/cli/admin.go Outdated

const scaffoldBranch = "fullsend/scaffold-install"
if branchErr := client.CreateBranch(ctx, owner, repo, scaffoldBranch); branchErr != nil {
if !strings.Contains(branchErr.Error(), "already exists") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] race condition / branch staleness

When CreateBranch returns already exists from a previous failed run, the existing branch may be based on an old commit. CommitFilesToBranch commits on top of potentially stale content, causing merge conflicts or unintended diff.

Suggested fix: When the branch already exists, force-update its ref to the current default branch HEAD before committing, or document that the scaffold PR may require manual rebase.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. In practice, the scaffold branch is created fresh from the default branch HEAD and only contains fullsend scaffold files (no user content), so staleness is low-risk — the PR diff will still be correct even if the base has advanced, and GitHub handles the merge. A force-update would require a new UpdateBranchRef forge method which is out of scope here. The idempotency guard (branchCommitted == false → skip PR creation) handles the common re-run case where files haven't changed.

Comment thread internal/cli/admin.go Outdated
}

if branchCommitted {
prBody := fmt.Sprintf("This PR adds the fullsend scaffold files for per-repo installation.\n\n"+

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] error handling / edge case

FakeClient shares CommitFilesChanged between CommitFiles and CommitFilesToBranch. The BranchUpToDate test relies on this shared control but the coupling is fragile and should be verified or separated.

Suggested fix: Consider adding a separate CommitFilesToBranchChanged field or documenting that CommitFilesChanged controls both methods.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted tradeoff. The shared CommitFilesChanged knob is intentional — both methods have the same idempotency semantics (compare content, return whether anything changed). Adding a separate field would add test API surface for no practical benefit since no test needs them to differ. The BranchUpToDate test name documents this coupling.

@waynesun09 waynesun09 force-pushed the fix-protected-branch-fallback branch from cc39241 to b7c4ac9 Compare June 12, 2026 01:43
@github-actions github-actions Bot removed the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 1:46 AM UTC · Completed 1:58 AM UTC
Commit: b7c4ac9 · View workflow run →

@fullsend-ai-review fullsend-ai-review Bot added the requires-manual-review Review requires human judgment label Jun 12, 2026
@rh-hemartin

Copy link
Copy Markdown
Member

Closes #1689

@waynesun09 waynesun09 added the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@waynesun09 waynesun09 force-pushed the fix-protected-branch-fallback branch from b7c4ac9 to 2aa22c4 Compare June 12, 2026 12:35
@github-actions github-actions Bot removed the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

🤖 Agent run interrupted (process terminated)
Commit: 3fa02b3 · View workflow run →

When `fullsend github setup owner/repo` or `fullsend admin install owner/repo`
encounters a protected default branch (422 on ref update), the CLI now falls back
to creating a feature branch and opening a PR with the scaffold files instead of
failing. Variables and secrets are set regardless of which path is taken.

- Add ErrBranchProtected sentinel and CommitFilesToBranch to forge.Client
- Refactor CommitFiles to detect 422 as branch protection failure
- Add PR-based fallback in applyPerRepoScaffold

Signed-off-by: Wayne Sun <gsun@redhat.com>
@waynesun09 waynesun09 force-pushed the fix-protected-branch-fallback branch from 2aa22c4 to c03bd36 Compare June 12, 2026 12:47
@waynesun09 waynesun09 added the ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) label Jun 12, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 12:51 PM UTC · Completed 1:03 PM UTC
Commit: c03bd36 · View workflow run →

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

}

// 8. Update branch ref to point to new commit.
// 7. Update branch ref to point to new commit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] fail-open

All 422 responses on the ref-update PATCH are mapped to ErrBranchProtected, but GitHub returns 422 for multiple distinct conditions beyond branch protection (invalid SHA, fast-forward conflict, nonexistent ref, other validation failures). Non-protection 422 errors would silently trigger the PR-creation fallback, masking the real error.

Suggested fix: Inspect the 422 response body (APIError.Message or APIError.Errors) for branch-protection-specific signals before wrapping as ErrBranchProtected. Only return the sentinel when the response clearly indicates branch protection.

Comment thread internal/layers/commit.go

const scaffoldBranch = "fullsend/scaffold-install"
if branchErr := client.CreateBranch(ctx, owner, repo, scaffoldBranch); branchErr != nil {
if !strings.Contains(branchErr.Error(), "already exists") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] race condition / branch staleness

When CreateBranch returns already exists (from a previous failed run), the existing branch may be based on an old commit of the default branch. CommitFilesToBranch commits on top of potentially stale content.

Suggested fix: When the scaffold branch already exists, delete and recreate it from the current default branch HEAD.

Comment thread internal/layers/commit.go
if branchErr := client.CreateBranch(ctx, owner, repo, scaffoldBranch); branchErr != nil {
if !strings.Contains(branchErr.Error(), "already exists") {
printer.StepFail("Failed to create scaffold branch")
return fmt.Errorf("creating scaffold branch: %w", branchErr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] error-handling-idiom

Error checking uses strings.Contains(err.Error(), already exists) for both CreateBranch and CreateChangeProposal errors instead of typed error comparison with sentinel errors.

Suggested fix: Define forge.ErrAlreadyExists sentinel and use errors.Is() checks.

@fullsend-ai-review fullsend-ai-review Bot added requires-manual-review Review requires human judgment and removed requires-manual-review Review requires human judgment labels Jun 12, 2026
@waynesun09 waynesun09 added ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) and removed ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) labels Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok-to-test Allow e2e CI to run after maintainer review (must be re-applied after each push) requires-manual-review Review requires human judgment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

install: fall back to PR when default branch push is rejected

2 participants