diff --git a/.github/workflows/fork-sync.yml b/.github/workflows/fork-sync.yml
new file mode 100644
index 0000000..9180985
--- /dev/null
+++ b/.github/workflows/fork-sync.yml
@@ -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---\nGenerated 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.\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
diff --git a/docs/kernel-recon/fork-sync.conf b/docs/kernel-recon/fork-sync.conf
new file mode 100644
index 0000000..7513626
--- /dev/null
+++ b/docs/kernel-recon/fork-sync.conf
@@ -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
+#
+# 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
diff --git a/scripts/check-fork-sync.sh b/scripts/check-fork-sync.sh
new file mode 100755
index 0000000..a81d702
--- /dev/null
+++ b/scripts/check-fork-sync.sh
@@ -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: " ". 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
+ 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))