Files

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')
});