From d74e3c7a53829df59f6128c5f5d4356714e97970 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Fri, 9 Oct 2026 13:45:48 -0700 Subject: [PATCH] automation: propose a 6.18-lts backport for each merged mainline PR Every pull request merged into edera/mainline flows down to edera/6.18-lts as its own backport pull request, unless it is labelled no-backport. lts-backport-trigger.yml on edera/mainline dispatches kernel-nightly.yml here with backport_pr=, which runs lts-backport.yml: - prepare works out which of the pull request's commits the LTS tree still lacks (pending-backports.sh, backport-blockers.sh), and holds it back with a backport-waiting label while an earlier pending Edera commit touches the same files, so interdependent patches land in mainline order; - Claude cherry-picks the commits with -x, adapting them to 6.18 where needed, following .automation/skills/mainline-backport/SKILL.md, and can also declare a dependency a file match cannot see; - verify-backport.sh and x86_64/arm64 builds judge the result; - publish always opens (or refreshes) backport/edera-6.18-lts/pr-, never pushes edera/6.18-lts, and keeps one status comment on the mainline pull request. Waiting backports are retried when a backport merges here (lts-backport-merged.yml) and after each nightly rebase, which also re-prepares open backport pull requests the rebase made unmergeable (retry-backports.sh). A backport whose base was rebased away while it ran dispatches itself again. --- .automation/backport-skip | 11 + .automation/skills/mainline-backport/SKILL.md | 206 +++++ .github/scripts/backport-blockers.sh | 92 +++ .github/scripts/pending-backports.sh | 80 ++ .github/scripts/retry-backports.sh | 35 + .github/scripts/tests/backport.test.sh | 225 ++++++ .github/scripts/verify-backport.sh | 177 +++++ .github/workflows/automation-selftest.yml | 14 +- .github/workflows/kernel-nightly.yml | 66 +- .github/workflows/lts-backport-merged.yml | 45 ++ .github/workflows/lts-backport.yml | 722 ++++++++++++++++++ 11 files changed, 1664 insertions(+), 9 deletions(-) create mode 100644 .automation/backport-skip create mode 100644 .automation/skills/mainline-backport/SKILL.md create mode 100755 .github/scripts/backport-blockers.sh create mode 100755 .github/scripts/pending-backports.sh create mode 100755 .github/scripts/retry-backports.sh create mode 100755 .github/scripts/tests/backport.test.sh create mode 100755 .github/scripts/verify-backport.sh create mode 100644 .github/workflows/lts-backport-merged.yml create mode 100644 .github/workflows/lts-backport.yml diff --git a/.automation/backport-skip b/.automation/backport-skip new file mode 100644 index 00000000000000..45f4777c562617 --- /dev/null +++ b/.automation/backport-skip @@ -0,0 +1,11 @@ +# Edera commits on edera/mainline that must never be backported to +# edera/6.18-lts, one subject per line, exactly as the commit has it. +# +# Each pull request merged into edera/mainline is backported on its own +# (.github/workflows/lts-backport.yml); label it no-backport to opt out. This +# file is for commits that did not arrive through such a pull request, or +# were ported to 6.18 in a form the subject match cannot see. A commit that +# is listed here, or belongs to a no-backport pull request, does not hold up +# later backports that touch the same files. +# +# Blank lines and lines starting with # are ignored. diff --git a/.automation/skills/mainline-backport/SKILL.md b/.automation/skills/mainline-backport/SKILL.md new file mode 100644 index 00000000000000..fe1d9506994b16 --- /dev/null +++ b/.automation/skills/mainline-backport/SKILL.md @@ -0,0 +1,206 @@ +--- +name: mainline-backport +description: Backport of one merged edera/mainline pull request to edera/6.18-lts, run unattended in CI by .github/workflows/lts-backport.yml. Covers the cherry-picks, adapting a commit to the older kernel, spotting a dependency on another backport, build checks, and the report that becomes the backport pull request. +--- + +# Mainline pull request backport + +Edera kernel work lands on `edera/mainline` first, one pull request at a time, +and every merged pull request is proposed for `edera/6.18-lts` as its own +backport pull request. You are preparing one of those. The workflow has +already worked out which of the pull request's commits the LTS tree still +lacks and hands you that list. + +The goal is that each backport changes **exactly what its mainline commit +changes**. Where 6.18 differs from mainline enough that the same change does +not apply, you adapt it the way a careful stable maintainer would and you say +exactly what you did. + +This runs unattended. Nobody is watching while you work, and nobody can answer +a question. Everything a reviewer needs has to end up in the report. + +## What happens to your result + +You cannot push, and nothing you write lands on `edera/6.18-lts`: a maintainer +merges the backport pull request, or does not. When you finish, the workflow +independently: + +1. runs `verify-backport.sh`, which checks from git alone that your branch + fast-forwards the LTS tip, is linear, and holds one commit per requested + commit, in order, each naming its source with a `(cherry picked from + commit )` trailer, keeping its source's subject, and changing the + same files by the same +/- lines; +2. builds x86_64 and arm64 `vmlinux` with `kernel-build.sh`. + +Both results go into the pull request next to your report. A commit you had +to adapt **will** show up as adapted there. That is expected, not a failure; +your report explains it. Never reword a subject, merge two commits into one, +or split one into two: backports are recognised later by subject, and a +renamed one would be offered again. + +## Inputs + +The workflow gives you, in the prompt: + +- `MAINLINE_PR`: the number and title of the merged pull request. +- `OLD_TIP`: the `edera/6.18-lts` commit to build on (already checked out). +- `REQUEST`: a file listing the pull request's commits to backport, one per + line as ` TAB `, oldest first. Their objects are already + fetched. Commits the LTS tree already has, and automation-only commits, are + not in it. +- `PENDING`: a file listing every Edera commit on `edera/mainline` that the + LTS tree lacks, in the same format, oldest first. The request's own commits + are among them. The workflow has already checked that nothing earlier in + this list touches the request's files; you look for the dependencies a file + match cannot see. +- `RESULT`: the local branch name your result must end up on. +- `REPORT`: the path your report must be written to. + +The automation's own scripts are in `.rebase-tools/scripts/`, copied from the +commit this run started from. Use those, never the copy inside the tree. + +### How to run commands here + +Your shell commands are checked against an allowlist that matches the +command's first word. Shell variables, `$(...)`, `<(...)` and `VAR=x cmd` +prefixes will be refused, so `OLD_TIP`, `RESULT`, `` and `<...>` below +are placeholders: substitute the literal value. Put scratch files, worktrees +and build output under `.rebase-scratch/` in the repository root; the +kernel's `.gitignore` ignores it. + +This is a blobless clone: file contents are fetched from GitHub the first time +a command needs them, so the first checkout or `git show` can be slow. That is +not a hang. + +## 1. Read the request + +```sh +cat REQUEST +cat PENDING +git show --stat # for each requested commit +``` + +For each commit, read its message and diff, and check what it depends on: +does it call a helper, use a struct field or a Kconfig symbol that 6.18 +lacks? If so, where does that come from? + +- From an earlier commit in the request: fine, it is backported first. +- From a commit in `PENDING` that is not in the request: this backport has to + **wait** for that one. Stop here; see "If it has to wait". +- From upstream (Linus' tree) only: see the next section. + +## 2. Cherry-pick, in order + +```sh +git switch -c RESULT OLD_TIP +git cherry-pick -x # one at a time, oldest first +``` + +`-x` is required: the trailer it adds is how the checker pairs your commit +with its source. If you have to amend, keep the trailer and the subject. + +When a pick conflicts, or applies but would not build or behave the same on +6.18: + +- Read what differs between the two kernels around the change, and adapt the + change so it does on 6.18 what it does on mainline. Prefer the smallest + adaptation; do not backport an unrelated upstream refactor to make the + patch apply. +- If the conflict is with Edera code that only a pending commit brings, + that is a dependency: **wait**, as above. +- If the commit depends on an upstream change 6.18 lacks and cannot be + adapted without it, do not pull that upstream change in. Leave the commit + out (`git cherry-pick --abort` for that one, then continue with the next) + and make it a **Needs a decision** item saying what it needs. +- If the commit does not apply to 6.18 at all (it fixes code only mainline + has), leave it out the same way and say so. +- If you cannot tell what the right adaptation is, make the most conservative + one you can defend, mark it **UNSURE** in the report, and explain both + readings. +- Never use `-X ours`, `-X theirs`, or `git checkout --ours/--theirs` on a + whole file to make a conflict go away. +- Never commit anything that is not a backport of a requested commit. If a + backport needs a 6.18-only fix to build, fold it into that backport and + report it as an adaptation. + +## 3. Build + +```sh +bash .rebase-tools/scripts/kernel-build.sh x86_64 .rebase-scratch/obj-x86 4 +bash .rebase-tools/scripts/kernel-build.sh arm64 .rebase-scratch/obj-arm64 4 +``` + +If a build fails because of a backport, fix it **in that backport**: drive the +todo list with sed, `git -c sequence.editor="sed -i 's/^pick /edit +/'" rebase -i OLD_TIP`, amend (keeping subject and trailer), then `git +rebase --continue`. Never rebase below OLD_TIP. + +If a build fails on OLD_TIP too, it is not yours: build OLD_TIP the same way +in a `.rebase-scratch/` worktree to confirm, and report it as pre-existing. + +## 4. Check yourself + +```sh +bash .rebase-tools/scripts/verify-backport.sh OLD_TIP RESULT REQUEST +``` + +Every commit it lists as adapted or left out must be accounted for in your +report. If one is not, you changed something you did not mean to: find it and +undo it. + +## 5. Report + +Write the report to the `REPORT` path, in Markdown. Its **first line** is the +status, exactly one of: + +- `Status: proposed` when the RESULT branch holds a backport for review; +- `Status: waiting` when it has to wait for another backport (no RESULT + branch); +- `Status: not-applicable` when none of the request applies to 6.18 (no + RESULT branch). + +The rest becomes the pull request body (or the comment on the mainline pull +request when there is no branch), so lead with what a reviewer must decide. +No preamble, no sign-off. + +```markdown +Status: proposed + +## Summary +One paragraph: commits requested, backported unchanged, adapted, left out, +and whether you expect the checker to pass. + +## Needs a decision +One bullet per question, each starting **UNSURE:**, with both readings and +what you committed. Every left-out commit is one. "None." if there is +nothing. + +## Adapted commits +Per commit: subject, what differs on 6.18, what you changed and why. "None." +if none. + +## Left out +Per commit: subject and why. "None." if none. + +## Builds +x86_64, arm64: pass, fail (with the first error), or pre-existing failure +(confirmed on OLD_TIP). +``` + +Then stop. The workflow reads the branch, not the working tree. + +## If it has to wait + +Do not create the RESULT branch. Write the report with `Status: waiting`, +and under `## Waiting on` list each pending commit it needs, as `` `` + ``, with one line on what it needs from it. The workflow labels the +mainline pull request `backport-waiting` and tries again once other backports +land. + +## If nothing applies, or you cannot finish + +If none of the request applies to 6.18, do not create the RESULT branch and +write the report with `Status: not-applicable`, explaining why. If you could +not produce a backport for any other reason, do not create the branch either +and explain what went wrong; the first line is then whatever fits best, and +a missing branch is treated as needing a human either way. diff --git a/.github/scripts/backport-blockers.sh b/.github/scripts/backport-blockers.sh new file mode 100755 index 00000000000000..6588478a0fcf96 --- /dev/null +++ b/.github/scripts/backport-blockers.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Works out what a merged edera/mainline pull request still needs on +# edera/6.18-lts, and what it has to wait for first. +# +# Backports to edera/6.18-lts land in the order their commits landed on +# edera/mainline. A pull request is backported only once every Edera commit +# that came before it on mainline, is still missing from the LTS tree, and +# touches a file it touches has been backported (or skipped). Otherwise its +# commits could be applied on top of code they were never written against. +# +# Usage: +# backport-blockers.sh +# +# is what pending-backports.sh prints for the whole of +# edera/mainline: the mainline commits the LTS tree lacks, oldest first. +# lists the pull request's own commits the same way (" +# TAB ", oldest first). A request commit still needs backporting when +# it matches a pending commit, and those are written to : +# +# - by patch-id first, which is how a rebase-merged commit normally matches +# its copy on mainline; +# - otherwise, if the LTS series does not already have its patch-id, by +# subject, ignoring a trailing version tag. The patch-id check keeps a +# commit that is already on the LTS tree from claiming a different +# pending commit that happens to share its subject. +# +# Prints the pending commits the pull request has to wait for, oldest first, +# and nothing when it can go ahead. + +set -euo pipefail + +if [ $# -ne 5 ] || [ ! -r "$1" ] || [ ! -r "$2" ]; then + echo "usage: $0 " >&2 + exit 2 +fi +pending=$1 +request=$2 +l_tip=$(git rev-parse --verify "$3^{commit}") +l_base=$(git merge-base "$l_tip" "$(git rev-parse --verify "$4^{commit}")") +todo=$5 + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +norm() { sed -E 's/[[:space:]]*\((v?[0-9]+\.[0-9]+(\.[0-9y]+)?(-lts)?)\)$//'; } +pid() { git show "$1" | git patch-id --stable | cut -d' ' -f1; } +files() { git show --format= --name-only "$1"; } + +git log --no-merges --format='commit %H' --patch "$l_base..$l_tip" | + git patch-id --stable | cut -d' ' -f1 | sort -u >"$work/lts-pids" + +# Pending commits, numbered, with patch-ids. +n=0 +while IFS=$'\t' read -r sha subject; do + n=$((n + 1)) + printf '%s\t%s\t%s\t%s\n' "$n" "$sha" "$subject" "$(pid "$sha")" +done <"$pending" >"$work/pending" + +# Each request commit claims the first pending commit it matches that nothing +# else has claimed. +: >"$todo" +: >"$work/claimed" +while IFS=$'\t' read -r sha subject; do + s=$(printf '%s' "$subject" | norm) + p=$(pid "$sha") + by=subject + grep -qxF -- "$p" "$work/lts-pids" && by=none + hit=$(awk -F'\t' -v s="$s" -v p="$p" -v by="$by" ' + FILENAME == ARGV[1] { claimed[$0] = 1; next } + ($1 in claimed) { next } + $4 == p { print $1; found = 1; exit } + by == "subject" && $3 == s && !first { first = $1 } + END { if (!found && first) print first } + ' "$work/claimed" "$work/pending") + if [ -n "$hit" ]; then + echo "$hit" >>"$work/claimed" + printf '%s\t%s\n' "$sha" "$s" >>"$todo" + files "$sha" >>"$work/files" + fi +done <"$request" +[ -s "$todo" ] || exit 0 +sort -u -o "$work/files" "$work/files" +first=$(sort -n "$work/claimed" | head -n1) + +awk -F'\t' -v first="$first" ' + FILENAME == ARGV[1] { claimed[$0] = 1; next } + $1 < first && !($1 in claimed) { print $2 "\t" $3 } +' "$work/claimed" "$work/pending" | while IFS=$'\t' read -r sha subject; do + if files "$sha" | grep -qxFf "$work/files"; then + printf '%s\t%s\n' "$sha" "$subject" + fi +done diff --git a/.github/scripts/pending-backports.sh b/.github/scripts/pending-backports.sh new file mode 100755 index 00000000000000..c6ae95a0669378 --- /dev/null +++ b/.github/scripts/pending-backports.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Lists the Edera commits on edera/mainline that edera/6.18-lts does not have. +# +# Edera kernel work lands on edera/mainline first and flows down to +# edera/6.18-lts. Both trees are rebased onto their upstream nightly, so commit +# hashes are no use for telling which commits already made it across. A commit +# counts as present on the LTS tree when its series there has: +# +# - a commit with the same subject, ignoring a trailing version tag such as +# " (6.18)" that a port may add (the k-th mainline commit with a subject +# pairs with the k-th LTS one, so repeated subjects are counted, not +# merged); or +# - a commit with the same patch-id, which catches a port whose subject was +# reworded but whose change was not. +# +# A commit that must never reach the LTS tree (it fixes code only mainline +# has, say) is listed by subject in the skip file, one per line (the +# backport workflow adds the commits of mainline pull requests labelled +# no-backport); blank lines +# and lines starting with # are ignored. Commits that change only .github/ or +# .automation/ are never offered: both trees carry the automation, it is +# landed on each by hand, and the backport is not allowed to touch it. +# +# Usage: +# pending-backports.sh [] +# +# Prints " TAB " for each pending commit, oldest first, and +# nothing when the trees agree. Merge commits are ignored on both sides: their +# content is in the commits they merged. + +set -euo pipefail + +if [ $# -lt 4 ] || [ $# -gt 5 ]; then + echo "usage: $0 []" >&2 + exit 2 +fi + +m_tip=$(git rev-parse --verify "$1^{commit}") +m_base=$(git merge-base "$m_tip" "$(git rev-parse --verify "$2^{commit}")") +l_tip=$(git rev-parse --verify "$3^{commit}") +l_base=$(git merge-base "$l_tip" "$(git rev-parse --verify "$4^{commit}")") +skip=${5:-/dev/null} + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# TAB TAB , oldest first. +series() { + git log --no-merges --reverse --format='commit %H' --patch "$1" | + git patch-id --stable | awk '{ print $2 "\t" $1 }' >"$work/pid.map" + git log --no-merges --reverse --format='%H%x09%s' "$1" | + awk -F'\t' 'NR == FNR { pid[$1] = $2; next } + { + s = $2 + sub(/[ \t]*\((v?[0-9]+\.[0-9]+(\.[0-9y]+)?(-lts)?)\)$/, "", s) + print $1 "\t" s "\t" ($1 in pid ? pid[$1] : "-") + }' "$work/pid.map" - +} +series "$m_base..$m_tip" >"$work/mainline" +series "$l_base..$l_tip" >"$work/lts" +# Mainline commits whose every changed path is automation. +git log --no-merges --format='commit %H' --name-only "$m_base..$m_tip" | + awk '/^commit / { if (c != "" && only) print c; c = $2; only = 0; seen = 0; next } + NF { if (!seen) only = 1; seen = 1; if ($0 !~ /^\.(github|automation)\//) only = 0 } + END { if (c != "" && only) print c }' >"$work/automation" +grep -vE '^[[:space:]]*(#|$)' "$skip" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g' >"$work/skip" || true + +awk -F'\t' ' + FILENAME == ARGV[1] { auto[$0] = 1; next } + FILENAME == ARGV[2] { skip[$0] = 1; next } + FILENAME == ARGV[3] { have[$2]++; if ($3 != "-") bypid[$3] = $2; next } + { + if (($1 in auto) || ($2 in skip)) next + # A patch-id match uses up the LTS commit it matched, under the + # subject that commit has there, so it cannot also pair with another. + if ($3 in bypid) { have[bypid[$3]]--; delete bypid[$3]; next } + if (have[$2] > 0) { have[$2]--; next } + print $1 "\t" $2 + } +' "$work/automation" "$work/skip" "$work/lts" "$work/mainline" diff --git a/.github/scripts/retry-backports.sh b/.github/scripts/retry-backports.sh new file mode 100755 index 00000000000000..723955972a35a8 --- /dev/null +++ b/.github/scripts/retry-backports.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Dispatches backports of merged edera/mainline pull requests again. +# +# - Every mainline pull request labelled backport-waiting: something it was +# waiting for may have landed on edera/6.18-lts since. +# - With --stale, also every open backport pull request GitHub can no +# longer merge: a rebase of edera/6.18-lts rewrote the history it was +# built on, so it is prepared again from the new tip. +# +# Each one is a run of kernel-nightly.yml on edera/6.18-lts with backport_pr +# set (lts-backport.yml). Needs GH_TOKEN with actions write and pull requests +# read, and GITHUB_REPOSITORY. +# +# Usage: retry-backports.sh [--stale] + +set -euo pipefail + +SOURCE=edera/mainline +TARGET=edera/6.18-lts +PREFIX=backport/edera-6.18-lts/pr- +R=$GITHUB_REPOSITORY + +{ + gh pr list --repo "$R" --base "$SOURCE" --state merged --label backport-waiting \ + --limit 200 --json number -q '.[].number' + if [ "${1:-}" = --stale ]; then + gh pr list --repo "$R" --base "$TARGET" --state open --limit 200 \ + --json headRefName,mergeable \ + -q ".[] | select(.headRefName | startswith(\"$PREFIX\")) | select(.mergeable == \"CONFLICTING\") | .headRefName" | + sed "s|^$PREFIX||" + fi +} | grep -xE '[0-9]+' | sort -un | while read -r n; do + echo "Dispatching the backport of #$n" + gh workflow run kernel-nightly.yml --repo "$R" --ref "$TARGET" -f backport_pr="$n" +done diff --git a/.github/scripts/tests/backport.test.sh b/.github/scripts/tests/backport.test.sh new file mode 100755 index 00000000000000..9c650aba454d5b --- /dev/null +++ b/.github/scripts/tests/backport.test.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# Tests for .github/scripts/pending-backports.sh, backport-blockers.sh and +# verify-backport.sh. +# +# Builds a small throwaway repository with two upstreams (an "lts" line and a +# "mainline" line that has moved on), an Edera series on each, and checks +# that pending-backports.sh finds exactly the mainline commits the LTS series +# lacks, and that backport-blockers.sh holds a pull request back only for +# earlier pending commits that touch its files. It then backports them +# honestly and in each way the checker exists to catch. A backport is only +# proposed on verify-backport.sh's say-so, so every damaged backport must be +# flagged or refused and the honest one must pass. +# +# Run from anywhere: bash .github/scripts/tests/backport.test.sh +set -uo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PENDING="$HERE/../pending-backports.sh" +VERIFY="$HERE/../verify-backport.sh" +BLOCKERS="$HERE/../backport-blockers.sh" + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +mkdir "$WORK/repo" && cd "$WORK/repo" || exit 1 + +failures=0 +ok() { echo "ok $1"; } +bad() { + echo "FAIL $1" >&2 + [ -z "${2:-}" ] || printf ' %s\n' "${2//$'\n'/$'\n' }" >&2 + failures=$((failures + 1)) +} + +git init -q -b lts-up . +git config user.name test +git config user.email test@example.invalid +commit() { git add -A && git commit -qm "$1"; } + +# Upstreams: mainline is the LTS base plus an unrelated change. +seq 1 60 >a.c +seq 1 60 >b.c +commit "upstream: base" +git switch -q -c mainline-up +echo "upstream main" >u.c +commit "upstream: mainline moves on" + +# Edera on LTS: one commit both trees share, one ported with a version tag, +# one reworded but identical in content, one LTS-only, and a repeated subject +# once. +git switch -q -c lts lts-up +sed -i 's/^5$/5 shared/' a.c +commit "xen: shared fix" +sed -i 's/^15$/15 ported/' a.c +commit "9p/xen: share one frontend (6.18)" +sed -i 's/^25$/25 reworded/' a.c +commit "xen: lts wording of a fix" +echo lts-only >l.c +commit "drm: lts only" +sed -i 's/^30$/30 dep one/' b.c +commit "apply dep patch" +lts_tip=$(git rev-parse HEAD) + +# Edera on mainline: the same, plus three new commits (one with the repeated +# subject, one to be skipped), a merge, and an automation-only commit, which +# is never offered. +git switch -q -c main mainline-up +sed -i 's/^5$/5 shared/' a.c +commit "xen: shared fix" +sed -i 's/^15$/15 ported/' a.c +commit "9p/xen: share one frontend" +sed -i 's/^25$/25 reworded/' a.c +commit "xen: mainline wording of a fix" +sed -i 's/^30$/30 dep one/' b.c +commit "apply dep patch" +git switch -q -c side +sed -i 's/^40$/40 new one/' b.c +commit "xen: new on mainline" +git switch -q main +git merge -q --no-ff --no-edit side >/dev/null +sed -i 's/^50$/50 dep two/' b.c +commit "apply dep patch" +echo mainline-only >m.c +commit "xen: mainline only" +mkdir -p .github/workflows +echo "on: push" >.github/workflows/x.yml +commit "automation: a workflow" +main_tip=$(git rev-parse HEAD) + +printf '# never backport\n\n xen: mainline only \n' >"$WORK/skip" +want=$(printf '%s\n' "xen: new on mainline" "apply dep patch") +got=$(bash "$PENDING" "$main_tip" mainline-up "$lts_tip" lts-up "$WORK/skip" | cut -f2) +if [ "$got" = "$want" ]; then ok "pending lists only what LTS lacks, oldest first"; else + bad "pending lists only what LTS lacks, oldest first" "got: $got"; fi + +got=$(bash "$PENDING" "$main_tip" mainline-up "$lts_tip" lts-up | cut -f2 | tail -n1) +if [ "$got" = "xen: mainline only" ]; then ok "without a skip file, skipped commits are pending"; else + bad "without a skip file, skipped commits are pending" "got: $got"; fi + +got=$(bash "$PENDING" "$lts_tip" lts-up "$lts_tip" lts-up) +if [ -z "$got" ]; then ok "a tree against itself has nothing pending"; else + bad "a tree against itself has nothing pending" "got: $got"; fi + +# Blockers. A "pull request" here is one mainline commit, as the request +# file lists it. +bash "$PENDING" "$main_tip" mainline-up "$lts_tip" lts-up >"$WORK/pending-all" +nth() { git log --reverse --format=%H --grep="^$1\$" "$main_tip" | sed -n "${2:-1}p"; } +pr() { for c in "$@"; do printf '%s\t%s\n' "$c" "$(git log -1 --format=%s "$c")"; done >"$WORK/pr"; } +blockers() { bash "$BLOCKERS" "$WORK/pending-all" "$WORK/pr" "$lts_tip" lts-up "$WORK/todo" | cut -f2; } +check() { # check + local got + got=$(blockers) + if [ "$got" != "$2" ]; then + bad "$1" "blockers: $got" + elif [ "$(grep -c . "$WORK/todo")" -ne "$3" ]; then + bad "$1" "todo: $(cat "$WORK/todo")" + else + ok "$1" + fi +} + +pr "$(nth 'apply dep patch' 2)" +check "an earlier pending commit on the same file blocks" "xen: new on mainline" 1 + +pr "$(nth 'apply dep patch' 1)" +check "a commit already on LTS has nothing to do, even with a repeated subject" "" 0 + +pr "$(nth 'xen: new on mainline')" +check "the oldest pending commit is not blocked" "" 1 + +pr "$(nth 'xen: new on mainline')" "$(nth 'apply dep patch' 2)" +check "a pull request does not wait for its own commits" "" 2 + +pr "$(nth 'xen: mainline only')" +check "earlier pending commits on other files do not block" "" 1 + +pr "$(nth 'automation: a workflow')" +check "an automation-only pull request has nothing to do" "" 0 + +printf 'xen: new on mainline\n' >"$WORK/skip2" +bash "$PENDING" "$main_tip" mainline-up "$lts_tip" lts-up "$WORK/skip2" >"$WORK/pending-all" +pr "$(nth 'apply dep patch' 2)" +check "a skipped commit does not block" "" 1 + +bash "$PENDING" "$main_tip" mainline-up "$lts_tip" lts-up "$WORK/skip" >"$WORK/request" + +expect() { + # expect [] + local desc=$1 want=$2 got out + out=$(bash "$VERIFY" "$lts_tip" "$3" "$WORK/request") + got=$? + if [ "$got" -ne "$want" ]; then + bad "$desc: exit $got, want $want" "$out" + elif [ $# -ge 4 ] && ! grep -qF -- "$4" <<<"$out"; then + bad "$desc: output lacks '$4'" "$out" + else + ok "$desc" + fi +} + +pick_all() { # pick_all + git switch -q -c "$1" "$lts_tip" + cut -f1 "$WORK/request" | while read -r c; do git cherry-pick -x "$c" >/dev/null; done +} + +pick_all good +good=$(git rev-parse HEAD) +expect "honest backport passes" 0 "$good" "All 2 requested commits backported" + +got=$(bash "$PENDING" "$main_tip" mainline-up "$good" lts-up "$WORK/skip") +if [ -z "$got" ]; then ok "after the backport nothing is pending"; else + bad "after the backport nothing is pending" "got: $got"; fi + +# Adapted: the backport changes different lines from its source. +git switch -q -c adapted "$good" +echo extra >>b.c +git commit -qa --amend --no-edit +expect "adapted commit needs review" 1 "$(git rev-parse HEAD)" "| adapted |" + +# Retitled: same change, new subject, which pending would not recognise. +git switch -q -c retitled "$good" +git commit -q --amend -m "apply a dependency patch + +(cherry picked from commit $(sed -n 2p "$WORK/request" | cut -f1))" +expect "retitled commit needs review" 1 "$(git rev-parse HEAD)" "| retitled |" + +# Left out: only the first requested commit. +git switch -q -c leftout "$good~1" +expect "left-out commit needs review" 1 "$(git rev-parse HEAD)" "| left-out |" + +# Added: an extra commit with no trailer. +git switch -q -c added "$good" +echo sneaky >s.c +commit "innocent looking" +expect "added commit needs review" 1 "$(git rev-parse HEAD)" "| added |" + +# Out of order. +git switch -q -c reordered "$lts_tip" +git cherry-pick -x "$(sed -n 2p "$WORK/request" | cut -f1)" >/dev/null +git cherry-pick -x "$(sed -n 1p "$WORK/request" | cut -f1)" >/dev/null +expect "out-of-order backport is refused" 2 "$(git rev-parse HEAD)" "| stray |" + +# A trailer naming a commit nobody asked for. +git switch -q -c stray "$good" +echo x >x.c +git add x.c +git commit -qm "xen: mainline only + +(cherry picked from commit $main_tip)" +expect "unrequested source is refused" 2 "$(git rev-parse HEAD)" "| stray |" + +# Not a fast-forward of the LTS tip. +git switch -q -c rewritten "$lts_tip~1" +cut -f1 "$WORK/request" | while read -r c; do git cherry-pick -x "$c" >/dev/null; done +expect "rewritten LTS history is refused" 2 "$(git rev-parse HEAD)" "does not contain the LTS tip" + +# A merge on top. +git switch -q -c merged "$good" +git merge -q --no-ff --no-edit side >/dev/null +expect "merge on top is refused" 2 "$(git rev-parse HEAD)" "merge commit" + +if [ "$failures" -ne 0 ]; then + echo "$failures failure(s)" >&2 + exit 1 +fi +echo "all passed" diff --git a/.github/scripts/verify-backport.sh b/.github/scripts/verify-backport.sh new file mode 100755 index 00000000000000..e5cc7964777ea0 --- /dev/null +++ b/.github/scripts/verify-backport.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Checks a backport of edera/mainline commits onto edera/6.18-lts, without +# trusting whoever did it. +# +# The backport of a merged edera/mainline pull request lets a model +# cherry-pick its commits, but nothing it says about its own result is taken +# on faith. This script is the gate. It proves, from git alone, that: +# +# 1. the new branch fast-forwards the LTS tip the backport started from; +# 2. the commits on top are linear (no merge commits); +# 3. every one of them is a backport of a requested commit, in the requested +# order, each named by its "(cherry picked from commit )" trailer, +# with nothing extra in between and nothing requested left out; +# 4. each keeps its source's subject, so pending-backports.sh recognises it +# next time instead of offering it again; +# 5. each changes the same files by the same +/- lines as its source. Hunk +# offsets and context may differ; the change itself may not. +# +# A commit that had to be adapted to the older kernel fails check 5. That is +# the point: such a commit is not wrong, but the pull request has to say so, +# so its reviewer knows to read it closely. +# +# Usage: +# verify-backport.sh +# +# holds the commits asked for, as pending-backports.sh prints +# them: " TAB ", oldest first. +# +# Writes a Markdown report to stdout. Exit status: +# 0 clean: every requested commit backported unchanged +# 1 differences: well-formed (1-3 hold for what is there) but a commit was +# adapted, retitled, left out, or added; flagged in the pull request +# 2 broken: 1 or 2 failed, a trailer names something not requested or out +# of order, or the inputs are unusable; not proposed at all + +set -euo pipefail + +if [ $# -ne 3 ] || [ ! -r "$3" ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +old=$(git rev-parse --verify "$1^{commit}") +new=$(git rev-parse --verify "$2^{commit}") +requested=$3 + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +status=0 +fail() { [ "$status" -ge "$1" ] || status=$1; } +short() { git rev-parse --short=12 "$1"; } +oneline() { git log -1 --format='%h %s' "$1"; } +# The subject without a trailing version tag such as " (6.18)", as +# pending-backports.sh compares them. +subject() { git log -1 --format=%s "$1" | sed -E 's/[[:space:]]*\((v?[0-9]+\.[0-9]+(\.[0-9y]+)?(-lts)?)\)$//'; } +own_change() { + git show --format= "$1" | grep -E '^([+-]|diff )' | grep -vE '^(\+\+\+|---) ' || true +} + +echo "| | |" +echo "| --- | --- |" +echo "| LTS tip before | \`$(short "$old")\` |" +echo "| LTS tip after | \`$(short "$new")\` |" +echo "| Commits requested | $(grep -c . "$requested" || true) |" +echo + +# 1. Fast-forward. +if git merge-base --is-ancestor "$old" "$new"; then + echo "- [x] New branch fast-forwards the LTS tip." +else + echo "- [ ] **New branch does not contain the LTS tip** \`$(short "$old")\`." + fail 2 +fi + +# 2. Linearity. +if [ "$status" -lt 2 ]; then + merges=$(git rev-list --merges "$old..$new" | wc -l) + if [ "$merges" -eq 0 ]; then + echo "- [x] Backported commits are linear." + else + echo "- [ ] **$merges merge commit(s) on top of the LTS tip.**" + fail 2 + fi +fi + +if [ "$status" -ge 2 ]; then + echo + echo "Stopped: the remaining checks assume a linear series on the LTS tip." + exit "$status" +fi + +# 3-5. Walk the new commits and the request list together. A commit names its +# source by trailer; it must be the next requested commit, or a later one (the +# ones skipped over were left out). +cut -f1 "$requested" | while read -r sha; do git rev-parse --verify "$sha^{commit}"; done >"$work/want" +git rev-list --reverse "$old..$new" >"$work/got" + +: >"$work/rows" +next=1 +n_want=$(wc -l <"$work/want") +while read -r c; do + src=$(git log -1 --format=%B "$c" | + sed -nE 's/^\(cherry picked from commit ([0-9a-f]{40})\)$/\1/p' | tail -n1) + if [ -z "$src" ]; then + echo "added $c -" >>"$work/rows" + fail 1 + continue + fi + pos=$(grep -nxF "$src" "$work/want" | cut -d: -f1 | head -n1 || true) + if [ -z "$pos" ] || [ "$pos" -lt "$next" ]; then + echo "stray $c $src" >>"$work/rows" + fail 2 + continue + fi + while [ "$next" -lt "$pos" ]; do + echo "left-out - $(sed -n "${next}p" "$work/want")" >>"$work/rows" + fail 1 + next=$((next + 1)) + done + next=$((pos + 1)) + if [ "$(subject "$c")" != "$(subject "$src")" ]; then + echo "retitled $c $src" >>"$work/rows" + fail 1 + elif [ "$(own_change "$c")" != "$(own_change "$src")" ]; then + echo "adapted $c $src" >>"$work/rows" + fail 1 + else + echo "same $c $src" >>"$work/rows" + fi +done <"$work/got" +while [ "$next" -le "$n_want" ]; do + echo "left-out - $(sed -n "${next}p" "$work/want")" >>"$work/rows" + fail 1 + next=$((next + 1)) +done + +count() { awk -F'\t' -v k="$1" '$1 == k' "$work/rows" | wc -l; } +n_same=$(count same) +if [ "$n_same" -eq "$n_want" ] && [ "$(wc -l <"$work/got")" -eq "$n_want" ]; then + echo "- [x] All $n_want requested commits backported in order, each with its source's subject and identical +/- lines." +else + echo "- [ ] **Backport differs from the request:** $n_same of $n_want unchanged;" \ + "$(count adapted) adapted, $(count retitled) retitled, $(count left-out) left out," \ + "$(count added) added, $(count stray) not requested or out of order." +fi + +if [ "$n_same" -ne "$(wc -l <"$work/rows")" ]; then + echo + echo "| | Backport | Mainline source |" + echo "| --- | --- | --- |" + while IFS=$'\t' read -r verdict c src; do + [ "$verdict" = same ] && continue + a='(none)'; b='(none)' + [ "$c" = - ] || a=$(oneline "$c") + [ "$src" = - ] || b=$(oneline "$src") + echo "| $verdict | \`$a\` | \`$b\` |" + done <"$work/rows" +fi + +if grep -q '^adapted' "$work/rows"; then + echo + echo "
Adapted commits (source +/- lines vs. backport)" + echo + echo '```diff' + awk -F'\t' '$1 == "adapted" { print $2 "\t" $3 }' "$work/rows" | + while IFS=$'\t' read -r c src; do + echo "### $(oneline "$c")" + diff -u --label mainline --label backport \ + <(own_change "$src") <(own_change "$c") || true + done | head -n 400 + echo '```' + echo + echo "
" +fi + +exit "$status" diff --git a/.github/workflows/automation-selftest.yml b/.github/workflows/automation-selftest.yml index b3cf554a636931..fff5f1f7ac104d 100644 --- a/.github/workflows/automation-selftest.yml +++ b/.github/workflows/automation-selftest.yml @@ -1,14 +1,17 @@ name: Automation self-test # Tests the scripts the nightly rebase relies on to decide whether to -# force-push edera/6.18-lts, so a change that weakens either must not land -# quietly: +# force-push edera/6.18-lts, and the ones the backports rely on to decide what +# to propose, so a change that weakens any of them must not land quietly: # # - verify-upstream-rebase.sh: honest replays pass, including one that # flattens a merged series; edited, dropped, added and merged commits and # a stale base are all refused; # - report-needs-decision.sh: any question in Claude's report, or anything -# marked UNSURE, stops the automatic push. +# marked UNSURE, stops the automatic push; +# - pending-backports.sh, backport-blockers.sh and verify-backport.sh: only +# what the LTS tree lacks is offered, in mainline order, and a damaged +# backport is flagged or refused. # # It runs only when the automation itself changes. @@ -21,6 +24,8 @@ on: - '.github/workflows/upstream-rebase.yml' - '.github/workflows/kernel-nightly.yml' - '.github/workflows/rebase-land.yml' + - '.github/workflows/lts-backport.yml' + - '.github/workflows/lts-backport-merged.yml' - '.github/workflows/automation-selftest.yml' - '.automation/**' @@ -56,5 +61,8 @@ jobs: - name: Decision detector tests run: bash .github/scripts/tests/report-needs-decision.test.sh + - name: Backport tests + run: bash .github/scripts/tests/backport.test.sh + - name: Shellcheck run: shellcheck .github/scripts/*.sh .github/scripts/tests/*.sh diff --git a/.github/workflows/kernel-nightly.yml b/.github/workflows/kernel-nightly.yml index f06f0c17c61c17..9c5135045f42c9 100644 --- a/.github/workflows/kernel-nightly.yml +++ b/.github/workflows/kernel-nightly.yml @@ -1,10 +1,17 @@ name: Nightly kernel maintenance -# Rebases edera/6.18-lts onto stable linux-6.18.y every night -# (upstream-rebase.yml). +# Keeps edera/6.18-lts current: # -# Claude does the rebase. Independent checks decide whether the result is -# pushed or proposed as a pull request; see upstream-rebase.yml. +# - nightly, rebases it onto stable linux-6.18.y (upstream-rebase.yml), then +# retries waiting backports and refreshes the backport pull requests the +# rebase left behind (retry-backports.sh); +# - with backport_pr set, proposes the backport of that merged +# edera/mainline pull request instead (lts-backport.yml). +# lts-backport-trigger.yml on edera/mainline dispatches this for every +# pull request that merges there. +# +# Claude does the rebase and the backports. Independent checks decide what +# happens to each result; see those workflows. # # GitHub runs scheduled workflows only from the default branch, edera/mainline, # so this copy has no schedule of its own. lts-rebase-trigger.yml on @@ -33,18 +40,25 @@ on: description: Rebase even if an open rebase pull request already covers this upstream tip type: boolean default: false + backport_pr: + description: Instead of the rebase, backport this merged edera/mainline pull request (its number) + type: string + default: '' permissions: contents: read concurrency: # Per tree: groups are repository-wide, and edera/mainline's copy of this - # workflow uses plain kernel-nightly. - group: kernel-nightly-edera/6.18-lts + # workflow uses plain kernel-nightly. Each backport has its own group, so + # they run alongside the rebase and each other; a newer dispatch for the + # same pull request replaces one still queued. + group: kernel-nightly-edera/6.18-lts${{ inputs.backport_pr != '' && format('-backport-{0}', inputs.backport_pr) || '' }} cancel-in-progress: false jobs: lts: + if: inputs.backport_pr == '' uses: ./.github/workflows/upstream-rebase.yml permissions: contents: read @@ -58,3 +72,43 @@ jobs: upstream_branch: linux-6.18.y force: ${{ inputs.force == true }} secrets: inherit + + # After the rebase, whatever it did. + retry: + needs: lts + if: always() && inputs.backport_pr == '' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: write + pull-requests: read + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + sparse-checkout: .github + + - name: Retry and refresh backports + env: + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/retry-backports.sh --stale + + backport: + if: inputs.backport_pr != '' + uses: ./.github/workflows/lts-backport.yml + permissions: + contents: read + actions: write + issues: write + pull-requests: write + id-token: write + with: + pr: ${{ inputs.backport_pr }} + secrets: inherit diff --git a/.github/workflows/lts-backport-merged.yml b/.github/workflows/lts-backport-merged.yml new file mode 100644 index 00000000000000..d77121a6a984ec --- /dev/null +++ b/.github/workflows/lts-backport-merged.yml @@ -0,0 +1,45 @@ +name: Retry waiting backports + +# When a backport pull request merges into edera/6.18-lts, a mainline pull +# request waiting for it (labelled backport-waiting) may now be able to go +# ahead, so every waiting one is dispatched again (retry-backports.sh). The +# nightly retries them too; this just saves the wait. +# +# pull_request_target runs this file as it is on edera/6.18-lts, never the +# pull request's copy, and nothing here checks out the pull request. + +on: + pull_request_target: + types: [closed] + branches: [edera/6.18-lts] + +permissions: {} + +jobs: + retry: + if: >- + github.event.pull_request.merged + && startsWith(github.event.pull_request.head.ref, 'backport/edera-6.18-lts/') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: write + pull-requests: read + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + persist-credentials: false + sparse-checkout: .github + + - name: Retry waiting backports + env: + GH_TOKEN: ${{ github.token }} + run: bash .github/scripts/retry-backports.sh diff --git a/.github/workflows/lts-backport.yml b/.github/workflows/lts-backport.yml new file mode 100644 index 00000000000000..4a0e15d997e3ce --- /dev/null +++ b/.github/workflows/lts-backport.yml @@ -0,0 +1,722 @@ +name: LTS backport + +# Proposes the backport of one merged edera/mainline pull request to +# edera/6.18-lts, as a pull request. Called by kernel-nightly.yml when it is +# dispatched with backport_pr: by lts-backport-trigger.yml on edera/mainline +# when a pull request merges there, and by retry-backports.sh to retry or +# refresh one. +# +# Edera kernel work lands on edera/mainline first and every merged pull +# request flows down, unless it is labelled no-backport. The order is kept: +# backport-blockers.sh holds a pull request back while an earlier Edera commit +# that touches its files is still missing from edera/6.18-lts, and Claude +# holds it back for a dependency a file match cannot see. Either way the +# mainline pull request is labelled backport-waiting and retried later. +# +# Claude cherry-picks the commits, following +# .automation/skills/mainline-backport/SKILL.md. Nothing it says about its own +# result decides what happens to it. The model runs read-only; its branch +# leaves as a git bundle and is judged by scripts from the commit this run +# started from: +# +# - verify-backport.sh proves the branch fast-forwards the LTS tip and holds +# exactly the requested commits, in order, each with its source's subject +# and its source's +/- lines; +# - kernel-build.sh builds x86_64 and arm64 vmlinux. +# +# The result always goes up as a backport/edera-6.18-lts/pr- pull request +# with both results and Claude's report; nothing here pushes edera/6.18-lts. +# The mainline pull request gets one status comment, kept up to date, and is +# labelled backport-needs-attention when there is nothing to propose and a +# human has to decide. + +on: + workflow_call: + inputs: + pr: + description: Number of the merged edera/mainline pull request + type: string + required: true + +permissions: + contents: read + +env: + SOURCE: edera/mainline + SOURCE_UPSTREAM_URL: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git + SOURCE_UPSTREAM_BRANCH: master + TARGET: edera/6.18-lts + TARGET_UPSTREAM_URL: https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git + TARGET_UPSTREAM_BRANCH: linux-6.18.y + PR: ${{ inputs.pr }} + BRANCH: backport/edera-6.18-lts/pr-${{ inputs.pr }} + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: read + outputs: + state: ${{ steps.plan.outputs.state }} + old: ${{ steps.plan.outputs.old }} + head: ${{ steps.plan.outputs.head }} + source: ${{ steps.plan.outputs.source }} + title: ${{ steps.plan.outputs.title }} + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ env.TARGET }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Trusted tools + run: | + set -euo pipefail + git fetch --no-tags --filter=blob:none origin "$GITHUB_SHA" + mkdir "$RUNNER_TEMP/tools" + git archive "$GITHUB_SHA" .github/scripts .automation | tar -x -C "$RUNNER_TEMP/tools" --strip-components=1 + + - name: Plan + id: plan + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + out=$RUNNER_TEMP/out + mkdir -p "$out" + tools=$RUNNER_TEMP/tools + finish() { # finish + echo "state=$1" >> "$GITHUB_OUTPUT" + printf '%s\n' "$2" >"$out/message.md" + printf '### Backport of #%s\n\n%s\n' "$PR" "$2" >> "$GITHUB_STEP_SUMMARY" + exit 0 + } + if ! [[ $PR =~ ^[0-9]+$ ]]; then + echo "::error::backport_pr must be a pull request number, not '$PR'." + exit 1 + fi + old=$(git rev-parse HEAD) + echo "old=$old" >> "$GITHUB_OUTPUT" + + pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR") + title=$(jq -r .title <<<"$pr" | tr -d '\r\n') + echo "title=$title" >> "$GITHUB_OUTPUT" + if [ "$(jq -r .merged <<<"$pr")" != true ] || [ "$(jq -r .base.ref <<<"$pr")" != "$SOURCE" ]; then + finish ignored "#$PR is not a merged pull request into \`$SOURCE\`." + fi + case $(jq -r .head.ref <<<"$pr") in + rebase/*) finish ignored "#$PR is a nightly rebase, not Edera work." ;; + esac + if jq -e '.labels | map(.name) | index("no-backport")' <<<"$pr" >/dev/null; then + finish skipped "#$PR is labelled \`no-backport\`, so it is not backported to \`$TARGET\`." + fi + + # The pull request's own commits, as they were reviewed. Their hashes + # stay valid when the nightly rebase rewrites edera/mainline. + git fetch --no-tags --filter=blob:none origin "refs/pull/$PR/head" + head=$(git rev-parse FETCH_HEAD) + echo "head=$head" >> "$GITHUB_OUTPUT" + gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/commits" -q '.[].sha' >"$out/pr-commits" + while read -r c; do + git merge-base --is-ancestor "$c" "$head" + printf '%s\t%s\n' "$c" "$(git log -1 --format=%s "$c")" + done <"$out/pr-commits" >"$out/request" + + git fetch --no-tags --filter=blob:none origin "$SOURCE" + src=$(git rev-parse FETCH_HEAD) + echo "source=$src" >> "$GITHUB_OUTPUT" + git fetch --no-tags --filter=blob:none "$SOURCE_UPSTREAM_URL" "$SOURCE_UPSTREAM_BRANCH" + src_up=$(git rev-parse FETCH_HEAD) + git fetch --no-tags --filter=blob:none "$TARGET_UPSTREAM_URL" "$TARGET_UPSTREAM_BRANCH" + tgt_up=$(git rev-parse FETCH_HEAD) + + # Never backported: the skip file, and the commits of every mainline + # pull request labelled no-backport. + cp "$tools/backport-skip" "$out/skip" + gh pr list --repo "$GITHUB_REPOSITORY" --base "$SOURCE" --state merged \ + --label no-backport --limit 500 --json number -q '.[].number' | + while read -r n; do + gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$n/commits" -q '.[].commit.message | split("\n")[0]' + done >>"$out/skip" + + bash "$tools/scripts/pending-backports.sh" "$src" "$src_up" "$old" "$tgt_up" "$out/skip" >"$out/pending" + bash "$tools/scripts/backport-blockers.sh" "$out/pending" "$out/request" "$old" "$tgt_up" "$out/todo" >"$out/blockers" + + if [ ! -s "$out/todo" ]; then + finish skipped "Nothing to backport: every commit of #$PR is already on \`$TARGET\` (\`${old:0:12}\`), changes only automation, or is no longer an Edera commit on \`$SOURCE\`." + fi + if [ -s "$out/blockers" ]; then + { + echo "Waiting: earlier Edera commits on \`$SOURCE\` that touch the same files are not on \`$TARGET\` yet, and have to be backported first:" + echo + while IFS=$'\t' read -r sha subject; do echo "- \`${sha:0:12}\` $subject"; done <"$out/blockers" + echo + echo "This is retried when a backport lands on \`$TARGET\`, and nightly. If one of them should never be backported, label its pull request \`no-backport\`, or list its subject in \`.automation/backport-skip\` on \`$TARGET\`." + } >"$out/blocked.md" + finish waiting "$(cat "$out/blocked.md")" + fi + { + echo "### Backport of #$PR to \`$TARGET\` (\`${old:0:12}\`)" + echo + while IFS=$'\t' read -r sha subject; do echo "- \`${sha:0:12}\` $subject"; done <"$out/todo" + } >> "$GITHUB_STEP_SUMMARY" + echo "state=run" >> "$GITHUB_OUTPUT" + + - name: Upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: backport-plan + path: ${{ runner.temp }}/out/ + if-no-files-found: error + retention-days: 30 + + backport: + needs: prepare + if: needs.prepare.outputs.state == 'run' + runs-on: ubuntu-latest + timeout-minutes: 240 + # Read-only on purpose: the model runs here. It reaches the Claude API + # through OIDC federation, and that is the only thing id-token is for. + permissions: + contents: read + id-token: write + outputs: + produced: ${{ steps.collect.outputs.produced }} + result: ${{ steps.collect.outputs.result }} + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.old }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + # The skill and scripts come from the commit this run started from. + # The kernel's .gitignore ignores dot-directories, so .rebase-tools/ + # cannot end up in a commit. + - name: Trusted tools + env: + HEAD_SHA: ${{ needs.prepare.outputs.head }} + SRC: ${{ needs.prepare.outputs.source }} + run: | + set -euo pipefail + git fetch --no-tags --filter=blob:none origin "$GITHUB_SHA" "$SRC" "refs/pull/$PR/head" + git cat-file -e "$HEAD_SHA^{commit}" + mkdir .rebase-tools + git archive "$GITHUB_SHA" .github/scripts .automation | tar -x -C .rebase-tools --strip-components=1 + git config --global user.name "edera-lts-backport" + git config --global user.email "noreply@edera.dev" + + - name: Download the plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: backport-plan + path: ${{ runner.temp }}/in + + - name: Install toolchains + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential bc bison flex libelf-dev libssl-dev python3 \ + gcc-aarch64-linux-gnu + + - name: Normalize identifiers + id: ids + env: + RULE: ${{ vars.REBASE_FEDERATION_RULE_ID }} + ORG: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + SVC: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} + WS: ${{ vars.ANTHROPIC_WORKSPACE_ID }} + run: | + set -euo pipefail + strip() { printf '%s' "$1" | tr -d '[:space:]'; } + { + echo "rule=$(strip "$RULE")" + echo "org=$(strip "$ORG")" + echo "svc=$(strip "$SVC")" + echo "ws=$(strip "$WS")" + } >> "$GITHUB_OUTPUT" + # An unset variable otherwise surfaces as an opaque API failure a + # second into the model step, with nothing in the log to say why. + missing=0 + for n in REBASE_FEDERATION_RULE_ID:RULE ANTHROPIC_ORGANIZATION_ID:ORG ANTHROPIC_SERVICE_ACCOUNT_ID:SVC ANTHROPIC_WORKSPACE_ID:WS; do + eval "v=\$${n#*:}" + if [ -z "$(strip "$v")" ]; then + echo "::error::vars.${n%%:*} is empty or not visible to this repository." + missing=1 + fi + done + exit "$missing" + + - name: Backport + id: claude + continue-on-error: true + uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245 + with: + # The job token, which is read-only here. Without it the action + # would exchange OIDC for its own app token, which can write. + github_token: ${{ github.token }} + anthropic_federation_rule_id: '${{ steps.ids.outputs.rule }}' + anthropic_organization_id: '${{ steps.ids.outputs.org }}' + anthropic_service_account_id: '${{ steps.ids.outputs.svc }}' + anthropic_workspace_id: '${{ steps.ids.outputs.ws }}' + prompt: | + Backport a merged edera/mainline pull request to edera/6.18-lts by + following .rebase-tools/skills/mainline-backport/SKILL.md exactly. + Read it in full before you start. + + MAINLINE_PR=#${{ inputs.pr }}: ${{ needs.prepare.outputs.title }} + OLD_TIP=${{ needs.prepare.outputs.old }} (${{ env.TARGET }}, checked out) + REQUEST=${{ runner.temp }}/in/todo + PENDING=${{ runner.temp }}/in/pending + RESULT=backport-result + REPORT=${{ runner.temp }}/backport-report.md + + You cannot push and must not try. The workflow verifies and + builds your branch independently afterwards and proposes it as a + pull request. + claude_args: | + --model claude-opus-5-5 + --max-turns 300 + --allowedTools "Read,Grep,Glob,Edit,Write,Bash(git:*),Bash(make:*),Bash(bash .rebase-tools/scripts/verify-backport.sh:*),Bash(bash .rebase-tools/scripts/kernel-build.sh:*),Bash(scripts/config:*),Bash(mkdir -p .rebase-scratch:*),Bash(diff:*),Bash(comm:*),Bash(sort:*),Bash(grep:*),Bash(sed:*),Bash(awk:*),Bash(cat:*),Bash(head:*),Bash(tail:*),Bash(wc:*),Bash(ls:*),Bash(cut:*),Bash(tee:*)" + + - name: Collect the result + id: collect + if: always() + env: + REPORT: ${{ runner.temp }}/backport-report.md + OLD: ${{ needs.prepare.outputs.old }} + OUTCOME: ${{ steps.claude.outcome }} + EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }} + run: | + set -euo pipefail + out=$RUNNER_TEMP/out + mkdir -p "$out" + if [ -s "$REPORT" ]; then + cp "$REPORT" "$out/report.md" + else + # Only the API error messages and the final result text: neither + # carries tool output into a public log. + why= + f=${EXECUTION_FILE:-$RUNNER_TEMP/claude-execution-output.json} + if [ -s "$f" ]; then + why=$(jq -r ' + (if type == "array" then .[] else . end) + | if .type == "assistant" and (.error != null) then + "API error (\(.error)): " + ([.message.content[]? | .text? // empty] | join(" ")) + elif .type == "result" then + ([.result // empty] + (.errors // [] | map(tostring))) | join("\n") + else empty end' "$f" 2>/dev/null | head -c 2000 || true) + fi + { + printf 'Status: failed\n\nClaude left no report (backport step: %s).\n' "$OUTCOME" + if [ -n "$why" ]; then + # shellcheck disable=SC2016 # a literal Markdown fence + printf '\n```\n%s\n```\n' "$why" + else + printf 'The run log has the cause.\n' + fi + } >"$out/report.md" + [ -z "$why" ] || echo "::error::Claude ended without a result: ${why//$'\n'/ }" + fi + if git rev-parse --verify -q refs/heads/backport-result >/dev/null \ + && git merge-base --is-ancestor "$OLD" backport-result \ + && [ "$(git rev-parse backport-result)" != "$OLD" ]; then + git bundle create "$out/backport.bundle" refs/heads/backport-result "^$OLD" + echo "produced=true" >> "$GITHUB_OUTPUT" + echo "result=$(git rev-parse backport-result)" >> "$GITHUB_OUTPUT" + else + echo "produced=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: backport + path: ${{ runner.temp }}/out/ + if-no-files-found: error + retention-days: 30 + + verify: + needs: [prepare, backport] + if: needs.backport.outputs.produced == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + status: ${{ steps.check.outputs.status }} + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.old }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Trusted tools + run: | + set -euo pipefail + git fetch --no-tags --filter=blob:none origin "$GITHUB_SHA" "refs/pull/$PR/head" + mkdir "$RUNNER_TEMP/tools" + git archive "$GITHUB_SHA" .github/scripts | tar -x -C "$RUNNER_TEMP/tools" --strip-components=1 + + - name: Download the plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: backport-plan + path: ${{ runner.temp }}/in/plan + + - name: Download the backport + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: backport + path: ${{ runner.temp }}/in/backport + + - name: Check + id: check + env: + OLD: ${{ needs.prepare.outputs.old }} + RESULT: ${{ needs.backport.outputs.result }} + run: | + set -euo pipefail + git fetch "$RUNNER_TEMP/in/backport/backport.bundle" backport-result:backport-result + [ "$(git rev-parse backport-result)" = "$RESULT" ] || { echo "bundle does not hold $RESULT"; exit 1; } + # Capture the status: under bash -e a bare call would end the step + # before the verdict is recorded. + rc=0 + bash "$RUNNER_TEMP/tools/scripts/verify-backport.sh" "$OLD" "$RESULT" "$RUNNER_TEMP/in/plan/todo" >"$RUNNER_TEMP/verify.md" || rc=$? + case $rc in + 0) status=clean ;; + 1) status=differences ;; + *) status=broken ;; + esac + echo "status=$status" >> "$GITHUB_OUTPUT" + cat "$RUNNER_TEMP/verify.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: backport-verify + path: ${{ runner.temp }}/verify.md + retention-days: 30 + + build: + needs: [prepare, backport] + if: needs.backport.outputs.produced == 'true' + runs-on: ubuntu-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + arch: [x86_64, arm64] + name: build (${{ matrix.arch }}) + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.old }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Trusted tools + run: | + set -euo pipefail + git fetch --no-tags --filter=blob:none origin "$GITHUB_SHA" + mkdir "$RUNNER_TEMP/tools" + git archive "$GITHUB_SHA" .github/scripts | tar -x -C "$RUNNER_TEMP/tools" --strip-components=1 + + - name: Download + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: backport + path: ${{ runner.temp }}/in + + - name: Check out the result + env: + RESULT: ${{ needs.backport.outputs.result }} + run: | + set -euo pipefail + git fetch "$RUNNER_TEMP/in/backport.bundle" backport-result:backport-result + git checkout --detach "$RESULT" + + - name: Install toolchains + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential bc bison flex libelf-dev libssl-dev python3 \ + gcc-aarch64-linux-gnu + + - name: Build + env: + ARCH: ${{ matrix.arch }} + run: bash "$RUNNER_TEMP/tools/scripts/kernel-build.sh" "$ARCH" "$RUNNER_TEMP/obj" + + publish: + needs: [prepare, backport, verify, build] + if: >- + always() + && needs.prepare.result == 'success' + && needs.prepare.outputs.state != 'ignored' + && needs.backport.result != 'cancelled' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + actions: write + issues: write + pull-requests: write + steps: + - name: Harden runner + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.old }} + fetch-depth: 0 + filter: blob:none + persist-credentials: false + + - name: Trusted tools + run: | + set -euo pipefail + git fetch --no-tags --filter=blob:none origin "$GITHUB_SHA" + mkdir "$RUNNER_TEMP/tools" + git archive "$GITHUB_SHA" .github/scripts | tar -x -C "$RUNNER_TEMP/tools" --strip-components=1 + + - name: Download the plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: backport-plan + path: ${{ runner.temp }}/in/plan + + - name: Download the backport + if: needs.prepare.outputs.state == 'run' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: backport + path: ${{ runner.temp }}/in/backport + + - name: Download the checker report + if: needs.verify.result == 'success' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: backport-verify + path: ${{ runner.temp }}/in/verify + + # Pushes and closes backport branches. Pull requests it opens trigger + # the usual checks, which ones opened with GITHUB_TOKEN would not. + - name: App token + id: app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.REBASE_APP_CLIENT_ID }} + private-key: ${{ secrets.REBASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Propose + env: + APP_TOKEN: ${{ steps.app.outputs.token }} + GH_TOKEN: ${{ github.token }} + STATE: ${{ needs.prepare.outputs.state }} + TITLE: ${{ needs.prepare.outputs.title }} + PRODUCED: ${{ needs.backport.outputs.produced }} + VERIFY: ${{ needs.verify.outputs.status }} + BUILD: ${{ needs.build.result }} + OLD: ${{ needs.prepare.outputs.old }} + RESULT: ${{ needs.backport.outputs.result }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + R=$GITHUB_REPOSITORY + in=$RUNNER_TEMP/in + summary() { cat >> "$GITHUB_STEP_SUMMARY"; } + + # One status comment on the mainline pull request, kept up to date. + marker='' + status_comment() { # status_comment + local id + { echo "$marker"; echo "**Backport to \`$TARGET\`:** $(head -n1 "$1")"; tail -n +2 "$1"; echo; echo "[Run log]($RUN_URL)"; } >"$1.full" + id=$(gh api --paginate "repos/$R/issues/$PR/comments" \ + -q ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n1) + if [ -n "$id" ]; then + gh api -X PATCH "repos/$R/issues/comments/$id" -F body=@"$1.full" >/dev/null + else + gh api -X POST "repos/$R/issues/$PR/comments" -F body=@"$1.full" >/dev/null + fi + } + # At most one of backport-waiting and backport-needs-attention. + set_label() { # set_label