Skip to content

SideRepoOps: API for a user step to resolve the target branch at runtime, then check it out and use it as the patch base (dynamic base; not covered by #39407) #41265

Description

@yskopets

🤖 This issue was investigated and filed by Claude Code.

Summary

In a SideRepoOps workflow, the branch of the target/side repository that a given run should operate on is sometimes only knowable at runtime — e.g. it is read from a label on the triggering issue, a dispatch input, or an API lookup — and is not fixed in the workflow YAML. Today there is no supported way to:

  1. let a user-defined step resolve that branch for the current run, and
  2. have gh-aw check out the target repo at that branch and use it as the base when generating the create_pull_request / push_to_pull_request_branch patch.

Because nothing carries a runtime-resolved branch into patch generation, the patch is computed against the target repo's default branch. The merge-base lies far behind the intended branch, so the diff drags in the entire default→release divergence — blowing past max-patch-files and failing PR creation.

Request: an API / extension point for a runtime-resolved base branch in the SideRepoOps pattern.

How this differs from #39407

#39407 covers the static case: a checkout that pins a non-default ref directly in YAML (checkout: [{ repository, ref: <release-branch> }]), asking that the checked-out ref be used as the base; its suggested workaround is a static safe-outputs.create-pull-request.base-branch:.

This issue is the dynamic case, which neither that workaround nor the fix proposed in #39407 covers:

  • The branch is not known at compile time. It is computed per-run by a user step (e.g. from an issue's branch/<name> label). A static base-branch: or a static checkout.ref cannot express a per-run value.
  • SideRepoOps: create_pull_request bases PR on repo default branch, not the checked-out ref (breaks non-default/release-branch checkouts) #39407's proposed fix (have the manifest record the checked-out HEAD) is insufficient here. The only place a user step can re-anchor the checkout is after the built-in "Build checkout manifest for safe-outputs handlers" step (custom steps: are emitted after the checkout block). So at manifest time HEAD is still the default branch, and recording HEAD would record the default branch.
  • The per-call base argument is ignored by patch generation. When the agent calls create_pull_request with base: <release-branch> (permitted via allowed-base-branches), that value is honored for the eventual PR API target but is not used to compute the patch base — patch generation prefers the checkout manifest's default_branch.

In short: there is currently no mechanism — static config, static checkout ref, or per-call base argument — that lets a SideRepoOps run target a runtime-resolved branch and have the patch computed against it.

Where the base comes from (for reference)

Base resolution for create_pull_request (per #39407's citation of safe_outputs_handlers.cjs):

if (prConfig.base_branch) baseBranch = prConfig.base_branch;            // ① static config
else if (manifestEntry && manifestEntry.default_branch) baseBranch = …; // ② checkout manifest
else baseBranch = await getBaseBranch(...);                             // ③ repos.get().default_branch

The manifest's default_branch (build_checkout_manifest.cjs) is resolved as:

git -C <path> symbolic-ref --short refs/remotes/origin/HEAD       // → repo default
  └─ fallback → gh api repos/<owner>/<repo> --jq .default_branch  // → repo default

Neither path consults the per-call base argument, the DEFAULT_BRANCH env var, or any runtime-resolved value. ① is the only override, and it is static.

Observed failure (redacted)

A SideRepoOps run (gh-aw v0.80.9) in which the agent committed a small change (5 tracked files) on a feature branch derived from <release-branch>, then called:

create_pull_request { base: "<release-branch>", branch: "<feature-branch>", ... }

The safe-outputs server logged:

[safeoutputs] Using checkout-manifest default_branch for <owner>/<side-repo>: <default-branch>
[safeoutputs] Generating patch ... baseBranch: <default-branch>
[generate_git_patch] Starting patch generation: mode=full, branch=<feature-branch>, defaultBranch=<default-branch>
[generate_git_patch] Strategy 1 (full): Computing merge-base with <default-branch>
[generate_git_patch] Strategy 1: Found 455 commits between <merge-base> and <tip>
[generate_git_patch] Final: SUCCESS - patchSize≈66 MB, patchLines≈1.4M

i.e. the patch was computed as diff(origin/<default-branch>, HEAD) despite base: <release-branch> in the call. The resulting patch contained thousands of files (generated .gen.go / .pb.* artifacts produced by the default↔release divergence — none of them touched by the agent), exceeding max-patch-files, so PR creation was rejected and fell back to a review issue.

Current workaround and why it silently fails

A custom step placed after the manifest step re-anchors the checkout and tries to override the base:

git -C <side-repo> checkout -B "<branch>" "origin/<branch>"
git -C <side-repo> remote set-head origin "<branch>"
echo "DEFAULT_BRANCH=<branch>" >> "$GITHUB_ENV"

This no longer works: the safe-outputs patch generator reads the checkout manifest (built earlier, recording the default branch), which takes precedence over the DEFAULT_BRANCH env override. The manifest is frozen before the re-anchor runs, so the patch base reverts to the default branch — with no error, just a huge patch.

Requested API

A supported way to inject a runtime base-branch resolution into the SideRepoOps lifecycle. Sketch of the contract:

  • A documented hook/step that runs before the cross-repo checkout + manifest, in which a user step resolves the target branch for this run (e.g. writes it to a known step output / env var / file).
  • gh-aw then (a) checks out the target repo at that branch, and (b) records it as the manifest default_branch / patch base for create_pull_request and push_to_pull_request_branch.

Possible shapes (any one would suffice):

  1. A frontmatter field such as safe-outputs.create-pull-request.base-branch-from: pointing at a step output / env var that gh-aw reads at runtime, and which also drives the target-repo checkout ref.
  2. A pre-checkout / resolve-base extension point that runs before the cross-repo checkout + manifest, whose output sets both the checkout ref and the manifest default_branch.
  3. Make patch generation honor the per-call base argument (and/or the existing DEFAULT_BRANCH env override) so a custom step that has already resolved the branch can set it.

Options 1 and 2 are preferable, because they derive the checkout ref and the patch base from a single user-resolved value, eliminating the drift that comes from specifying the branch in two places.

Redaction note

Private repository names, branch names, issue numbers, and run URLs are replaced with placeholders (<owner>/<side-repo>, <release-branch>, <default-branch>, <feature-branch>). Related: #39407.

Activity

  1. locked and limited conversation to collaborators on Jun 24, 2026
  2. unlocked this conversation on Jun 24, 2026
  3. yskopets commented on Jun 24, 2026

    @yskopets
    Author

    🤖 This comment has been generated by Claude Code.

    Having now built a downstream workaround for this, I want to propose what I think is the cleanest preferred solution — and flag one implementation subtlety so it doesn't get lost, because it's the difference between "works" and "looks like it works."

    Preferred solution

    Let checkout.ref reference the output of a pre-step (i.e. accept a runtime ${{ steps.* }} expression), and have gh-aw treat the checked-out ref as the base for create_pull_request / push_to_pull_request_branch.

    From the user's perspective this is the only knob they touch: resolve the branch in a pre-step (e.g. from an issue label, a dispatch input, an API lookup), point checkout.ref at that output, and the PR/patch is naturally based on the branch they checked out. That's the intuitive contract — the PR is based on what I checked out — with no base-branch duplication, no manifest fiddling, and no post-checkout re-anchoring. It also covers the dynamic / per-run case that a static base-branch: or a static checkout.ref can't express.

    The subtlety: "check out the ref" is not automatically enough today

    The patch base is currently derived from the checkout manifest's default_branch, which build_checkout_manifest.cjs resolves as:

    git -C <path> symbolic-ref --short refs/remotes/origin/HEAD   // primary
      └─ fallback → gh api repos/<owner>/<repo> --jq .default_branch
    

    actions/checkout does not set refs/remotes/origin/HEAD, even when given an explicit ref. I verified this by simulating the exact checkout flow gh-aw emits (init + git fetch origin <ref> + checkout + the "Fetch additional refs" git fetch origin '+refs/heads/*:refs/remotes/origin/*'):

    current branch: <release-branch>
    git symbolic-ref refs/remotes/origin/HEAD
      => fatal: ref refs/remotes/origin/HEAD is not a symbolic ref   (NOT set)
    

    So the resolver falls through to the GitHub API default branch, and the patch is computed as diff(origin/<default-branch>, HEAD) regardless of which ref was checked out — pulling in the entire <default-branch>→<release-branch> divergence. In other words, wiring checkout.ref to a pre-step output alone would put the working tree on the right branch but still base the patch on the default branch.

    What the implementation needs

    For the preferred solution to "just work," gh-aw must derive the base from the checked-out ref rather than origin/HEAD / the API. Concretely, the checkout-manifest step (or the checkout step) could record the actually-checked-out branch — e.g. git -C <path> rev-parse --abbrev-ref HEAD, or run git remote set-head origin <ref> so the resolver's primary lookup succeeds. Combined with checkout.ref accepting a pre-step output, the entire user-facing surface collapses to a single, intuitive field.

    This is the same "base should track the checked-out ref" point from the original report; the pre-step-output ref is what makes it usable when the branch isn't known at compile time.

    Redaction note: placeholders as before (<owner>/<side-repo>, <release-branch>, <default-branch>).

  4. locked and limited conversation to collaborators on Jun 24, 2026
  5. unlocked this conversation on Jun 24, 2026
  6. IEvangelist commented on Aug 18, 2026

    @IEvangelist

    Aspire now has a concrete v0.86.2 run showing the remaining cross-job half of this dynamic-base problem: https://github.com/microsoft/aspire/actions/runs/32112079288

    We use an agent-job workaround that resolves the docs target at runtime and configures:

    safe-outputs:
      create-pull-request:
        base-branch: ${{ steps.resolve-target.outputs.branch || 'main' }}

    For this run, the resolver selected release/13.5. That was sufficient for agent-time patch generation: gh-aw produced a valid three-file patch/bundle whose base is exactly aa2777825624f037a77160728939e36f7c788eff (release/13.5).

    The preserved RPC trace is important: the model's create_pull_request MCP request contained only title and body. The safeoutputs server config had create_pull_request.base_branch = release/13.5, and gh-aw itself enriched the raw safeoutputs.jsonl item with:

    {
      "head_repo": "microsoft/aspire.dev",
      "base_branch": "release/13.5",
      "base_commit": "aa2777825624f037a77160728939e36f7c788eff",
      "branch": "docs/pr-17235-32112079288-1"
    }

    However, canonical /tmp/gh-aw/agent_output.json retained only type, body, branch, and title for the PR item. The resolved base metadata was not transported into the separate safe-output application job. In that job the resolver step does not exist, so the same expression falls back to main; without a supported cross-job runtime-base value, apply-time checkout cannot reliably select the branch used to generate the patch.

    Our fail-closed compatibility step rejected the missing canonical target instead of applying the release-based bundle to main, so no incorrect PR was created. We can make the repository workaround parse and validate the server-enriched raw artifact, but that is relying on internal artifact shape.

    This run therefore demonstrates that a step-output base-branch expression can fix patch generation while the runtime-resolved branch still lacks a supported path into the safe-output job. A first-class mechanism that carries one resolved value through checkout, patch generation, and application would remove this fragile split.

  7. locked and limited conversation to collaborators on Aug 18, 2026
  8. unlocked this conversation on Aug 18, 2026
  9. pelikhan commented on Oct 7, 2026

    @pelikhan
    Collaborator

    Status (2026-10-07; Contributor proposal): The August 18 Aspire report says a runtime-resolved release/13.5 base produced the intended agent-time patch, but the resolved base metadata did not reach the separate safe-output application job. The proposed next step is to carry that base through collection and application, then test that the resulting PR targets the same branch used for patch generation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions