Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions .github/workflows/fork-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
################################################################################
#
# fork-sync.yml — weekly backport queue for MiSTer-devel/Linux-Kernel_MiSTer.
#
# This repo is the source of truth for the MiSTer kernel, but the fork is a live repo
# other people commit to. Anything landing there after our last reconciliation is
# something we do not have and nobody has decided about. Nothing else forces that
# question -- and the question does not get asked on its own: the fork sat on 5.15.1
# through 210 stable releases partly because no moment ever said "these N commits are
# unaccounted for".
#
# So: scripts/check-fork-sync.sh diffs docs/kernel-recon/fork-sync.conf (the last
# RECONCILED commit per fork branch) against the fork's live HEADs, and this opens an
# issue listing whatever has landed since. The queue becomes a fact rather than a memory.
#
# The direction that matters most is MiSTer-v6.18: once #75 merges upstream, commits
# landing there are changes made to OUR series by other people, and they must flow back
# into board/mister/de10nano/linux-patches/ or the next `make export` silently erases
# them. That is the failure this file exists to prevent.
#
# COST: two compare API calls and no clone -- seconds, weekly. It deliberately does NOT
# check out the kernel (~300MB) or build anything; "what is new upstream" does not need
# either. This is why it can be a schedule rather than something manual: it is far below
# the noise floor of the build workflow. (Contrast build.yml, whose cold path is ~3h.)
#
# ONE ISSUE, UPDATED -- not one per run. A weekly job that opens a fresh issue is a job
# people mute, and a muted queue is the same as no queue. It finds its own open issue by
# label, edits it in place, and closes it when the queue empties.
#
################################################################################

name: Fork sync queue

on:
schedule:
# Mondays 06:00 UTC. Weekly is matched to how fast the fork actually moves (11
# commits across 2026 so far) -- daily would just re-post the same list six more
# times before anyone acts on it.
- cron: "0 6 * * 1"
workflow_dispatch:

# Only the issue write. No checkout of anything but this repo, no packages, no build.
permissions:
contents: read
issues: write

concurrency:
group: fork-sync
cancel-in-progress: false

jobs:
check:
name: Check fork for unreconciled commits
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

# Exit 1 from the script means "commits need triage", which is a normal state and
# must not fail the job -- a red X every week trains people to ignore it. Exit 2 is
# a real error and MUST fail. The report is the product; the exit code only routes it.
#
# Run ONCE, with --markdown, and use that output for both the log and the issue
# body. An earlier cut called the script twice -- plain for the log, --markdown for
# the body with `|| true` to swallow the expected exit 1 -- which was wrong twice
# over: `|| true` also swallows exit 2, so a broken run could file an issue with an
# empty body and still go green; and two invocations are two sets of API calls that
# can disagree if one hits a transient failure.
- name: Diff fork HEADs against the reconciled sync points
id: check
env:
GH_TOKEN: ${{ github.token }}
run: |
set -o pipefail
rc=0
scripts/check-fork-sync.sh --markdown > body.md 2> err.txt || rc=$?

case "$rc" in
0) echo "drift=false" >> "$GITHUB_OUTPUT" ;;
1) echo "drift=true" >> "$GITHUB_OUTPUT" ;;
*)
echo "::error title=fork-sync check failed::scripts/check-fork-sync.sh exited $rc"
cat err.txt >&2
exit "$rc"
;;
esac

# A body we would post must not be empty -- that is the failure mode the single
# invocation is meant to close, so assert it rather than assume it.
if [ ! -s body.md ]; then
echo "::error title=fork-sync produced an empty report::exit was $rc but body.md is empty"
exit 2
fi

cat body.md

