Skip to content

Commit 714d920

Browse files
committed
Redesign backport automation around port-commit --from-pr
Move all backport label parsing, per-base looping, idempotency, and error aggregation into ddev release port-commit --from-pr, backed by unit tests. Slim backport-pr.yml to a should_run gate job plus a gated backport job, and use ddev config override in place of the repo-pointing config set calls.
1 parent 4a915e4 commit 714d920

9 files changed

Lines changed: 677 additions & 169 deletions

File tree

.github/workflows/backport-pr.yml

Lines changed: 42 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@ on:
66
- labeled
77

88
jobs:
9-
backport:
10-
name: Backport PR
9+
check:
10+
name: Check for backport labels
1111
runs-on: ubuntu-latest
12-
permissions:
13-
id-token: write # OIDC token federation with dd-octo-sts
14-
# Writes use the scoped octo-sts token; ambient GITHUB_TOKEN stays read-only (pull_request_target).
15-
contents: read
16-
pull-requests: read
12+
# Cheap event filter: only merged PRs, and only the `closed` event or a freshly-added
13+
# `backport/*` label. `should_run` below is the label-presence semaphore that gates the
14+
# expensive backport job.
1715
if: >
1816
github.event.pull_request.merged
1917
&& (
@@ -23,6 +21,33 @@ jobs:
2321
&& startsWith(github.event.label.name, 'backport/')
2422
)
2523
)
24+
permissions:
25+
contents: read
26+
outputs:
27+
should_run: ${{ steps.gate.outputs.should_run }}
28+
steps:
29+
- name: Detect backport labels
30+
id: gate
31+
env:
32+
PR_LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }}
33+
run: |
34+
set -euo pipefail
35+
if printf '%s' "${PR_LABELS_JSON}" | jq -e 'any(.[]; startswith("backport/"))' >/dev/null; then
36+
echo "should_run=true" >> "${GITHUB_OUTPUT}"
37+
else
38+
echo "should_run=false" >> "${GITHUB_OUTPUT}"
39+
fi
40+
41+
backport:
42+
name: Backport PR
43+
needs: check
44+
if: needs.check.outputs.should_run == 'true'
45+
runs-on: ubuntu-latest
46+
permissions:
47+
id-token: write # OIDC token federation with dd-octo-sts
48+
# Writes use the scoped octo-sts token; ambient GITHUB_TOKEN stays read-only (pull_request_target).
49+
contents: read
50+
pull-requests: read
2651
steps:
2752
- name: Get GitHub token via dd-octo-sts
2853
uses: DataDog/dd-octo-sts-action@acaa02eee7e3bb0839e4272dacb37b8f3b58ba80 # v1.0.3
@@ -34,7 +59,7 @@ jobs:
3459
- name: Checkout code
3560
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3661
with:
37-
# Full history reaches the merged commit and release branches; octo-sts token authenticates the push.
62+
# Full history reaches the merged commit and every release branch; octo-sts token authenticates the push.
3863
fetch-depth: 0
3964
token: ${{ steps.octo-sts.outputs.token }}
4065

