mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
1149 lines
57 KiB
YAML
1149 lines
57 KiB
YAML
name: Backport to stable
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
workflow_dispatch:
|
|
inputs:
|
|
ref:
|
|
description: 'Commit SHA on `main` to back-port. Defaults to the current HEAD of `main`.'
|
|
required: false
|
|
type: string
|
|
model:
|
|
description: 'AI model used for AI-assisted decisions and conflict resolution, as a `<provider>/<model>` id the Vercel AI Gateway serves (e.g. `anthropic/claude-opus-5` — see https://ai-gateway.vercel.sh/v1/models). The `vercel/` prefix is added automatically, so do not include it. Leave blank to use the workflow default.'
|
|
required: false
|
|
type: string
|
|
|
|
# One backport at a time per target commit, rather than one across the whole
|
|
# repository. A single global group loses runs: GitHub keeps only one *pending*
|
|
# run per group, so any new run silently cancels a queued one. Observed
|
|
# 2026-08-18: a manual `workflow_dispatch` backport was cancelled by a push to
|
|
# `main`, and a push run for 7027301 was cancelled by the next push, so that
|
|
# commit got no backport evaluation at all. Neither leaves a signal anywhere.
|
|
#
|
|
# Keying on the target commit is safe because runs never push to `stable`
|
|
# itself — each one cherry-picks locally and pushes its own
|
|
# `backport/pr-<n>-to-stable` branch — so two runs for different commits cannot
|
|
# interfere.
|
|
#
|
|
# `github.sha` (not `github.event.after`) is the fallback so a dispatch with a
|
|
# blank `ref` lands in the same group as the push run for that commit: on
|
|
# `workflow_dispatch` it is the HEAD of `main`, which is exactly what the
|
|
# `Resolve commit SHA` step defaults to, and on `push` it is the pushed head
|
|
# commit. Residual gap: a dispatch whose `ref` input is a short SHA or a branch
|
|
# name keys off that literal string, so it can still run alongside the push run
|
|
# for the same commit; pass a full SHA (what the no-backport comment gives you)
|
|
# to stay in the same group.
|
|
concurrency: backport-stable-${{ inputs.ref || github.sha }}
|
|
|
|
# AI model used by every opencode invocation in this workflow. The
|
|
# `vercel/` provider prefix is added at each use site, so this should
|
|
# be specified as `<provider>/<model>` (e.g. `anthropic/claude-opus-5`).
|
|
# The model slug must match an id the Vercel AI Gateway exposes (see
|
|
# https://ai-gateway.vercel.sh/v1/models); the `Validate AI model` step
|
|
# below checks that up front so a bad slug fails fast with a readable
|
|
# error instead of an opaque gateway failure mid-run.
|
|
# Manual `workflow_dispatch` runs may override this via the `model` input.
|
|
env:
|
|
AI_MODEL: ${{ inputs.model || 'anthropic/claude-opus-5' }}
|
|
|
|
jobs:
|
|
backport:
|
|
name: Backport to stable
|
|
# Runs automatically on every push to `main` (AI decides whether to
|
|
# backport), or manually via `workflow_dispatch` (always forces a
|
|
# backport). To force a backport after the fact, re-run this workflow
|
|
# via `workflow_dispatch` with the relevant commit SHA.
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
issues: write
|
|
steps:
|
|
- name: Validate AI model
|
|
# Deliberately inline rather than a `.github/scripts/*.js` helper with
|
|
# unit tests (this repo's usual home for CI logic): `Checkout Repo`
|
|
# below checks out the *commit being backported*, which for a manual
|
|
# dispatch of an older SHA can predate any script we add here. Logic
|
|
# in the workflow file always comes from the workflow ref, so it works
|
|
# no matter which commit is being backported. Keep it that way.
|
|
env:
|
|
# Empty on `push`; non-empty only when a manual run overrode the
|
|
# model, which lets us word the error for whoever can fix it.
|
|
MODEL_INPUT: ${{ inputs.model }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Every opencode call site runs `--model vercel/${AI_MODEL}`, so
|
|
# `AI_MODEL` has to be a `<provider>/<model>` id the Vercel AI
|
|
# Gateway actually serves. An unserved slug fails inside opencode,
|
|
# not at the gateway: opencode resolves the model against its own
|
|
# provider catalog and throws `ProviderModelNotFoundError` from
|
|
# `SessionPrompt.getModel` before any request goes out. We check the
|
|
# slug here instead so the failure names the model.
|
|
#
|
|
# What reaches the job for that is
|
|
# `UnknownError: Unexpected server error`, which is worth reading
|
|
# correctly because it does NOT mean "bad model". It is what
|
|
# opencode's own HTTP server returns for *any* unhandled error in a
|
|
# route handler: the real error and its stack go to opencode's log
|
|
# under a random `ref`, and the client gets only the generic message
|
|
# plus that `ref`. Both `opencode run` call sites therefore pass
|
|
# `--print-logs`, which puts the log on stderr, so the preceding
|
|
# `level=ERROR message=failed ref=<same ref> error=... cause=<stack>`
|
|
# line lands in the job output and the `ref` is resolvable. (Checked
|
|
# 1.18.4: the log carries no credentials.)
|
|
#
|
|
# Run 34382013303 on 2026-09-09 is the case this is written from. It
|
|
# hit that `UnknownError` two seconds into `Decide whether to
|
|
# backport`, and the cause is now unrecoverable: the AI Gateway
|
|
# recorded no request at all for that run, while two backport runs in
|
|
# the same minute, on the same key and model, succeeded, so it was an
|
|
# opencode-side crash before the first model request. Re-running the
|
|
# same commit passed. Without the server log there was nothing to
|
|
# diagnose and nothing to do but re-run blind.
|
|
#
|
|
# This matters most for manual `workflow_dispatch` runs: those force
|
|
# a backport and therefore skip `Decide whether to backport`, the
|
|
# step that exercises the model cheaply on every push. Without this
|
|
# check a typo in the `model` input stays latent until conflict
|
|
# resolution, i.e. after the cherry-pick, and only on the commits
|
|
# that happen to conflict.
|
|
MODELS_URL="https://ai-gateway.vercel.sh/v1/models"
|
|
|
|
# A pasted value can carry surrounding whitespace, which opencode
|
|
# would reject. Normalize it for every later step via $GITHUB_ENV
|
|
# (model ids never contain whitespace, so stripping all of it is
|
|
# safe).
|
|
MODEL=$(printf '%s' "$AI_MODEL" | tr -d '[:space:]')
|
|
if [ -z "$MODEL" ]; then
|
|
echo "::error::The \`model\` input is blank apart from whitespace. Leave it empty to use the workflow default."
|
|
exit 1
|
|
fi
|
|
if [ "$MODEL" != "$AI_MODEL" ]; then
|
|
echo "Trimmed whitespace from the AI model: '${AI_MODEL}' -> '${MODEL}'"
|
|
echo "AI_MODEL=${MODEL}" >> "$GITHUB_ENV"
|
|
fi
|
|
|
|
# Fail open on infra trouble: an unreachable or unparseable model
|
|
# list must not take down every backport run. We only want to reject
|
|
# slugs the gateway definitively does not serve.
|
|
if ! MODELS_JSON=$(curl -sfL --max-time 30 --retry 2 "$MODELS_URL"); then
|
|
echo "::warning::Could not fetch ${MODELS_URL}; skipping validation of AI model '${MODEL}'."
|
|
exit 0
|
|
fi
|
|
IDS=$(printf '%s' "$MODELS_JSON" | jq -r '.data[]?.id // empty' 2>/dev/null || true)
|
|
if [ -z "$IDS" ]; then
|
|
echo "::warning::${MODELS_URL} returned no usable model ids; skipping validation of AI model '${MODEL}'."
|
|
exit 0
|
|
fi
|
|
|
|
if printf '%s\n' "$IDS" | grep -Fxq -- "$MODEL"; then
|
|
echo "AI model '${MODEL}' is served by the Vercel AI Gateway."
|
|
exit 0
|
|
fi
|
|
|
|
# The common mistake is the right model under the wrong provider
|
|
# prefix (e.g. `claude/claude-opus-5` for
|
|
# `anthropic/claude-opus-5`), so suggest every id whose model name
|
|
# matches.
|
|
MODEL_NAME=${MODEL##*/}
|
|
SUGGESTIONS=$(printf '%s\n' "$IDS" | while IFS= read -r id; do
|
|
if [ "${id##*/}" = "$MODEL_NAME" ]; then printf '%s\n' "$id"; fi
|
|
done)
|
|
if [ -n "$SUGGESTIONS" ]; then
|
|
echo "Gateway model ids offering the same model name:"
|
|
printf '%s\n' "$SUGGESTIONS" | sed 's/^/ /'
|
|
HINT="Did you mean '$(printf '%s\n' "$SUGGESTIONS" | head -n 1)'?"
|
|
else
|
|
HINT="See ${MODELS_URL} for the ids the gateway serves."
|
|
fi
|
|
|
|
if [ -n "$MODEL_INPUT" ]; then
|
|
echo "::error::The \`model\` input '${MODEL}' is not a model id the Vercel AI Gateway serves. ${HINT} Pass a bare \`<provider>/<model>\` id; the \`vercel/\` prefix is added automatically."
|
|
else
|
|
echo "::error::The workflow's default AI model '${MODEL}' is not a model id the Vercel AI Gateway serves. ${HINT} Fix the \`AI_MODEL\` default in .github/workflows/backport.yml."
|
|
fi
|
|
exit 1
|
|
|
|
- name: Resolve commit SHA
|
|
id: resolve
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
PUSH_SHA: ${{ github.event.after }}
|
|
DISPATCH_REF: ${{ inputs.ref }}
|
|
run: |
|
|
# On push, we only consider the head commit of the push
|
|
# (`github.event.after`). This matches our merge model: PRs land on
|
|
# `main` as a single squash-merge commit, and direct pushes to
|
|
# `main` are forbidden by AGENTS.md. Multi-commit pushes (e.g. an
|
|
# accidental rebase merge) would only have their head commit
|
|
# considered for backport — those edge cases can be handled by
|
|
# re-running this workflow via `workflow_dispatch` with the
|
|
# relevant commit SHA.
|
|
#
|
|
# On manual `workflow_dispatch`, the `ref` input may specify a
|
|
# particular SHA on `main` (or any commit-ish that resolves to a
|
|
# SHA via `gh api`); when blank we default to the current
|
|
# `main` HEAD.
|
|
case "$EVENT_NAME" in
|
|
push)
|
|
SHA="$PUSH_SHA"
|
|
echo "trigger=push" >> "$GITHUB_OUTPUT"
|
|
;;
|
|
workflow_dispatch)
|
|
if [ -n "$DISPATCH_REF" ]; then
|
|
# Resolve the user-supplied ref to a full SHA so downstream
|
|
# steps can compare/re-use it.
|
|
SHA=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${DISPATCH_REF}" --jq '.sha')
|
|
if [ -z "$SHA" ]; then
|
|
echo "::error::Could not resolve workflow_dispatch input ref '${DISPATCH_REF}' to a SHA."
|
|
exit 1
|
|
fi
|
|
else
|
|
# Default to the current HEAD of `main`.
|
|
SHA=$(gh api "repos/${GITHUB_REPOSITORY}/commits/main" --jq '.sha')
|
|
fi
|
|
echo "trigger=dispatch" >> "$GITHUB_OUTPUT"
|
|
;;
|
|
*)
|
|
echo "::error::Unexpected event name '$EVENT_NAME'. This workflow only supports 'push' and 'workflow_dispatch'."
|
|
exit 1
|
|
;;
|
|
esac
|
|
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
|
echo "Resolved commit SHA: $SHA"
|
|
|
|
- name: Checkout Repo
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
ref: ${{ steps.resolve.outputs.sha }}
|
|
|
|
- name: Look up associated PR
|
|
id: pr-lookup
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
TRIGGER: ${{ steps.resolve.outputs.trigger }}
|
|
run: |
|
|
# Helper: write a multiline value to $GITHUB_OUTPUT using a random
|
|
# delimiter so user-controlled content (PR title, body, AI reasoning)
|
|
# cannot collide with or inject into the heredoc terminator.
|
|
write_multiline_output() {
|
|
local key="$1"
|
|
local value="$2"
|
|
local delim
|
|
delim="EOF_$(uuidgen | tr -d -)"
|
|
{
|
|
printf '%s<<%s\n' "$key" "$delim"
|
|
printf '%s\n' "$value"
|
|
printf '%s\n' "$delim"
|
|
} >> "$GITHUB_OUTPUT"
|
|
}
|
|
|
|
# Look up PR associated with this commit (if any).
|
|
PR_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/pulls" --jq '.[0] // empty')
|
|
|
|
if [ -z "$PR_JSON" ]; then
|
|
echo "No PR associated with commit ${SHA}"
|
|
echo "pr_number=" >> "$GITHUB_OUTPUT"
|
|
echo "pr_title=" >> "$GITHUB_OUTPUT"
|
|
echo "pr_body=" >> "$GITHUB_OUTPUT"
|
|
# Manual dispatch always forces a backport (explicit user
|
|
# intent), even when no associated PR is found.
|
|
if [ "$TRIGGER" = "dispatch" ]; then
|
|
echo "force_backport=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "force_backport=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
|
|
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
|
|
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
|
|
|
|
echo "Found PR #$PR_NUMBER"
|
|
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
|
write_multiline_output "pr_title" "$PR_TITLE"
|
|
write_multiline_output "pr_body" "$PR_BODY"
|
|
# Manual dispatch always forces a backport (explicit user intent).
|
|
if [ "$TRIGGER" = "dispatch" ]; then
|
|
echo "force_backport=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "force_backport=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Check if backport PR already exists
|
|
id: existing-pr
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }}
|
|
run: |
|
|
# Branch name is keyed off PR number when available, else commit SHA.
|
|
if [ -n "$PR_NUMBER" ]; then
|
|
BRANCH="backport/pr-${PR_NUMBER}-to-stable"
|
|
else
|
|
BRANCH="backport/commit-${SHA:0:12}-to-stable"
|
|
fi
|
|
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
|
|
|
|
EXISTING=$(gh pr list --state open --base stable --head "$BRANCH" --json url --jq '.[0].url' || true)
|
|
if [ -n "$EXISTING" ]; then
|
|
echo "Backport PR already exists: $EXISTING"
|
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
echo "url=$EXISTING" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
- name: Skip if backport PR already open
|
|
if: steps.existing-pr.outputs.exists == 'true'
|
|
run: echo "Backport PR already exists for this commit; skipping."
|
|
|
|
- name: Setup pnpm
|
|
if: steps.existing-pr.outputs.exists != 'true'
|
|
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
|
|
with:
|
|
install: false
|
|
runtime: node@22
|
|
|
|
- name: Install opencode
|
|
if: steps.existing-pr.outputs.exists != 'true'
|
|
# opencode-ai's postinstall downloads its platform-specific binary.
|
|
run: pnpm add --global --allow-build=opencode-ai opencode-ai@1.18.4
|
|
|
|
- name: Configure opencode
|
|
if: steps.existing-pr.outputs.exists != 'true'
|
|
env:
|
|
AI_GATEWAY_TOKEN: ${{ secrets.AI_GATEWAY_API_KEY }}
|
|
run: |
|
|
mkdir -p ~/.local/share/opencode
|
|
jq -n --arg key "$AI_GATEWAY_TOKEN" '{vercel:{type:"api",key:$key}}' > ~/.local/share/opencode/auth.json
|
|
|
|
- name: Decide whether to backport
|
|
id: decide
|
|
if: steps.existing-pr.outputs.exists != 'true'
|
|
env:
|
|
# Allow opencode tools to run without prompting. We pass the full
|
|
# object form (see https://opencode.ai/docs/permissions/) — the
|
|
# bare string "allow" shortcut was observed not to override
|
|
# `external_directory` (which defaults to "ask" and auto-rejects
|
|
# in non-interactive `opencode run`), so we list it explicitly.
|
|
OPENCODE_PERMISSION: '{"*":"allow","external_directory":"allow"}'
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }}
|
|
PR_TITLE: ${{ steps.pr-lookup.outputs.pr_title }}
|
|
PR_BODY: ${{ steps.pr-lookup.outputs.pr_body }}
|
|
FORCE_BACKPORT: ${{ steps.pr-lookup.outputs.force_backport }}
|
|
run: |
|
|
# Fail the entire job on any unhandled error or unset variable so
|
|
# AI/infra failures (auth errors, gateway down, opencode crashes)
|
|
# surface as a red workflow run instead of being silently masked
|
|
# as a "no backport" decision.
|
|
set -euo pipefail
|
|
|
|
# Explicit override: manual workflow_dispatch run. Skip AI analysis.
|
|
if [ "$FORCE_BACKPORT" = "true" ]; then
|
|
echo "Manual dispatch: forcing backport (skipping AI analysis)."
|
|
echo "decision=yes" >> "$GITHUB_OUTPUT"
|
|
echo "reasoning=This workflow was triggered manually via \`workflow_dispatch\`, forcing this commit to be backported." >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
COMMIT_MSG=$(git log -1 --format='%B' "$SHA")
|
|
COMMIT_SUBJECT=$(git log -1 --format='%s' "$SHA")
|
|
CHANGED_FILES=$(git show --name-only --format='' "$SHA")
|
|
|
|
# Capture the diff with a generous but bounded size cap so we don't
|
|
# blow up the prompt for huge refactors. We write it inside the
|
|
# repo working directory (same dir opencode runs in) so opencode's
|
|
# `read` tool doesn't need `external_directory` permission.
|
|
git show --format='' "$SHA" > .backport-commit-diff.patch
|
|
DIFF_SIZE=$(wc -c < .backport-commit-diff.patch)
|
|
MAX_DIFF_BYTES=200000
|
|
if [ "$DIFF_SIZE" -gt "$MAX_DIFF_BYTES" ]; then
|
|
head -c "$MAX_DIFF_BYTES" .backport-commit-diff.patch > .backport-commit-diff-truncated.patch
|
|
echo "" >> .backport-commit-diff-truncated.patch
|
|
echo "[... diff truncated at ${MAX_DIFF_BYTES} bytes; full size was ${DIFF_SIZE} bytes ...]" >> .backport-commit-diff-truncated.patch
|
|
mv .backport-commit-diff-truncated.patch .backport-commit-diff.patch
|
|
fi
|
|
|
|
# Decision file lives inside the working directory for the same
|
|
# reason: opencode's `write` tool treats anything outside its cwd as
|
|
# an `external_directory` access, which auto-rejects in headless
|
|
# mode unless explicitly allowed.
|
|
DECISION_FILE=".backport-decision.json"
|
|
|
|
{
|
|
echo "You are deciding whether a commit on the \`main\` branch should be backported to the \`stable\` branch."
|
|
echo ""
|
|
echo "\`main\` is the current GA line and receives all new work. \`stable\` is a **maintenance branch**: it exists so teams that cannot upgrade yet keep getting fixes. It receives stability fixes only. Feature work is never backported, no matter how small or self-contained."
|
|
echo ""
|
|
echo "## Decision criteria"
|
|
echo ""
|
|
echo "Recommend a backport ONLY when the commit's purpose is to keep the maintenance line working correctly:"
|
|
echo "- Bug fixes to functionality that already exists on \`stable\`"
|
|
echo "- Correctness, data-loss, crash, hang, deadlock, and resource-leak fixes"
|
|
echo "- Security fixes, including dependency bumps that address a known vulnerability"
|
|
echo "- Fixes for a regression introduced by an earlier backport"
|
|
echo "- Test-only changes covering behavior that also exists on \`stable\`, and fixes for flaky tests"
|
|
echo "- Build, CI, or release-plumbing fixes needed to keep \`stable\` buildable, testable, and releasable"
|
|
echo "- Documentation corrections for content already on \`stable\` — fixing something that is wrong or misleading, not documenting new capabilities"
|
|
echo ""
|
|
echo "Recommend AGAINST a backport for everything else. In particular:"
|
|
echo "- **Any new feature or feature enhancement**, including ones that are small, self-contained, additive, and independent of \`main\`-only code. A change that adds a capability, option, config flag, API surface, CLI command, or export is feature work — even when it cherry-picks cleanly and carries a \`minor\` changeset."
|
|
echo "- Performance optimizations and refactors that are not fixing a user-visible defect. A performance change only qualifies if the current behavior is a genuine stability problem on \`stable\` (e.g. an unbounded allocation that OOMs), not merely slower than it could be."
|
|
echo "- Behavior changes to existing APIs that are not fixing a defect (changed defaults, relaxed validation, new response fields)"
|
|
echo "- Changes that explicitly build on or require new APIs/behavior introduced only on \`main\`"
|
|
echo "- Breaking changes intended for the next major release"
|
|
echo "- Routine dependency bumps that are not motivated by a vulnerability or a bug that affects \`stable\`"
|
|
echo "- Changes confined to files/directories that are not maintained on \`stable\`. **This list is exhaustive** — only the following paths qualify; assume every other path IS actively maintained on \`stable\` unless you verify otherwise (see below):"
|
|
echo " - the \`docs/\` app outside of \`docs/content/\` (i.e. \`docs/content/\` IS maintained on \`stable\`, the rest of the docs app is not)"
|
|
echo " - anything under \`skills/\`"
|
|
echo "- Changesets-only commits, version bump commits (\"Version Packages\"), and similar release plumbing"
|
|
echo "- Commits that revert something that only exists on \`main\`"
|
|
echo ""
|
|
echo "A commit that mixes a fix with feature work counts as feature work: recommend against, and say in your reasoning which part is the fix so a human can decide whether to split it out and force a backport."
|
|
echo ""
|
|
echo "## Your task"
|
|
echo ""
|
|
echo "Analyze the commit below and decide whether to recommend a backport."
|
|
echo ""
|
|
echo "When in doubt, recommend AGAINST. Shipping feature work to a maintenance line is worse than missing a fix: a missed fix can be forced through later via \`workflow_dispatch\`, whereas unwanted change on \`stable\` costs its users the stability they stayed behind for. Do not reason from how cleanly the commit would apply — a clean cherry-pick is not evidence that a change belongs on \`stable\`."
|
|
echo ""
|
|
echo "**Important:** before recommending AGAINST a backport on the grounds that a file or directory is not maintained on \`stable\`, you MUST verify your claim. The exhaustive list above is the only set of paths you may assume are absent or stubbed on \`stable\` without checking. For any other path, use the \`bash\` tool to run \`git ls-tree origin/stable -- <path>\` (or \`git show origin/stable:<path>\`) and confirm the path is actually absent or a stub on \`stable\` before citing it as a reason against backport. Do not guess based on file/directory names — for example, names containing words like \"docs\", \"preview\", \"tarball\", or \"workflow\" do not imply a path is main-only."
|
|
echo ""
|
|
echo "## Output format"
|
|
echo ""
|
|
echo "Write your decision to ${DECISION_FILE} (relative to the current working directory) as a single JSON object with EXACTLY these two keys (and nothing else):"
|
|
echo "- \`decision\`: either the string \"yes\" or \"no\""
|
|
echo "- \`reasoning\`: a 1-3 sentence explanation in Markdown (no headings, no code fences around the whole thing)"
|
|
echo ""
|
|
echo "Use the \`write\` tool to create the file. Do not print the JSON to stdout. Do not include any additional keys."
|
|
echo ""
|
|
echo "## Commit context"
|
|
echo ""
|
|
echo "Commit SHA: ${SHA}"
|
|
if [ -n "$PR_NUMBER" ]; then
|
|
echo "Associated PR: #${PR_NUMBER}"
|
|
echo "PR title: ${PR_TITLE}"
|
|
if [ -n "$PR_BODY" ]; then
|
|
echo ""
|
|
echo "PR body:"
|
|
echo "\`\`\`"
|
|
echo "$PR_BODY"
|
|
echo "\`\`\`"
|
|
fi
|
|
fi
|
|
echo ""
|
|
echo "Commit subject: ${COMMIT_SUBJECT}"
|
|
echo ""
|
|
echo "Full commit message:"
|
|
echo "\`\`\`"
|
|
echo "$COMMIT_MSG"
|
|
echo "\`\`\`"
|
|
echo ""
|
|
echo "Changed files:"
|
|
echo "\`\`\`"
|
|
echo "$CHANGED_FILES"
|
|
echo "\`\`\`"
|
|
echo ""
|
|
echo "Diff:"
|
|
echo "\`\`\`diff"
|
|
cat .backport-commit-diff.patch
|
|
echo "\`\`\`"
|
|
} > .backport-decision-prompt.txt
|
|
|
|
rm -f "$DECISION_FILE"
|
|
|
|
# Run the AI. `opencode run` exits 0 even when the AI Gateway auth
|
|
# fails or a tool call is rejected, so we can't rely on its exit
|
|
# code alone — but with `set -e` above, any non-zero exit will also
|
|
# fail the job. The real signal is whether the decision file was
|
|
# produced.
|
|
#
|
|
# `--print-logs` puts opencode's server log on stderr. Without it an
|
|
# opencode-side crash prints only a masked `UnknownError` naming a
|
|
# log line we never see (see `Validate AI model` above).
|
|
opencode run --print-logs --model "vercel/${AI_MODEL}" < .backport-decision-prompt.txt
|
|
|
|
if [ ! -f "$DECISION_FILE" ]; then
|
|
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, re-run this workflow via \`workflow_dispatch\` with the relevant commit SHA."
|
|
exit 1
|
|
fi
|
|
|
|
if ! jq empty "$DECISION_FILE" 2>/dev/null; then
|
|
echo "::error::AI decision file at ${DECISION_FILE} is not valid JSON:"
|
|
cat "$DECISION_FILE"
|
|
exit 1
|
|
fi
|
|
|
|
DECISION=$(jq -r '.decision' "$DECISION_FILE")
|
|
REASONING=$(jq -r '.reasoning' "$DECISION_FILE")
|
|
|
|
if [ "$DECISION" != "yes" ] && [ "$DECISION" != "no" ]; then
|
|
echo "::error::AI returned invalid decision '$DECISION' (expected 'yes' or 'no'). Reasoning: $REASONING"
|
|
exit 1
|
|
fi
|
|
|
|
echo "AI decision: $DECISION"
|
|
echo "AI reasoning: $REASONING"
|
|
echo "decision=$DECISION" >> "$GITHUB_OUTPUT"
|
|
|
|
# Use a randomized heredoc delimiter so AI-generated reasoning
|
|
# cannot collide with or inject into the terminator.
|
|
REASONING_DELIM="EOF_$(uuidgen | tr -d -)"
|
|
{
|
|
printf 'reasoning<<%s\n' "$REASONING_DELIM"
|
|
printf '%s\n' "$REASONING"
|
|
printf '%s\n' "$REASONING_DELIM"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Comment on PR (no backport)
|
|
if: |
|
|
steps.existing-pr.outputs.exists != 'true' &&
|
|
steps.decide.outputs.decision == 'no' &&
|
|
steps.pr-lookup.outputs.pr_number != ''
|
|
uses: actions/github-script@v7
|
|
env:
|
|
REASONING: ${{ steps.decide.outputs.reasoning }}
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }}
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
const reasoning = process.env.REASONING;
|
|
const fullSha = process.env.SHA;
|
|
const shortSha = fullSha.slice(0, 7);
|
|
const prNumber = Number(process.env.PR_NUMBER);
|
|
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
const workflowUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/backport.yml`;
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body: [
|
|
`**No backport to \`stable\`** for ${shortSha} ([AI decision](${runUrl})).`,
|
|
'',
|
|
reasoning,
|
|
'',
|
|
// GitHub Actions doesn't support prefilling workflow_dispatch
|
|
// inputs via URL query params (community/community#51159), so
|
|
// we paste the full SHA here for easy copy-paste into the
|
|
// "Commit SHA" input on the workflow run page.
|
|
`To override, re-run the [Backport to stable](${workflowUrl}) workflow manually via \`workflow_dispatch\` and paste this commit SHA into the \`ref\` input:`,
|
|
'',
|
|
'```',
|
|
fullSha,
|
|
'```'
|
|
].join('\n')
|
|
});
|
|
|
|
- name: Cherry-pick to stable
|
|
id: cherry-pick
|
|
if: |
|
|
steps.existing-pr.outputs.exists != 'true' &&
|
|
steps.decide.outputs.decision == 'yes'
|
|
env:
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
run: |
|
|
git config user.name "$(git log -1 --format='%an' "$SHA")"
|
|
git config user.email "$(git log -1 --format='%ae' "$SHA")"
|
|
git fetch origin stable
|
|
git checkout stable
|
|
if git cherry-pick "$SHA" --no-edit --signoff; then
|
|
echo "status=clean" >> "$GITHUB_OUTPUT"
|
|
echo "cherry_pick_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
else
|
|
# Auto-resolve conflicts in directories that are not maintained
|
|
# on the stable branch by keeping the stable side (discarding the
|
|
# incoming change from main):
|
|
# - docs/ (except docs/content/): the docs app is a minimal
|
|
# placeholder on stable; only docs/content/ is actively
|
|
# maintained (markdown files bundled into npm packages via
|
|
# prepack scripts).
|
|
# - skills/: skill files are unrelated to npm packaging and
|
|
# are not maintained on stable.
|
|
git diff --name-only --diff-filter=U \
|
|
| { grep -E '^(docs/|skills/)' || true; } \
|
|
| { grep -v '^docs/content/' || true; } \
|
|
| while IFS= read -r file; do
|
|
echo "Auto-resolving conflict (keeping stable version): $file"
|
|
# Check for a stage-2 ("ours") entry in the index, which means
|
|
# the file exists on the stable side of the conflict. Otherwise
|
|
# the file was newly added on main and we drop it.
|
|
if git show ":2:$file" >/dev/null 2>&1; then
|
|
git checkout --ours -- "$file"
|
|
git add -- "$file"
|
|
else
|
|
git rm -f -- "$file"
|
|
fi
|
|
done
|
|
|
|
# Lockfile conflicts can be resolved by re-running pnpm install,
|
|
# but only when no other conflicts remain (to avoid pnpm choking
|
|
# on conflict markers in other files).
|
|
REMAINING_BEFORE_LOCKFILE=$(git diff --name-only --diff-filter=U | grep -v '^pnpm-lock.yaml$' || true)
|
|
if [ -z "$REMAINING_BEFORE_LOCKFILE" ] && git diff --name-only --diff-filter=U | grep -q '^pnpm-lock.yaml$'; then
|
|
echo "Auto-resolving pnpm-lock.yaml conflict"
|
|
pnpm install --no-frozen-lockfile
|
|
git add pnpm-lock.yaml
|
|
fi
|
|
|
|
# Check if all conflicts are now resolved
|
|
REMAINING=$(git diff --name-only --diff-filter=U || true)
|
|
if [ -z "$REMAINING" ]; then
|
|
GIT_EDITOR=true git cherry-pick --continue
|
|
echo "status=clean" >> "$GITHUB_OUTPUT"
|
|
echo "cherry_pick_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "status=conflict" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
fi
|
|
|
|
- name: Resolve conflicts with opencode
|
|
if: steps.cherry-pick.outputs.status == 'conflict'
|
|
id: ai-resolve
|
|
# We don't use `continue-on-error` here because we want to
|
|
# distinguish two outcomes:
|
|
# 1. opencode ran cleanly but couldn't fully resolve conflicts
|
|
# (the legitimate "needs human help" path) — we set
|
|
# `resolved=false` and the next step posts a manual-resolution
|
|
# comment on the source PR.
|
|
# 2. opencode itself errored (auth failure, permission rejection,
|
|
# crash, etc.) — that's an infra problem; we exit non-zero so
|
|
# the workflow fails loudly and we notice instead of silently
|
|
# claiming the conflicts couldn't be resolved.
|
|
env:
|
|
# Allow opencode tools to run without prompting. See the matching
|
|
# comment on the `Decide whether to backport` step for details.
|
|
OPENCODE_PERMISSION: '{"*":"allow","external_directory":"allow"}'
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
PR_TITLE: ${{ steps.pr-lookup.outputs.pr_title }}
|
|
run: |
|
|
# Fail loudly on any unhandled error; we explicitly handle the
|
|
# "AI ran cleanly but couldn't resolve all conflicts" case below
|
|
# by setting `resolved=false` and exiting 0.
|
|
set -euo pipefail
|
|
|
|
COMMIT_MSG=$(git log -1 --format='%B' "$SHA")
|
|
|
|
# The AI writes its outcome here. The presence of this file (with
|
|
# a recognized status) is how we tell "AI ran cleanly" apart from
|
|
# "opencode itself crashed / hit an auth or permission error",
|
|
# which we treat as an infra failure.
|
|
OUTCOME_FILE=".backport-conflict-outcome.json"
|
|
rm -f "$OUTCOME_FILE"
|
|
|
|
# Prompt file lives inside the workspace so opencode never needs
|
|
# `external_directory` access just to read it.
|
|
cat > .backport-conflict-prompt.txt <<PROMPT
|
|
The working tree has git merge conflicts from a failed cherry-pick.
|
|
A commit is being cherry-picked from the main branch to the stable branch.
|
|
|
|
PR title: $PR_TITLE
|
|
Commit message: $COMMIT_MSG
|
|
|
|
IMPORTANT: Some directories are not fully maintained on the stable
|
|
branch and should be auto-resolved by keeping the stable side:
|
|
|
|
- docs/ (except docs/content/): the docs app is a minimal
|
|
placeholder on stable; only docs/content/ is actively maintained
|
|
(markdown files bundled into npm packages).
|
|
- skills/: skill files are unrelated to npm packaging and are not
|
|
maintained on stable.
|
|
|
|
If any remaining conflicts involve files under docs/ that are NOT
|
|
in docs/content/, or any files under skills/, resolve them by
|
|
keeping the stable branch version (the <<<<<<< HEAD side) and
|
|
discarding the incoming change from main. If the file does not
|
|
exist on the stable side (HEAD side is empty), remove it with
|
|
"git rm". Conflicts in docs/content/ should be resolved normally.
|
|
|
|
Resolve all merge conflicts in the working tree. The content between
|
|
<<<<<<< HEAD and ======= is the current stable branch. The content between
|
|
======= and >>>>>>> is the incoming change from main.
|
|
|
|
When working with intermediate scratch files (diffs, notes, etc.),
|
|
keep them inside the current working directory rather than under
|
|
/tmp — the working directory is already part of your workspace and
|
|
doesn't require the \`external_directory\` permission.
|
|
|
|
After resolving each file, run \`git add\` on it to mark it as
|
|
resolved. Do NOT run \`git cherry-pick --continue\` or \`git commit\`.
|
|
|
|
When you are done, write a JSON file at \`$OUTCOME_FILE\` (relative
|
|
to the current working directory) with EXACTLY one of these shapes:
|
|
|
|
{"status":"resolved"} // every conflict was resolved cleanly
|
|
{"status":"unresolved","reason":"<short explanation>"} // some
|
|
// conflicts couldn't be resolved (or
|
|
// were too risky to resolve safely)
|
|
|
|
Use the \`write\` tool to create the file. The file is the only
|
|
signal we use for whether the resolution succeeded; do not skip it.
|
|
PROMPT
|
|
|
|
# `opencode run` may exit 0 even on infra failures (auth errors,
|
|
# rejected tool calls). The outcome-file presence + `set -e` on
|
|
# this step's exit are what we rely on to distinguish infra vs.
|
|
# AI-said-no.
|
|
#
|
|
# `--print-logs`: same reason as the decision step above.
|
|
opencode run --print-logs --model "vercel/${AI_MODEL}" < .backport-conflict-prompt.txt
|
|
|
|
if [ ! -f "$OUTCOME_FILE" ]; then
|
|
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."
|
|
exit 1
|
|
fi
|
|
|
|
if ! jq empty "$OUTCOME_FILE" 2>/dev/null; then
|
|
echo "::error::AI outcome file at ${OUTCOME_FILE} is not valid JSON:"
|
|
cat "$OUTCOME_FILE"
|
|
exit 1
|
|
fi
|
|
|
|
STATUS=$(jq -r '.status' "$OUTCOME_FILE")
|
|
case "$STATUS" in
|
|
resolved)
|
|
# Sanity-check the AI's claim before committing the
|
|
# cherry-pick. Two failure modes to defend against:
|
|
#
|
|
# 1. Files left as unmerged index entries (the AI didn't
|
|
# `git add` them, or didn't resolve them at all).
|
|
UNMERGED=$(git diff --name-only --diff-filter=U || true)
|
|
if [ -n "$UNMERGED" ]; then
|
|
echo "::warning::AI claimed conflicts were resolved but the following files still have unmerged index entries; treating as unresolved:"
|
|
echo "$UNMERGED"
|
|
echo "resolved=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
# 2. Files that were `git add`-ed but still contain conflict
|
|
# markers in their content. `git diff --check --cached`
|
|
# flags lines like `file.txt:1: leftover conflict marker`
|
|
# when any staged file contains the standard markers; we
|
|
# grep specifically for that phrase so an unrelated
|
|
# whitespace warning doesn't trip the check.
|
|
CHECK_OUTPUT=$(git diff --check --cached 2>&1 || true)
|
|
if echo "$CHECK_OUTPUT" | grep -q "leftover conflict marker"; then
|
|
echo "::warning::AI claimed conflicts were resolved but staged files still contain conflict markers (<<<<<<<, =======, >>>>>>>); treating as unresolved:"
|
|
echo "$CHECK_OUTPUT" | grep "leftover conflict marker"
|
|
echo "resolved=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
GIT_EDITOR=true git cherry-pick --continue
|
|
echo "resolved=true" >> "$GITHUB_OUTPUT"
|
|
echo "cherry_pick_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
;;
|
|
unresolved)
|
|
REASON=$(jq -r '.reason // "(no reason given)"' "$OUTCOME_FILE")
|
|
echo "AI could not fully resolve conflicts: $REASON"
|
|
echo "resolved=false" >> "$GITHUB_OUTPUT"
|
|
;;
|
|
*)
|
|
echo "::error::AI returned unexpected status '$STATUS' (expected 'resolved' or 'unresolved'). Outcome file:"
|
|
cat "$OUTCOME_FILE"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
- name: Push backport branch via GitHub API
|
|
# Branch protection on this repo requires verified signatures on
|
|
# every ref (an enterprise-level ruleset matching `~ALL`). A normal
|
|
# `git push` of a locally-cherry-picked commit is rejected because
|
|
# CI doesn't have a signing key. To work around this, we replay the
|
|
# cherry-pick through the GitHub GraphQL `createCommitOnBranch`
|
|
# mutation, which signs commits automatically with GitHub's
|
|
# internal key (the same way commits made via the web UI are
|
|
# signed). The resulting commit is attributed to the token owner
|
|
# (`github-actions[bot]`), not the original author.
|
|
if: |
|
|
steps.cherry-pick.outputs.status == 'clean' ||
|
|
(steps.cherry-pick.outputs.status == 'conflict' && steps.ai-resolve.outputs.resolved == 'true')
|
|
id: push-branch
|
|
uses: actions/github-script@v7
|
|
env:
|
|
BRANCH: ${{ steps.existing-pr.outputs.branch }}
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const branch = process.env.BRANCH;
|
|
const cherrySha = process.env.SHA;
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
|
|
const git = (...args) =>
|
|
execFileSync('git', args, { encoding: 'utf8' }).trim();
|
|
|
|
// The cherry-pick was just performed on top of `stable` in the
|
|
// working tree, so HEAD is the local cherry-pick commit and its
|
|
// first parent is the `stable` HEAD we want to base on.
|
|
const localHead = git('rev-parse', 'HEAD');
|
|
const parentSha = git('rev-parse', 'HEAD~1');
|
|
const commitMessage = git('log', '-1', '--format=%B', localHead);
|
|
|
|
core.info(
|
|
`Replaying ${cherrySha} (local ${localHead}) on top of ${parentSha} via GraphQL createCommitOnBranch`,
|
|
);
|
|
|
|
// Diff against the parent to find every path that changed.
|
|
// Status codes: A=added, M=modified, D=deleted, R=renamed (Rxx),
|
|
// C=copied (Cxx), T=type-changed.
|
|
const diffOutput = git(
|
|
'diff',
|
|
'--name-status',
|
|
'-z',
|
|
parentSha,
|
|
localHead,
|
|
);
|
|
// -z output: <status>\0<path>\0 (or for R/C: <status>\0<old>\0<new>\0)
|
|
const diffParts = diffOutput
|
|
.split('\0')
|
|
.filter((s) => s.length > 0);
|
|
const additions = []; // [{ path, contents (base64) }]
|
|
const deletions = []; // [{ path }]
|
|
|
|
const addPath = (path) => {
|
|
// Look up the mode + blob in the local commit so we can
|
|
// detect unsupported file types (executable bit, symlinks,
|
|
// submodules) — GraphQL FileChanges doesn't support those.
|
|
const lsTree = git('ls-tree', localHead, '--', path);
|
|
const match = lsTree.match(
|
|
/^(\d{6}) (\w+) ([0-9a-f]{40})\t/,
|
|
);
|
|
if (!match) {
|
|
throw new Error(
|
|
`Unexpected ls-tree output for ${path}: ${lsTree}`,
|
|
);
|
|
}
|
|
const [, mode, type, blobSha] = match;
|
|
if (type !== 'blob') {
|
|
throw new Error(
|
|
`Unsupported tree entry for ${path}: type=${type} mode=${mode}. ` +
|
|
`GraphQL createCommitOnBranch only supports regular files.`,
|
|
);
|
|
}
|
|
if (mode !== '100644') {
|
|
core.warning(
|
|
`File ${path} has mode ${mode} in the cherry-pick, but ` +
|
|
`GraphQL createCommitOnBranch only supports mode 100644. ` +
|
|
`The backport will lose the executable bit / symlink — ` +
|
|
`please review the resulting PR carefully.`,
|
|
);
|
|
}
|
|
// Read raw bytes (binary-safe) from the git object store.
|
|
const blobBytes = execFileSync(
|
|
'git',
|
|
['cat-file', 'blob', blobSha],
|
|
{ maxBuffer: 256 * 1024 * 1024 }, // 256MB cap
|
|
);
|
|
additions.push({
|
|
path,
|
|
contents: blobBytes.toString('base64'),
|
|
});
|
|
};
|
|
|
|
for (let i = 0; i < diffParts.length; ) {
|
|
const status = diffParts[i++];
|
|
const code = status[0];
|
|
if (code === 'R' || code === 'C') {
|
|
const oldPath = diffParts[i++];
|
|
const newPath = diffParts[i++];
|
|
addPath(newPath);
|
|
deletions.push({ path: oldPath });
|
|
continue;
|
|
}
|
|
const path = diffParts[i++];
|
|
if (code === 'D') {
|
|
deletions.push({ path });
|
|
} else {
|
|
addPath(path);
|
|
}
|
|
}
|
|
|
|
if (additions.length === 0 && deletions.length === 0) {
|
|
core.warning(
|
|
'Cherry-pick produced no file changes; skipping backport push.',
|
|
);
|
|
core.setOutput('pushed', 'false');
|
|
return;
|
|
}
|
|
|
|
// Ensure the branch exists on the remote and points at the
|
|
// current `stable` HEAD before invoking `createCommitOnBranch`.
|
|
// The mutation requires the named branch to already exist and
|
|
// its `expectedHeadOid` to match — it doesn't create new
|
|
// branches itself. If the branch is stale or absent, force it
|
|
// to the parent commit.
|
|
const refName = `heads/${branch}`;
|
|
let needsCreate = false;
|
|
try {
|
|
const { data: existingRef } = await github.rest.git.getRef({
|
|
owner,
|
|
repo,
|
|
ref: refName,
|
|
});
|
|
if (existingRef.object.sha !== parentSha) {
|
|
core.info(
|
|
`Resetting existing branch ${branch} from ${existingRef.object.sha} -> ${parentSha} (stable HEAD)`,
|
|
);
|
|
await github.rest.git.updateRef({
|
|
owner,
|
|
repo,
|
|
ref: refName,
|
|
sha: parentSha,
|
|
force: true,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (err.status === 404) {
|
|
needsCreate = true;
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
if (needsCreate) {
|
|
core.info(`Creating branch ${branch} at ${parentSha}`);
|
|
await github.rest.git.createRef({
|
|
owner,
|
|
repo,
|
|
ref: `refs/${refName}`,
|
|
sha: parentSha,
|
|
});
|
|
}
|
|
|
|
// Split the commit message into headline (first line) + body
|
|
// (everything after the first blank line), per the
|
|
// CommitMessage GraphQL input type.
|
|
const firstNewline = commitMessage.indexOf('\n');
|
|
let headline;
|
|
let body;
|
|
if (firstNewline === -1) {
|
|
headline = commitMessage;
|
|
body = '';
|
|
} else {
|
|
headline = commitMessage.slice(0, firstNewline);
|
|
// Skip exactly one blank line if present, so we don't
|
|
// double-blank the body when the message has the standard
|
|
// "subject\n\nbody" shape.
|
|
const rest = commitMessage.slice(firstNewline + 1);
|
|
body = rest.startsWith('\n') ? rest.slice(1) : rest;
|
|
}
|
|
|
|
// Run the mutation. GitHub signs the commit automatically.
|
|
const mutation = `
|
|
mutation($input: CreateCommitOnBranchInput!) {
|
|
createCommitOnBranch(input: $input) {
|
|
commit { oid url }
|
|
}
|
|
}
|
|
`;
|
|
const result = await github.graphql(mutation, {
|
|
input: {
|
|
branch: {
|
|
repositoryNameWithOwner: `${owner}/${repo}`,
|
|
branchName: branch,
|
|
},
|
|
expectedHeadOid: parentSha,
|
|
message: { headline, body },
|
|
fileChanges: { additions, deletions },
|
|
},
|
|
});
|
|
const newOid = result.createCommitOnBranch.commit.oid;
|
|
core.info(
|
|
`Created signed commit ${newOid} on branch ${branch} (${result.createCommitOnBranch.commit.url})`,
|
|
);
|
|
|
|
core.setOutput('pushed', 'true');
|
|
core.setOutput('commit_sha', newOid);
|
|
|
|
- name: Create backport PR
|
|
if: steps.push-branch.outputs.pushed == 'true'
|
|
id: backport-pr
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }}
|
|
PR_TITLE: ${{ steps.pr-lookup.outputs.pr_title }}
|
|
BRANCH: ${{ steps.existing-pr.outputs.branch }}
|
|
CHERRY_PICK_STATUS: ${{ steps.cherry-pick.outputs.status }}
|
|
AI_REASONING: ${{ steps.decide.outputs.reasoning }}
|
|
TRIGGER: ${{ steps.resolve.outputs.trigger }}
|
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
run: |
|
|
# Build the PR title.
|
|
if [ -n "$PR_NUMBER" ]; then
|
|
TITLE="Backport #${PR_NUMBER}: ${PR_TITLE}"
|
|
else
|
|
COMMIT_SUBJECT=$(git log -1 --format='%s' "$SHA")
|
|
TITLE="Backport ${SHA:0:7}: ${COMMIT_SUBJECT}"
|
|
fi
|
|
|
|
# Build the PR body.
|
|
{
|
|
if [ -n "$PR_NUMBER" ]; then
|
|
echo "Automated backport of #${PR_NUMBER} to \`stable\` ([backport job run](${RUN_URL}))."
|
|
else
|
|
echo "Automated backport of ${SHA} to \`stable\` ([backport job run](${RUN_URL}))."
|
|
fi
|
|
echo ""
|
|
|
|
case "$TRIGGER" in
|
|
dispatch)
|
|
echo "Triggered manually via \`workflow_dispatch\`."
|
|
;;
|
|
*)
|
|
echo "**AI recommendation:** $AI_REASONING"
|
|
;;
|
|
esac
|
|
|
|
if [ "$CHERRY_PICK_STATUS" = "conflict" ]; then
|
|
echo ""
|
|
echo "Merge conflicts were resolved by AI ([opencode](https://opencode.ai) with \`${AI_MODEL}\`). **Please review the conflict resolution carefully before merging.**"
|
|
fi
|
|
} > /tmp/pr-body.md
|
|
|
|
# If a PR already exists for this branch (e.g. an earlier failed
|
|
# attempt left one behind), reuse it instead of erroring.
|
|
EXISTING_PR=$(gh pr list --state open --base stable --head "$BRANCH" --json url --jq '.[0].url' || true)
|
|
if [ -n "$EXISTING_PR" ]; then
|
|
PR_URL="$EXISTING_PR"
|
|
echo "Reusing existing backport PR: $PR_URL"
|
|
else
|
|
PR_URL=$(gh pr create \
|
|
--base stable \
|
|
--head "$BRANCH" \
|
|
--title "$TITLE" \
|
|
--body-file /tmp/pr-body.md)
|
|
echo "Created backport PR: $PR_URL"
|
|
fi
|
|
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Comment on source PR (backport created)
|
|
if: |
|
|
steps.backport-pr.outputs.pr_url != '' &&
|
|
steps.pr-lookup.outputs.pr_number != ''
|
|
uses: actions/github-script@v7
|
|
env:
|
|
CHERRY_PICK_STATUS: ${{ steps.cherry-pick.outputs.status }}
|
|
PR_URL: ${{ steps.backport-pr.outputs.pr_url }}
|
|
PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }}
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
const cherryPickStatus = process.env.CHERRY_PICK_STATUS;
|
|
const prUrl = process.env.PR_URL;
|
|
const prNumber = Number(process.env.PR_NUMBER);
|
|
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
const conflictNote = cherryPickStatus === 'conflict'
|
|
? ' Merge conflicts were resolved by AI — please review carefully.'
|
|
: '';
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body: `Backport PR opened against \`stable\`: ${prUrl}.${conflictNote} ([backport job run](${runUrl}))`
|
|
});
|
|
|
|
- name: Comment on conflict failure
|
|
# Only post the manual-resolution comment when the AI ran cleanly
|
|
# but couldn't resolve all conflicts (`resolved=false`). Infra
|
|
# failures (auth errors, opencode crashes, etc.) cause the
|
|
# `Resolve conflicts with opencode` step to exit non-zero, which
|
|
# fails the job — we don't want to also post a misleading
|
|
# "couldn't resolve" comment in that case.
|
|
if: |
|
|
steps.cherry-pick.outputs.status == 'conflict' &&
|
|
steps.ai-resolve.outputs.resolved == 'false' &&
|
|
steps.pr-lookup.outputs.pr_number != ''
|
|
uses: actions/github-script@v7
|
|
env:
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }}
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
const sha = process.env.SHA;
|
|
const prNumber = Number(process.env.PR_NUMBER);
|
|
const shortSha = sha.slice(0, 12);
|
|
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body: [
|
|
`**Backport to \`stable\` failed** — the cherry-pick had conflicts that could not be resolved automatically ([backport job run](${runUrl})).`,
|
|
'',
|
|
'To resolve manually, push a backport branch and open a PR against `stable` (the workflow never pushes directly to `stable`). Note: this repository requires verified signatures on every branch, so your local commits must be signed (`git config commit.gpgsign true` with a configured GPG/SSH signing key, or `git cherry-pick -S`).',
|
|
'```bash',
|
|
'git fetch origin stable',
|
|
`git checkout -b backport/pr-${prNumber}-to-stable origin/stable`,
|
|
`git cherry-pick -S ${sha} # -S signs the commit`,
|
|
'# Fix conflicts, then:',
|
|
'git add -A',
|
|
'git cherry-pick --continue',
|
|
`git push -u origin backport/pr-${prNumber}-to-stable`,
|
|
`gh pr create --base stable --head backport/pr-${prNumber}-to-stable \\`,
|
|
` --title "Backport #${prNumber}: <original PR title>" \\`,
|
|
` --body "Manual backport of #${prNumber} (cherry-pick ${shortSha}) to \\\`stable\\\`."`,
|
|
'```'
|
|
].join('\n')
|
|
});
|
|
|
|
- name: Comment on backport failure
|
|
# Catch-all for task/infra failures that fail the job outright — e.g.
|
|
# the configured AI model not being found, AI Gateway errors, opencode
|
|
# crashes, or any other step erroring out. Without this, such failures
|
|
# only surface as a red workflow run with no signal on the source PR.
|
|
#
|
|
# This is deliberately disjoint from "Comment on conflict failure"
|
|
# above: that step handles the case where the AI ran cleanly but
|
|
# couldn't resolve conflicts (`resolved == 'false'`), which exits 0 and
|
|
# does NOT fail the job — so `failure()` is false there and we won't
|
|
# double-comment. We also skip if no source PR is associated (nowhere
|
|
# to comment) or if the failure occurred before the PR lookup ran.
|
|
if: |
|
|
failure() &&
|
|
steps.pr-lookup.outputs.pr_number != ''
|
|
uses: actions/github-script@v7
|
|
env:
|
|
SHA: ${{ steps.resolve.outputs.sha }}
|
|
PR_NUMBER: ${{ steps.pr-lookup.outputs.pr_number }}
|
|
with:
|
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
script: |
|
|
const sha = process.env.SHA || '';
|
|
const prNumber = Number(process.env.PR_NUMBER);
|
|
const shortSha = sha ? sha.slice(0, 12) : '(unknown)';
|
|
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
const workflowUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/backport.yml`;
|
|
const lines = [
|
|
`**Backport to \`stable\` failed** for ${shortSha} due to a workflow error ([backport job run](${runUrl})).`,
|
|
'',
|
|
'This is usually an infrastructure problem (e.g. the configured AI model could not be found, an AI Gateway error, or an opencode crash) rather than a merge conflict. Check the job logs linked above for details.',
|
|
];
|
|
if (sha) {
|
|
lines.push(
|
|
'',
|
|
`Once the underlying issue is fixed, re-run the [Backport to stable](${workflowUrl}) workflow manually via \`workflow_dispatch\` and paste this commit SHA into the \`ref\` input:`,
|
|
'',
|
|
'```',
|
|
sha,
|
|
'```'
|
|
);
|
|
}
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body: lines.join('\n')
|
|
});
|