- name: Open or update the backport-queue issue
env:
GH_TOKEN: ${{ github.token }}
DRIFT: ${{ steps.check.outputs.drift }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
TITLE="Backport queue: unreconciled commits in Linux-Kernel_MiSTer"
LABEL="fork-sync"

# Label may not exist yet on a fresh repo; create it once, idempotently.
gh label create "$LABEL" --repo "$REPO" --color 0E8A16 \
--description "Upstream fork commits awaiting a disposition" 2>/dev/null || true

# --limit 1: only the first match is used, and the default page of 30 is API
# work we never look at.
existing="$(gh issue list --repo "$REPO" --label "$LABEL" --state open \
--limit 1 --json number --jq '.[0].number // empty')"

if [ "$DRIFT" = "true" ]; then
{
cat body.md
printf '\n---\n<sub>Generated by `.github/workflows/fork-sync.yml` — '
printf 'edited in place each run, so this is always the current queue. '
printf 'Closes itself when everything has a disposition.</sub>\n'
} > issue.md

if [ -n "$existing" ]; then
gh issue edit "$existing" --repo "$REPO" --body-file issue.md
echo "::notice title=Backport queue updated::Issue #$existing refreshed."
else
# `gh issue create` has no --json/--jq; it prints the issue URL. Take the
# number off the end rather than retrying the command -- a `||` fallback
# around a create is how you end up filing two issues.
url="$(gh issue create --repo "$REPO" --title "$TITLE" --label "$LABEL" \
--body-file issue.md)"
echo "::notice title=Backport queue opened::Issue ${url##*/} lists commits needing triage."
fi
elif [ -n "$existing" ]; then
gh issue close "$existing" --repo "$REPO" \
--comment "Everything on the watched fork branches now has a disposition in \`docs/patch-provenance.md\`, and \`docs/kernel-recon/fork-sync.conf\` has been advanced past it. Reopening automatically if the fork moves again."
echo "::notice title=Backport queue empty::Closed issue #$existing."
else
echo "::notice title=Backport queue empty::Fork fully reconciled; nothing to do."
fi
51 changes: 51 additions & 0 deletions docs/kernel-recon/fork-sync.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# fork-sync.conf — how far we have reconciled MiSTer-devel/Linux-Kernel_MiSTer.
#
# WHY THIS FILE EXISTS
# --------------------
# This repo is the source of truth for the MiSTer kernel: the patch series in
# board/mister/de10nano/linux-patches/ is what gets built, and scripts/export-kernel-tree.sh
# renders it into the fork's tree format. But the fork is a live repo that other people
# commit to. Anything landing there after our last reconciliation is, by definition,
# something this repo does not have and nobody has decided about.
#
# Left to humans that decision never gets made -- not through neglect, but because there
# is no moment that forces the question. The fork sat on 5.15.1 for 210 stable releases
# for exactly this reason: nothing ever said "these N commits are unaccounted for".
#
# So this file records the last commit on each fork branch whose content has a disposition
# in docs/patch-provenance.md, and scripts/check-fork-sync.sh diffs it against the fork's
# live HEADs. .github/workflows/fork-sync.yml runs that weekly and opens an issue when the
# answer is non-empty. The queue is then a fact rather than a memory.
#
# FORMAT
# <branch> <full-40-char-sha>
# Comments and blank lines ignored. Full SHAs only -- an abbreviation that is unambiguous
# today can collide later, and this is compared, not just displayed.
#
# UPDATING
# Move a line ONLY when the commits it skips past have been dispositioned in
# docs/patch-provenance.md -- carried into the series, or recorded as deliberately dropped
# with a reason. Advancing it to silence the issue is the one thing that breaks this: the
# file stops meaning "reconciled" and starts meaning "seen", which is what it exists to
# prevent.
#
# WHY BOTH BRANCHES
# MiSTer-v5.15 -- the fork's historical line and the baseline the whole reconciliation
# was performed against (docs/patch-provenance.md's header names f0fb626ac; this is
# now ahead of that, see below). Still actively committed to.
# MiSTer-v6.18 -- upstream's own vanilla 6.18.38 base, created 2026-07-16, which our
# export now replays onto (MiSTer-devel/Linux-Kernel_MiSTer#75). Once that merges,
# commits landing here are changes made to OUR series by other people, and they have
# to flow back into linux-patches/ or the next regeneration silently erases them.
# This is the direction that matters most for keeping this repo the source of truth.

# Reconciled: docs/patch-provenance.md dispositioned every commit up to f0fb626ac.
# Advanced to 794e6f002 on 2026-07-16: the single commit since (794e6f002, "New driver for
# RTL8821CU") is dispositioned -- that chip is covered by mainline rtw88_8821cu
# (CONFIG_RTW88_8821CU=m), which is why BR2_PACKAGE_RTL8821CU_MORROWNR is deliberately off
# in configs/mister_de10nano_defconfig. Nothing to carry.
MiSTer-v5.15 794e6f002d0f655c504733c126a01f8c1f0bc1d4

# Upstream's pristine v6.18.38 base commit, and the parent our export replays onto.
# Nothing of ours or theirs sits on top of it upstream yet.
MiSTer-v6.18 d9ac12a691ead295c8bc6438754767b94c0f26a2
134 changes: 134 additions & 0 deletions scripts/check-fork-sync.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
#
# check-fork-sync.sh — report fork commits we have not reconciled yet.
#
# Diffs docs/kernel-recon/fork-sync.conf (the last reconciled commit per fork branch)
# against MiSTer-devel/Linux-Kernel_MiSTer's live HEADs, and prints what has landed since.
# That list is the backport queue: each commit needs a disposition in
# docs/patch-provenance.md -- carried into board/mister/de10nano/linux-patches/, or
# recorded as deliberately dropped with a reason.
#
# Read fork-sync.conf's header for why this exists at all; the short version is that
# nothing else ever forces the question, and the fork sat on 5.15.1 for 210 stable
# releases partly because of that.
#
# Cheap by construction: one compare API call per branch, no clone. The kernel repo is
# ~300MB and there is no reason to fetch it to answer "what is new".
#
# Usage: scripts/check-fork-sync.sh [--markdown]
# --markdown emit a GitHub-flavoured report (used by the workflow for issue bodies)
#
# Exit: 0 = fully reconciled; 1 = commits need triage (report on stdout); 2 = error.
# The workflow keys off 1 vs 0, so do not make drift fatal-looking; it is normal.

set -o errexit
set -o nounset
set -o pipefail

# Assigned then marked readonly separately: `readonly X="$(cmd)"` masks cmd's exit status
# (shellcheck SC2155), and the rest of scripts/ avoids that pattern.
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly REPO_ROOT
readonly CONF="$REPO_ROOT/docs/kernel-recon/fork-sync.conf"
readonly FORK="${FORK_REPO:-MiSTer-devel/Linux-Kernel_MiSTer}"

markdown=false
[[ ${1:-} == --markdown ]] && markdown=true

err() { printf 'check-fork-sync: %s\n' "$*" >&2; }

command -v gh >/dev/null || { err 'gh CLI not found'; exit 2; }
command -v jq >/dev/null || { err 'jq not found (this script parses the compare API with it)'; exit 2; }
[[ -f $CONF ]] || { err "no such file: $CONF"; exit 2; }

drift=0
report=''

# One line per branch: "<branch> <sha>". Comments and blanks stripped.
while read -r branch sync; do
[[ -n ${branch:-} ]] || continue

[[ ${#sync} -eq 40 ]] || { err "$branch: sync point must be a full 40-char SHA, got '$sync'"; exit 2; }

# compare gives us ahead_by + the commit list without cloning anything. `...` is
# three-dot on purpose: we want commits reachable from the branch but not from the
# sync point, which is exactly "what landed since we last looked".
if ! cmp_json="$(gh api "repos/$FORK/compare/$sync...$branch" 2>/dev/null)"; then
# A missing branch is worth failing on: it means the fork restructured and this
# file is now describing something that does not exist.
err "cannot compare $sync...$branch in $FORK (branch gone, or SHA not an ancestor?)"
exit 2
fi

ahead="$(jq -r '.ahead_by' <<<"$cmp_json")"

# Insist on an integer before comparing. This is not defensive padding: bash
# arithmetic treats a bare word as an unset variable, so BOTH `[[ null -eq 0 ]]` and
# `[[ "" -eq 0 ]]` evaluate TRUE. A response that parsed but had no ahead_by -- an
# auth failure, a rate limit, a partial body -- would therefore report "nothing new"
# and exit 0. A tool whose entire job is to stop commits going unaccounted for must
# not have a path where it silently says all-clear because it could not tell.
[[ $ahead =~ ^[0-9]+$ ]] || {
err "$branch: compare API returned no usable ahead_by (got '$ahead')."
err "Refusing to report 'reconciled' from a response we cannot read."
exit 2
}

if [[ $ahead -eq 0 ]]; then
Comment thread
mcfbytes marked this conversation as resolved.
if $markdown; then
report+="- ✅ \`$branch\` — reconciled through \`${sync:0:9}\`, nothing new.
"
else
report+=" [ok] $branch — reconciled through ${sync:0:9}, nothing new
"
fi
continue
fi

drift=$((drift + 1))
if $markdown; then
report+="
### \`$branch\` — $ahead commit(s) to triage

Reconciled through [\`${sync:0:9}\`](https://github.com/$FORK/commit/$sync). Since then:

| commit | subject | author | date |
|---|---|---|---|
"
report+="$(jq -r --arg f "$FORK" '.commits[] |
"| [`\(.sha[0:9])`](https://github.com/\($f)/commit/\(.sha)) | \(.commit.message | split("\n")[0] | gsub("\\|"; "\\\\|")) | \(.commit.author.name) | \(.commit.author.date[0:10]) |"' <<<"$cmp_json")
"
else
report+="
[TRIAGE] $branch — $ahead commit(s) since ${sync:0:9}
"
report+="$(jq -r '.commits[] | " \(.sha[0:9]) \(.commit.author.date[0:10]) \(.commit.message | split("\n")[0])"' <<<"$cmp_json")
"
fi
done < <(grep -vE '^\s*(#|$)' "$CONF")

if $markdown; then
if ((drift)); then
printf '%s\n' "The fork has commits with no disposition in this repo. Each needs one of:

- **carried** → a patch in \`board/mister/de10nano/linux-patches/\`, or
- **dropped** → a row in \`docs/patch-provenance.md\` saying so, and why (superseded upstream, packaged separately, obsolete…).

Then advance the branch's line in \`docs/kernel-recon/fork-sync.conf\`.

> Advancing that file to silence this issue is the one thing that breaks the mechanism — it would stop meaning *reconciled* and start meaning *seen*.
$report"
else
printf '%s\n' "$report
Nothing to triage."
fi
else
printf '=== fork sync: %s\n%s\n' "$FORK" "$report"
if ((drift)); then
printf 'RESULT: %d branch(es) need triage — see docs/patch-provenance.md\n' "$drift"
else
printf 'RESULT: fully reconciled\n'
fi
fi

((drift == 0))
Loading