@@ -52,92 +77,20 @@ jobs:
5277
- name: Configure ddev
5378
run: |
5479
ddev config set upgrade_check false
55-
ddev config set repos.core .
56-
ddev config set repo core
80+
ddev config override
81+
82+
- name: Configure git identity
83+
run: |
84+
git config --global user.name "dd-agent-integrations-bot[bot]"
85+
git config --global user.email "dd-agent-integrations-bot[bot]@users.noreply.github.com"
5786
5887
- name: Backport merged PR to labelled branches
5988
env:
60-
EVENT_ACTION: ${{ github.event.action }}
61-
LABEL_NAME: ${{ github.event.label.name }}
62-
PR_LABELS_JSON: ${{ toJSON(github.event.pull_request.labels.*.name) }}
63-
PR_NUMBER: ${{ github.event.pull_request.number }}
64-
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
65-
REPO: ${{ github.repository }}
66-
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
6789
# ddev reads the token/user from these; the same token authenticates git push via checkout.
6890
DD_GITHUB_TOKEN: ${{ steps.octo-sts.outputs.token }}
6991
GH_TOKEN: ${{ steps.octo-sts.outputs.token }}
7092
DD_GITHUB_USER: dd-agent-integrations-bot
7193
run: |
72-
set -euo pipefail
73-
74-
# Best-effort PR comment; a comment failure must never fail the backport.
75-
comment_pr() { gh pr comment "${PR_NUMBER}" --repo "${REPO}" --body "$1" || true; }
76-
77-
if [ -z "${MERGE_SHA}" ]; then
78-
echo "::error::merge_commit_sha is empty; cannot backport."
79-
exit 1
80-
fi
81-
82-
# Target branches come from backport/<base> labels: only the new one on `labeled`, all of them on `closed`.
83-
bases=()
84-
if [ "${EVENT_ACTION}" = "labeled" ]; then
85-
case "${LABEL_NAME}" in
86-
backport/*) bases+=("${LABEL_NAME#backport/}") ;;
87-
*) echo "Label '${LABEL_NAME}' is not a backport label; nothing to do."; exit 0 ;;
88-
esac
89-
else
90-
while IFS= read -r base; do
91-
[ -n "${base}" ] && bases+=("${base}")
92-
done < <(printf '%s' "${PR_LABELS_JSON}" | jq -r '.[] | select(startswith("backport/")) | ltrimstr("backport/")')
93-
fi
94-
95-
if [ "${#bases[@]}" -eq 0 ]; then
96-
echo "No backport/* labels to process."
97-
exit 0
98-
fi
99-
100-
git config --global user.name "dd-agent-integrations-bot[bot]"
101-
git config --global user.email "dd-agent-integrations-bot[bot]@users.noreply.github.com"
102-
103-
sha10="${MERGE_SHA:0:10}"
104-
overall_rc=0
105-
for base in "${bases[@]}"; do
106-
# Mirror port-commit's branch naming so we can detect an existing backport before doing work.
107-
branch="$(printf '%s' "${DD_GITHUB_USER}/backport-${sha10}-to-${base}" | tr '[:upper:]' '[:lower:]')"
108-
109-
# Only an OPEN backport PR is detected. A branch left by a failed prior run (no open PR) is not
110-
# auto-cleaned; port-commit's push then fails non-fast-forward until the stale branch is deleted manually.
111-
if [ -n "$(gh pr list --repo "${REPO}" --head "${branch}" --state open --json number --jq '.[].number')" ]; then
112-
echo "::notice::Backport to ${base} already has an open PR (branch ${branch}); skipping."
113-
continue
114-
fi
115-
116-
# Ensure origin/<base> exists locally for the worktree; a missing branch fails just this base, not the run.
117-
if ! git fetch --no-tags origin "+refs/heads/${base}:refs/remotes/origin/${base}"; then
118-
echo "::error::Target branch ${base} does not exist on origin; skipping."
119-
comment_pr "⚠️ Automatic backport to \`${base}\` skipped: the target branch does not exist."
120-
overall_rc=1
121-
continue
122-
fi
123-
124-
echo "::group::Backport #${PR_NUMBER} to ${base}"
125-
# Pass the merge SHA we already have rather than PR-<n>, which would round-trip via the API back to this commit.
126-
if ddev --no-interactive release port-commit "${MERGE_SHA}" \
127-
--target-branch "${base}" \
128-
--branch-prefix backport \
129-
--pr-labels "backport,bot"; then
130-
pr_url="$(gh pr list --repo "${REPO}" --head "${branch}" --state open --json url --jq '.[0].url // ""' || true)"
131-
echo "::notice::Opened backport PR for ${base}: ${pr_url}"
132-
if [ -n "${pr_url}" ]; then
133-
comment_pr "Backport to \`${base}\` opened: ${pr_url}"
134-
fi
135-
else
136-
echo "::error::Backport to ${base} failed."
137-
comment_pr "⚠️ Automatic backport to \`${base}\` failed. See the [workflow run](${RUN_URL}) for details, or backport manually with \`ddev release port-commit PR-${PR_NUMBER} --target-branch ${base}\`. If the push was rejected as non-fast-forward, delete the stale \`${branch}\` branch and retry."
138-
overall_rc=1
139-
fi
140-
echo "::endgroup::"
141-
done
142-
143-
exit "${overall_rc}"
94+
ddev --no-interactive release port-commit --from-pr "${{ github.event.pull_request.number }}" \
95+
--branch-prefix backport \
96+
--pr-labels backport,bot

ddev/changelog.d/24470.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
`release port-commit --from-pr <number>` backports a merged PR to every `backport/<base>` label on it, deriving the commit and target branches from the PR and skipping bases whose backport PR already exists.
12
`release port-commit` now resets `.deps/` in addition to `.in-toto` when porting a commit, so resolved dependency lockfiles are taken from the target branch.

ddev/src/ddev/cli/release/port_commit.py

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@
2828
@click.option('--draft', is_flag=True, default=False, help='Open the PR as a draft.')
2929
@click.option('--verify', is_flag=True, default=False, help='Run commit hooks (skipped by default).')
3030
@click.option('--dry-run', is_flag=True, default=False, help='Print every step instead of executing it.')
31+
@click.option(
32+
'--from-pr',
33+
'from_pr',
34+
type=int,
35+
default=None,
36+
metavar='PR_NUMBER',
37+
help='Backport a merged PR to every `backport/<base>` label on it, deriving the commit and target '
38+
'branches from the PR. Mutually exclusive with COMMIT_OR_PR.',
39+
)
3140
def port_commit(
3241
app: Application,
3342
commit_hash: str | None,
@@ -39,6 +48,7 @@ def port_commit(
3948
draft: bool,
4049
verify: bool,
4150
dry_run: bool,
51+
from_pr: int | None,
4252
) -> None:
4353
"""
4454
Backport a commit onto a target branch.
@@ -53,22 +63,52 @@ def port_commit(
5363
first when a GitHub token is configured, and fall back to commit resolution on 404. If
5464
omitted, the current HEAD commit is used after confirmation.
5565
66+
Pass `--from-pr <number>` instead of COMMIT_OR_PR to backport a merged PR to every
67+
`backport/<base>` label on it, deriving the commit and target branches from the PR. A base
68+
whose backport PR already exists (open, merged, or closed) is skipped, so re-runs are
69+
idempotent. Give `--target-branch` alongside `--from-pr` to restrict the backport to that one
70+
branch.
71+
5672
The GitHub user for the branch prefix is taken from `ddev config` (`github.user`) or the
5773
`DD_GITHUB_USER` / `GITHUB_USER` / `GITHUB_ACTOR` environment variables.
5874
"""
5975
import logging
6076

6177
from ddev.cli.release.port_commit_workflow import (
62-
PortStepError,
63-
build_port_steps,
6478
display_completion_summary,
79+
execute_port_plan,
6580
resolve_port_plan,
81+
run_backport_from_pr,
6682
)
6783

6884
# httpx logs every request at INFO and clutters the workflow output. The PR-resolution and
6985
# PR-creation steps already print their own status lines; the underlying HTTP traffic is noise.
7086
logging.getLogger('httpx').setLevel(logging.WARNING)
7187

88+
if from_pr is not None:
89+
if commit_hash is not None:
90+
app.abort('Pass either COMMIT_OR_PR or --from-pr, not both.')
91+
from click.core import ParameterSource
92+
93+
ctx = click.get_current_context()
94+
target_branch_explicit = ctx.get_parameter_source('target_branch') is not ParameterSource.DEFAULT
95+
succeeded = run_backport_from_pr(
96+
app,
97+
pr_number=from_pr,
98+
target_branch=target_branch,
99+
target_branch_explicit=target_branch_explicit,
100+
branch_prefix=branch_prefix,
101+
branch_suffix=branch_suffix,
102+
pr_labels=pr_labels,
103+
no_pr=no_pr,
104+
draft=draft,
105+
verify=verify,
106+
dry_run=dry_run,
107+
)
108+
if not succeeded:
109+
app.abort('One or more backports failed.')
110+
return
111+
72112
plan = resolve_port_plan(
73113
app,
74114
commit_hash=commit_hash,
@@ -81,36 +121,13 @@ def port_commit(
81121
verify=verify,
82122
dry_run=dry_run,
83123
)
84-
bundle = build_port_steps(app, plan)
85-
86-
success = False
87-
error_msg: str | None = None
88-
try:
89-
for step in bundle.steps:
90-
step.run()
91-
success = True
92-
except PortStepError as e:
93-
error_msg = str(e)
94-
finally:
95-
# If the PR was created before the failure (e.g. labeling failed afterwards), the worktree
96-
# holds no recoverable state — the work is pushed and the PR exists on GitHub. Suppress the
97-
# warning in that case to avoid a misleading "inspect the worktree" message.
98-
pr_already_created = bundle.pr_step is not None and bundle.pr_step.pr_url is not None
99-
if not success and not plan.dry_run and not pr_already_created:
100-
app.display_warning(f'Worktree left at `{plan.worktree_path}` for inspection.')
101-
102-
if error_msg is not None:
103-
app.abort(error_msg)
104-
105-
try:
106-
bundle.teardown.run()
107-
except PortStepError as e:
108-
app.display_warning(f'Could not remove worktree at `{plan.worktree_path}`: {e}')
109-
app.display_warning(f'Run `git worktree remove --force {plan.worktree_path}` to clean it up manually.')
124+
125+
outcome = execute_port_plan(app, plan)
126+
if outcome.error is not None:
127+
app.abort(outcome.error)
110128

111129
if plan.dry_run:
112130
app.display_success('Dry run complete.')
113131
return
114132

115-
pr_url = bundle.pr_step.pr_url if bundle.pr_step is not None else None
116-
display_completion_summary(app, plan, pr_url=pr_url)
133+
display_completion_summary(app, plan, pr_url=outcome.pr_url)

0 commit comments

Comments
 (0)