Skip to content

Commit 4c165b6

Browse files
authored
Fix backport AI permission, surface infra failures, and allow manual dispatch (#1943)
* Hoist AI model env, fix opencode external_directory permission, fail loud on AI infra errors Three related fixes triggered by the failed run on #1935: 1. Hoist the AI model name to a top-level `AI_MODEL` env var (`anthropic/claude-opus-4.7`); both `opencode run` invocations now interpolate `vercel/${AI_MODEL}` so the model is specified in exactly one place. 2. Switch `OPENCODE_PERMISSION` from the bare-string shortcut `"allow"` to the explicit object form `{"*":"allow","external_directory":"allow"}`. The shortcut was observed not to override `external_directory` (which defaults to "ask" and auto-rejects in non-interactive `opencode run`), causing the conflict-resolution AI to fail when reading scratch files it created under `/tmp/`. 3. The `Resolve conflicts with opencode` step no longer uses `continue-on-error`, and now distinguishes two outcomes via an AI- written outcome file (`.backport-conflict-outcome.json`): - `{"status":"resolved"}` — the legitimate clean path; cherry-pick continues and the backport PR is opened. - `{"status":"unresolved", ...}` — the legitimate "AI couldn't do it, hand off to a human" path; `resolved=false` is set and the conflict-failure comment is posted on the source PR. - Anything else (missing file, malformed JSON, unknown status) is treated as an opencode/AI Gateway infra failure: the step exits non-zero, the workflow fails red, and the misleading "couldn't resolve" comment is suppressed. The prompt + scratch files are also moved into the workspace so opencode never needs `external_directory` access anyway. * Allow manual workflow_dispatch with ref+model inputs; use AI_MODEL in PR body Add a `workflow_dispatch` trigger to the backport workflow with two optional inputs: - `ref` — commit SHA on `main` to back-port (defaults to `main` HEAD) - `model` — overrides the default AI model used by opencode for the decision and conflict-resolution steps (defaults to the workflow's hardcoded `AI_MODEL`) The top-level `AI_MODEL` env var now uses `${{ inputs.model || 'anthropic/claude-opus-4.7' }}` so manual runs pick up the override without changing anything else. Manual dispatch (like the `backport-stable` label) always forces a backport regardless of any AI verdict — the operator's intent is explicit by virtue of triggering the workflow. The PR body shows "Triggered manually via `workflow_dispatch`." in that case. The PR body's conflict-resolution attribution also now interpolates `${AI_MODEL}` (e.g. "opencode with `anthropic/claude-opus-4.7`") instead of hardcoding "Claude Opus" so the text stays accurate if the default model is later changed. * Address PR review: also detect leftover conflict markers in staged files The previous `Resolve conflicts with opencode` sanity check used `git diff --diff-filter=U` to detect unresolved cherry-pick conflicts, which only catches unmerged index entries. That misses the case where the AI runs `git add` on a file that still has `<<<<<<<` / `=======` / `>>>>>>>` markers in its content — git happily stages the broken file as a normal modification. Add a second check using `git diff --check --cached`, which emits `leftover conflict marker` lines when any staged content still has the standard markers. Grep specifically for that phrase so unrelated whitespace warnings don't trip the check. Also update the inline comment to accurately describe what each check covers (per Copilot's review on #1943).
1 parent d0e3f27 commit 4c165b6

2 files changed

Lines changed: 196 additions & 38 deletions

File tree

.github/workflows/backport.yml

Lines changed: 194 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,34 @@ on:
66
pull_request_target:
77
types: [labeled]
88
branches: [main]
9+
workflow_dispatch:
10+
inputs:
11+
ref:
12+
description: 'Commit SHA on `main` to back-port. Defaults to the current HEAD of `main`.'
13+
required: false
14+
type: string
15+
model:
16+
description: 'AI model used for AI-assisted decisions and conflict resolution. Format: `<provider>/<model>` (the `vercel/` prefix is added automatically). Leave blank to use the workflow default.'
17+
required: false
18+
type: string
919

1020
concurrency: backport-stable
1121

22+
# AI model used by every opencode invocation in this workflow. The
23+
# `vercel/` provider prefix is added at each use site, so this should
24+
# be specified as `<provider>/<model>` (e.g. `anthropic/claude-opus-4.7`).
25+
# Manual `workflow_dispatch` runs may override this via the `model` input.
26+
env:
27+
AI_MODEL: ${{ inputs.model || 'anthropic/claude-opus-4.7' }}
28+
1229
jobs:
1330
backport:
1431
name: Backport to stable
1532
# Run on every push to main, OR when the `backport-stable` label is added
16-
# to a merged PR (manual override / re-trigger).
33+
# to a merged PR (manual override / re-trigger), OR on manual dispatch.
1734
if: |
1835
github.event_name == 'push' ||
36+
github.event_name == 'workflow_dispatch' ||
1937
(github.event_name == 'pull_request_target' &&
2038
github.event.pull_request.merged == true &&
2139
github.event.label.name == 'backport-stable')
@@ -32,6 +50,7 @@ jobs:
3250
EVENT_NAME: ${{ github.event_name }}
3351
PUSH_SHA: ${{ github.event.after }}
3452
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
53+
DISPATCH_REF: ${{ inputs.ref }}
3554
run: |
3655
# On push, we only consider the head commit of the push
3756
# (`github.event.after`). This matches our merge model: PRs land on
@@ -41,13 +60,36 @@ jobs:
4160
# considered for backport — those edge cases can be handled by
4261
# adding the `backport-stable` label to the relevant PRs to
4362
# re-trigger this workflow against each one.
44-
if [ "$EVENT_NAME" = "push" ]; then
45-
SHA="$PUSH_SHA"
46-
echo "trigger=push" >> "$GITHUB_OUTPUT"
47-
else
48-
SHA="$MERGE_SHA"
49-
echo "trigger=label" >> "$GITHUB_OUTPUT"
50-
fi
63+
#
64+
# On manual `workflow_dispatch`, the `ref` input may specify a
65+
# particular SHA on `main` (or any commit-ish that resolves to a
66+
# SHA via `gh api`); when blank we default to the current
67+
# `main` HEAD.
68+
case "$EVENT_NAME" in
69+
push)
70+
SHA="$PUSH_SHA"
71+
echo "trigger=push" >> "$GITHUB_OUTPUT"
72+
;;
73+
workflow_dispatch)
74+
if [ -n "$DISPATCH_REF" ]; then
75+
# Resolve the user-supplied ref to a full SHA so downstream
76+
# steps can compare/re-use it.
77+
SHA=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DISPATCH_REF}" --jq '.sha')
78+
if [ -z "$SHA" ]; then
79+
echo "::error::Could not resolve workflow_dispatch input ref '${DISPATCH_REF}' to a SHA."
80+
exit 1
81+
fi
82+
else
83+
# Default to the current HEAD of `main`.
84+
SHA=$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq '.sha')
85+
fi
86+
echo "trigger=dispatch" >> "$GITHUB_OUTPUT"
87+
;;
88+
*)
89+
SHA="$MERGE_SHA"
90+
echo "trigger=label" >> "$GITHUB_OUTPUT"
91+
;;
92+
esac
5193
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
5294
echo "Resolved commit SHA: $SHA"
5395
@@ -94,15 +136,22 @@ jobs:
94136
exit 0
95137
fi
96138
97-
# Push trigger: look up PR associated with this commit (if any).
139+
# Push or workflow_dispatch trigger: look up PR associated with
140+
# this commit (if any).
98141
PR_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/pulls" --jq '.[0] // empty')
99142
100143
if [ -z "$PR_JSON" ]; then
101144
echo "No PR associated with commit ${SHA}"
102145
echo "pr_number=" >> "$GITHUB_OUTPUT"
103146
echo "pr_title=" >> "$GITHUB_OUTPUT"
104147
echo "pr_body=" >> "$GITHUB_OUTPUT"
105-
echo "force_backport=false" >> "$GITHUB_OUTPUT"
148+
# Manual dispatch always forces a backport (explicit user
149+
# intent), even when no associated PR is found.
150+
if [ "$TRIGGER" = "dispatch" ]; then
151+
echo "force_backport=true" >> "$GITHUB_OUTPUT"
152+
else
153+
echo "force_backport=false" >> "$GITHUB_OUTPUT"
154+
fi
106155
exit 0
107156
fi
108157
@@ -115,7 +164,12 @@ jobs:
115164
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
116165
write_multiline_output "pr_title" "$PR_TITLE"
117166
write_multiline_output "pr_body" "$PR_BODY"
118-
echo "force_backport=$HAS_LABEL" >> "$GITHUB_OUTPUT"
167+
# Manual dispatch always forces; otherwise honor the label.
168+
if [ "$TRIGGER" = "dispatch" ] || [ "$HAS_LABEL" = "true" ]; then
169+
echo "force_backport=true" >> "$GITHUB_OUTPUT"
170+
else
171+
echo "force_backport=false" >> "$GITHUB_OUTPUT"
172+
fi
119173
120174
- name: Check if backport PR already exists
121175
id: existing-pr
@@ -171,12 +225,12 @@ jobs:
171225
id: decide
172226
if: steps.existing-pr.outputs.exists != 'true'
173227
env:
174-
# Allow opencode tools to run without prompting. The value is the
175-
# entire `permission` config (see https://opencode.ai/docs/permissions/);
176-
# the bare string "allow" applies allow-everything to every scope,
177-
# including `external_directory` (which defaults to "ask" and would
178-
# otherwise auto-reject in non-interactive `opencode run`).
179-
OPENCODE_PERMISSION: '"allow"'
228+
# Allow opencode tools to run without prompting. We pass the full
229+
# object form (see https://opencode.ai/docs/permissions/) — the
230+
# bare string "allow" shortcut was observed not to override
231+
# `external_directory` (which defaults to "ask" and auto-rejects
232+
# in non-interactive `opencode run`), so we list it explicitly.
233+
OPENCODE_PERMISSION: '{"*":"allow","external_directory":"allow"}'
180234
SHA: ${{ steps.resolve.outputs.sha }}
181235
PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }}
182236
PR_TITLE: ${{ steps.pr-lookup.outputs.pr_title }}
@@ -295,7 +349,7 @@ jobs:
295349
# code alone — but with `set -e` above, any non-zero exit will also
296350
# fail the job. The real signal is whether the decision file was
297351
# produced.
298-
opencode run --model vercel/anthropic/claude-opus-4.7 "$(cat .backport-decision-prompt.txt)"
352+
opencode run --model "vercel/${AI_MODEL}" "$(cat .backport-decision-prompt.txt)"
299353
300354
if [ ! -f "$DECISION_FILE" ]; then
301355
echo "::error::AI did not produce a decision file at ${DECISION_FILE}. This usually indicates an opencode/AI Gateway infrastructure failure (e.g. expired API key, gateway down, or rejected tool call) — check the step output above. To force a backport regardless of the AI decision, add the \`backport-stable\` label to the source PR."
@@ -423,17 +477,40 @@ jobs:
423477
- name: Resolve conflicts with opencode
424478
if: steps.cherry-pick.outputs.status == 'conflict'
425479
id: ai-resolve
426-
continue-on-error: true
480+
# We don't use `continue-on-error` here because we want to
481+
# distinguish two outcomes:
482+
# 1. opencode ran cleanly but couldn't fully resolve conflicts
483+
# (the legitimate "needs human help" path) — we set
484+
# `resolved=false` and the next step posts a manual-resolution
485+
# comment on the source PR.
486+
# 2. opencode itself errored (auth failure, permission rejection,
487+
# crash, etc.) — that's an infra problem; we exit non-zero so
488+
# the workflow fails loudly and we notice instead of silently
489+
# claiming the conflicts couldn't be resolved.
427490
env:
428491
# Allow opencode tools to run without prompting. See the matching
429492
# comment on the `Decide whether to backport` step for details.
430-
OPENCODE_PERMISSION: '"allow"'
493+
OPENCODE_PERMISSION: '{"*":"allow","external_directory":"allow"}'
431494
SHA: ${{ steps.resolve.outputs.sha }}
432495
PR_TITLE: ${{ steps.pr-lookup.outputs.pr_title }}
433496
run: |
497+
# Fail loudly on any unhandled error; we explicitly handle the
498+
# "AI ran cleanly but couldn't resolve all conflicts" case below
499+
# by setting `resolved=false` and exiting 0.
500+
set -euo pipefail
501+
434502
COMMIT_MSG=$(git log -1 --format='%B' "$SHA")
435503
436-
cat > /tmp/backport-prompt.txt <<PROMPT
504+
# The AI writes its outcome here. The presence of this file (with
505+
# a recognized status) is how we tell "AI ran cleanly" apart from
506+
# "opencode itself crashed / hit an auth or permission error",
507+
# which we treat as an infra failure.
508+
OUTCOME_FILE=".backport-conflict-outcome.json"
509+
rm -f "$OUTCOME_FILE"
510+
511+
# Prompt file lives inside the workspace so opencode never needs
512+
# `external_directory` access just to read it.
513+
cat > .backport-conflict-prompt.txt <<PROMPT
437514
The working tree has git merge conflicts from a failed cherry-pick.
438515
A commit is being cherry-picked from the main branch to the stable branch.
439516
@@ -460,20 +537,88 @@ jobs:
460537
<<<<<<< HEAD and ======= is the current stable branch. The content between
461538
======= and >>>>>>> is the incoming change from main.
462539
463-
After resolving each file, run git add on it to mark it as resolved.
464-
Do NOT run git cherry-pick --continue or git commit.
540+
When working with intermediate scratch files (diffs, notes, etc.),
541+
keep them inside the current working directory rather than under
542+
/tmp — the working directory is already part of your workspace and
543+
doesn't require the \`external_directory\` permission.
544+
545+
After resolving each file, run \`git add\` on it to mark it as
546+
resolved. Do NOT run \`git cherry-pick --continue\` or \`git commit\`.
547+
548+
When you are done, write a JSON file at \`$OUTCOME_FILE\` (relative
549+
to the current working directory) with EXACTLY one of these shapes:
550+
551+
{"status":"resolved"} // every conflict was resolved cleanly
552+
{"status":"unresolved","reason":"<short explanation>"} // some
553+
// conflicts couldn't be resolved (or
554+
// were too risky to resolve safely)
555+
556+
Use the \`write\` tool to create the file. The file is the only
557+
signal we use for whether the resolution succeeded; do not skip it.
465558
PROMPT
466559
467-
opencode run --model vercel/anthropic/claude-opus-4.7 "$(cat /tmp/backport-prompt.txt)"
560+
# `opencode run` may exit 0 even on infra failures (auth errors,
561+
# rejected tool calls). The outcome-file presence + `set -e` on
562+
# this step's exit are what we rely on to distinguish infra vs.
563+
# AI-said-no.
564+
opencode run --model "vercel/${AI_MODEL}" "$(cat .backport-conflict-prompt.txt)"
468565
469-
# Verify all conflicts are resolved
470-
REMAINING=$(git diff --name-only --diff-filter=U || true)
471-
if [ -z "$REMAINING" ]; then
472-
GIT_EDITOR=true git cherry-pick --continue
473-
echo "resolved=true" >> "$GITHUB_OUTPUT"
474-
echo "cherry_pick_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
566+
if [ ! -f "$OUTCOME_FILE" ]; then
567+
echo "::error::AI conflict resolution did not produce ${OUTCOME_FILE}. This usually indicates an opencode/AI Gateway infrastructure failure (e.g. expired API key, rejected tool call, opencode crash) — check the step output above. To resolve manually, see the source PR for instructions."
568+
exit 1
475569
fi
476570
571+
if ! jq empty "$OUTCOME_FILE" 2>/dev/null; then
572+
echo "::error::AI outcome file at ${OUTCOME_FILE} is not valid JSON:"
573+
cat "$OUTCOME_FILE"
574+
exit 1
575+
fi
576+
577+
STATUS=$(jq -r '.status' "$OUTCOME_FILE")
578+
case "$STATUS" in
579+
resolved)
580+
# Sanity-check the AI's claim before committing the
581+
# cherry-pick. Two failure modes to defend against:
582+
#
583+
# 1. Files left as unmerged index entries (the AI didn't
584+
# `git add` them, or didn't resolve them at all).
585+
UNMERGED=$(git diff --name-only --diff-filter=U || true)
586+
if [ -n "$UNMERGED" ]; then
587+
echo "::warning::AI claimed conflicts were resolved but the following files still have unmerged index entries; treating as unresolved:"
588+
echo "$UNMERGED"
589+
echo "resolved=false" >> "$GITHUB_OUTPUT"
590+
exit 0
591+
fi
592+
# 2. Files that were `git add`-ed but still contain conflict
593+
# markers in their content. `git diff --check --cached`
594+
# flags lines like `file.txt:1: leftover conflict marker`
595+
# when any staged file contains the standard markers; we
596+
# grep specifically for that phrase so an unrelated
597+
# whitespace warning doesn't trip the check.
598+
CHECK_OUTPUT=$(git diff --check --cached 2>&1 || true)
599+
if echo "$CHECK_OUTPUT" | grep -q "leftover conflict marker"; then
600+
echo "::warning::AI claimed conflicts were resolved but staged files still contain conflict markers (<<<<<<<, =======, >>>>>>>); treating as unresolved:"
601+
echo "$CHECK_OUTPUT" | grep "leftover conflict marker"
602+
echo "resolved=false" >> "$GITHUB_OUTPUT"
603+
exit 0
604+
fi
605+
606+
GIT_EDITOR=true git cherry-pick --continue
607+
echo "resolved=true" >> "$GITHUB_OUTPUT"
608+
echo "cherry_pick_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
609+
;;
610+
unresolved)
611+
REASON=$(jq -r '.reason // "(no reason given)"' "$OUTCOME_FILE")
612+
echo "AI could not fully resolve conflicts: $REASON"
613+
echo "resolved=false" >> "$GITHUB_OUTPUT"
614+
;;
615+
*)
616+
echo "::error::AI returned unexpected status '$STATUS' (expected 'resolved' or 'unresolved'). Outcome file:"
617+
cat "$OUTCOME_FILE"
618+
exit 1
619+
;;
620+
esac
621+
477622
- name: Push backport branch via GitHub API
478623
# Branch protection on this repo requires verified signatures on
479624
# every ref (an enterprise-level ruleset matching `~ALL`). A normal
@@ -717,15 +862,21 @@ jobs:
717862
fi
718863
echo ""
719864
720-
if [ "$TRIGGER" = "label" ]; then
721-
echo "Triggered by the \`backport-stable\` label."
722-
else
723-
echo "**AI recommendation:** $AI_REASONING"
724-
fi
865+
case "$TRIGGER" in
866+
label)
867+
echo "Triggered by the \`backport-stable\` label."
868+
;;
869+
dispatch)
870+
echo "Triggered manually via \`workflow_dispatch\`."
871+
;;
872+
*)
873+
echo "**AI recommendation:** $AI_REASONING"
874+
;;
875+
esac
725876
726877
if [ "$CHERRY_PICK_STATUS" = "conflict" ]; then
727878
echo ""
728-
echo "Merge conflicts were resolved by AI ([opencode](https://opencode.ai) with Claude Opus). **Please review the conflict resolution carefully before merging.**"
879+
echo "Merge conflicts were resolved by AI ([opencode](https://opencode.ai) with \`${AI_MODEL}\`). **Please review the conflict resolution carefully before merging.**"
729880
fi
730881
} > /tmp/pr-body.md
731882
@@ -771,10 +922,15 @@ jobs:
771922
});
772923
773924
- name: Comment on conflict failure
925+
# Only post the manual-resolution comment when the AI ran cleanly
926+
# but couldn't resolve all conflicts (`resolved=false`). Infra
927+
# failures (auth errors, opencode crashes, etc.) cause the
928+
# `Resolve conflicts with opencode` step to exit non-zero, which
929+
# fails the job — we don't want to also post a misleading
930+
# "couldn't resolve" comment in that case.
774931
if: |
775-
always() &&
776932
steps.cherry-pick.outputs.status == 'conflict' &&
777-
steps.ai-resolve.outputs.resolved != 'true' &&
933+
steps.ai-resolve.outputs.resolved == 'false' &&
778934
steps.pr-lookup.outputs.pr_number != ''
779935
uses: actions/github-script@v7
780936
env:

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ When in doubt, AI is told to lean toward recommending a backport — a human rev
239239

240240
**Manual override.** Adding the `backport-stable` label to a merged PR forces a backport regardless of AI's verdict (it skips AI analysis entirely). Use this when AI declined a backport that you want to ship to `stable`, or when AI hasn't run yet and you want to express intent up front. The label can be applied before or after merging — the action triggers on both push events and label events.
241241

242+
The workflow can also be run manually from the GitHub Actions UI via `workflow_dispatch`, which accepts an optional `ref` input (a commit SHA on `main`; defaults to `main` HEAD) and an optional `model` input (the AI model used for AI-assisted decisions and conflict resolution, in `<provider>/<model>` form — defaults to the workflow's current default). Manual dispatch always forces a backport (skipping AI analysis), the same way the label does.
243+
242244
**No-backport notification.** When AI decides against a backport, it leaves a comment on the source PR (if one is associated with the commit) explaining its reasoning, with instructions for forcing a backport via the `backport-stable` label.
243245

244246
**Conflict handling.** If the cherry-pick fails due to conflicts, the action first auto-resolves conflicts in directories that are not maintained on `stable` (docs app files under `docs/` except `docs/content/`, and any files under `skills/`) by keeping the `stable` branch version. It also auto-resolves `pnpm-lock.yaml` conflicts by re-running `pnpm install`. Any remaining conflicts are resolved using [opencode](https://opencode.ai) (AI-powered conflict resolution); the resulting backport PR notes that conflicts were AI-resolved and must be reviewed carefully. If AI cannot resolve the conflicts, the action comments on the original PR with instructions for manual resolution.

0 commit comments

Comments
 (0)