From cbeab0f7b9f0299663abae0772d914a33dbb3692 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 03:32:52 +0000 Subject: [PATCH] Pin the WLED sources a firmware image is built from Both workflows resolved the upstream ref themselves -- "the latest published WLED release", read fresh on every run -- and took the list of upstream pull requests to apply from a workflow_dispatch input that defaults to empty. Two things followed from that. The compile-only build on a pull request never carried a patch, because nothing passes that input on a push. So it rehearsed a tree no release has ever shipped, and the one thing it is for -- proving the release build works -- it could not do. On the other side, a release carried wled/WLED#5521 only when whoever pressed the button remembered to type 5521. Miss it and nothing fails: the battery usermod exports no um_data, getUMData(USERMOD_ID_BATTERY) never succeeds, and the node ships reporting no battery state. The ref being unpinned is the larger one. Every build profile inherits its platform, toolchain and NeoPixelBus version from WLED's shared [esp32], [esp32s2], [esp32s3] and [esp32c3] sections, and on WLED's main those have already moved from ESP-IDF 4.4 to 5.5. Unpinned, that toolchain switch -- and whatever it means for the OTA images already in the field -- arrives the day the next WLED release is published, with no commit here to point at. So wled_source.json now names both, and the workflows read it. The inputs still override for a one-off run; what changed is that an empty input means "what the repository says" rather than "whatever upstream looks like right now", and asking for something else is explicit: "latest" for the newest published release, "none" for a stock tag. The pin's own tests reject an unknown key, since a misspelled one would otherwise be ignored in silence and build the default it was meant to change. Pinned to v16.0.1 + wled/WLED#5521 -- what v0.1.14 shipped. Verified that #5521 still applies cleanly to that tag. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CEr4gm1NX2GzmH13UYyMyN --- .github/workflows/build.yml | 16 ++-- .github/workflows/release.yml | 11 ++- readme.md | 45 ++++++++- scripts/apply_wled_patches.py | 33 ++++++- scripts/resolve_wled_release.py | 56 +++++++++--- scripts/wled_source.py | 94 +++++++++++++++++++ tests/test_release_workflow.py | 22 ++++- tests/test_wled_source.py | 157 ++++++++++++++++++++++++++++++++ wled_source.json | 24 +++++ 9 files changed, 426 insertions(+), 32 deletions(-) create mode 100644 scripts/wled_source.py create mode 100644 tests/test_wled_source.py create mode 100644 wled_source.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 894319f..56eb218 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,8 +4,9 @@ name: Build Firmware # ever built this repo, and it tags and publishes in the same run — so a broken # build or a broken staging step surfaced in the middle of a real release. # -# This job runs the identical build-and-stage script against the latest -# published WLED release, verifies the resulting factory images, and ships +# This job runs the identical build-and-stage script against the WLED sources +# pinned in wled_source.json -- the same ref and the same upstream patches a +# release would build -- verifies the resulting factory images, and ships # nothing. on: @@ -16,12 +17,12 @@ on: workflow_dispatch: inputs: wled_ref: - description: "Optional WLED tag/ref override. Leave empty to use the latest published WLED release." + description: "Optional WLED tag/ref override. Leave empty for the ref pinned in wled_source.json, or 'latest' for the newest published WLED release." required: false default: "" type: string wled_patch_prs: - description: "Optional wled/WLED pull requests to apply on top of that ref, e.g. 5521 or 5521,5533." + description: "Optional wled/WLED pull requests to apply on top of that ref, e.g. 5521 or 5521,5533. Leave empty for the list pinned in wled_source.json, or 'none' for a stock build." required: false default: "" type: string @@ -71,8 +72,11 @@ jobs: fetch-depth: 1 # Before npm and PlatformIO see the tree: a pull request may touch - # package-lock.json or platformio.ini. With no pull requests to apply this - # echoes the ref back unchanged and touches nothing, so it needs no `if:`. + # package-lock.json or platformio.ini. The list comes from + # wled_source.json unless the input overrides it, so this rehearsal + # carries the same patches a release does. With no pull requests to apply + # it echoes the ref back unchanged and touches nothing, so it needs no + # `if:`. - name: Apply WLED pull requests id: wled_patches env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d91b90..1619864 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,12 +14,12 @@ on: default: "main" type: string wled_ref: - description: "Optional WLED tag/ref override. Leave empty to use the latest published WLED release." + description: "Optional WLED tag/ref override. Leave empty for the ref pinned in wled_source.json, or 'latest' for the newest published WLED release." required: false default: "" type: string wled_patch_prs: - description: "Optional wled/WLED pull requests to apply on top of that ref, e.g. 5521 or 5521,5533." + description: "Optional wled/WLED pull requests to apply on top of that ref, e.g. 5521 or 5521,5533. Leave empty for the list pinned in wled_source.json, or 'none' for a stock build." required: false default: "" type: string @@ -114,9 +114,10 @@ jobs: path: external/WLED fetch-depth: 1 - # Same step, same order as build.yml -- the rehearsal has to see the tree - # the release builds. `git apply` is strict on purpose: a patch that no - # longer fits the tagged release fails here rather than in the compiler. + # Same step, same order as build.yml, and with the same defaults out of + # wled_source.json -- the rehearsal has to see the tree the release + # builds. `git apply` is strict on purpose: a patch that no longer fits + # the tagged release fails here rather than in the compiler. - name: Apply WLED pull requests id: wled_patches env: diff --git a/readme.md b/readme.md index c834890..7ec51a9 100644 --- a/readme.md +++ b/readme.md @@ -68,6 +68,7 @@ RaceLink_WLED/ ├─ bak_RaceLink_Node_v3_s2_llcc68.platformio_override.ini └─ all_profiles.platformio_override.ini ├─ library.json +├─ wled_source.json ├─ racelink_epaper.cpp ├─ racelink_epaper.h ├─ racelink_proto.h @@ -112,10 +113,11 @@ Before building, make sure you have: ### 1. Get the official WLED repository -Clone or download the official WLED repository: +Clone or download the official WLED repository, at the release this firmware is +built against (see **Which WLED version** below): ```bash -git clone https://github.com/wled/WLED.git +git clone --branch v16.0.1 https://github.com/wled/WLED.git ``` You can also fork it on GitHub first and then clone your fork. @@ -182,6 +184,45 @@ This means that users should **start with the closest matching profile** and the --- +## Which WLED version + +`wled_source.json` names the WLED sources a RaceLink image is built from: + +```json +{ + "ref": "v16.0.1", + "patch_prs": [5521] +} +``` + +* `ref` — the wled/WLED tag to build against, or `"latest"` to resolve the + newest published upstream release at build time. +* `patch_prs` — upstream pull requests applied on top of that tag, in order. + `git apply` is strict here: a patch that no longer fits the tag fails the + build rather than being fuzzed into place. + +Both the compile-only build and the release read this file, so a pull request +is compiled against the same tree a release would ship. Both workflows still +take a `wled_ref` and a `wled_patch_prs` input for a one-off run; leaving them +empty uses the pinned values, and `latest` / `none` are the ways to ask for +something else explicitly. + +Pinning is deliberate. Every build profile inherits its platform, toolchain and +NeoPixelBus version from WLED's shared `[esp32]`, `[esp32s2]`, `[esp32s3]` and +`[esp32c3]` sections. On WLED's `main` those sections have already moved from +ESP-IDF 4.4 to 5.5, so an unpinned build would adopt a new toolchain — and a +different OTA story — the day the next WLED release appears, with no commit +here to point at. + +`patch_prs` currently carries **wled/WLED#5521**, which exports the battery +usermod's readings through `um_data`. Without it `getUMData(USERMOD_ID_BATTERY)` +never succeeds and a node reports no battery state; nothing fails, the lookup is +simply retried forever. The pull request is still open upstream, and +wled/WLED#5399 rewrites the same file — once that merges, this patch stops +applying and the dependency needs another answer. + +--- + ## Using this repository as an external usermod source The intended usage model is: diff --git a/scripts/apply_wled_patches.py b/scripts/apply_wled_patches.py index 60eaaf7..6b99701 100644 --- a/scripts/apply_wled_patches.py +++ b/scripts/apply_wled_patches.py @@ -18,6 +18,13 @@ build. :func:`label_ref` appends the pull requests so the sidecar names every source that went into the image. +Which pull requests those are comes from ``wled_source.json``, not from a +field somebody types on release day. A patch list that lives only in a +``workflow_dispatch`` input is carried by no rehearsal and by whichever release +its operator remembered it for -- and #5521 in particular fails silently when +it is missed, because the battery data it exports is looked up at runtime and +merely retried when absent. Passing ``none`` builds a stock tag anyway. + With no pull requests to apply this is a pass-through: the ref is echoed unchanged and the tree is untouched, so the workflows need no conditional around it. @@ -32,6 +39,12 @@ import urllib.request from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.wled_source import NO_PATCHES, load_source_pin + WLED_REPOSITORY = "wled/WLED" DEFAULT_USER_AGENT = "RaceLink_WLED-release-resolver" @@ -60,11 +73,27 @@ def _parse_args() -> argparse.Namespace: parser.add_argument( "--patch-prs", default="", - help="Pull requests to apply, separated by commas or whitespace. May be empty.", + help=( + "Pull requests to apply, separated by commas or whitespace. If " + "empty, apply the list pinned in wled_source.json; " + f"'{NO_PATCHES}' applies none." + ), ) return parser.parse_args() +def resolve_pr_numbers(raw: str, *, pinned: str | None = None) -> list[int]: + """Resolve the list to apply from the input, or from the pin file.""" + requested = str(raw).strip() + # An empty input is the workflow field nobody filled in, not a decision to + # build unpatched -- that one has to be said out loud. + if not requested: + requested = pinned if pinned is not None else load_source_pin().patch_prs + if requested.strip().lower() == NO_PATCHES: + return [] + return parse_pr_numbers(requested) + + def parse_pr_numbers(raw: str) -> list[int]: """Parse a pull-request list, tolerating commas, spaces and leading '#'.""" numbers: list[int] = [] @@ -132,7 +161,7 @@ def main() -> int: label = apply_pull_requests( wled_dir=args.wled_dir.resolve(), ref=args.wled_ref, - pr_numbers=parse_pr_numbers(args.patch_prs), + pr_numbers=resolve_pr_numbers(args.patch_prs), ) sys.stdout.write(f"{label}\n") return 0 diff --git a/scripts/resolve_wled_release.py b/scripts/resolve_wled_release.py index cd86547..aa0310e 100644 --- a/scripts/resolve_wled_release.py +++ b/scripts/resolve_wled_release.py @@ -1,4 +1,13 @@ -"""Resolve the WLED source ref to use for RaceLink_WLED release builds.""" +"""Resolve the WLED source ref to use for RaceLink_WLED release builds. + +The ref comes from ``wled_source.json`` unless a run overrides it. Resolving +the newest upstream release instead is still available -- pin ``"latest"``, or +pass it as the workflow input -- but it is now something a run asks for rather +than what happens by default. An unpinned ref means the firmware's foundation +can change with no commit in this repository, and the change WLED 17 brings is +not a small one: the shared esp32/esp32s2/esp32s3/esp32c3 build sections move +from ESP-IDF 4.4 to 5.5, and every RaceLink profile inherits from them. +""" from __future__ import annotations @@ -7,6 +16,13 @@ import os import sys import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.wled_source import LATEST_REF, load_source_pin WLED_REPOSITORY = "wled/WLED" DEFAULT_USER_AGENT = "RaceLink_WLED-release-resolver" @@ -19,7 +35,11 @@ def _parse_args() -> argparse.Namespace: parser.add_argument( "--wled-ref", default="", - help="Explicit WLED tag/ref override. If empty, use the latest published WLED release.", + help=( + "Explicit WLED tag/ref override. If empty, use the ref pinned in " + f"wled_source.json; '{LATEST_REF}' resolves the newest published " + "WLED release instead." + ), ) parser.add_argument( "--print", @@ -45,25 +65,35 @@ def _read_json(url: str) -> object: return json.loads(response.read().decode("utf-8")) -def resolve_wled_ref(explicit_ref: str) -> str: - """Resolve the WLED ref from input or the latest GitHub release.""" - normalized = str(explicit_ref).strip() - if normalized: - return normalized - +def fetch_latest_release_ref() -> str: + """Return the tag of the newest published upstream release.""" payload = _read_json(f"https://api.github.com/repos/{WLED_REPOSITORY}/releases/latest") if not isinstance(payload, dict) or not payload.get("tag_name"): raise RuntimeError("GitHub latest-release API returned an unexpected WLED payload.") return str(payload["tag_name"]).strip() +def resolve_wled_ref(explicit_ref: str, *, pinned_ref: str | None = None) -> str: + """Resolve the ref from the input, the pin, or the latest upstream release.""" + # An empty input is not a request for anything -- it is the workflow field + # nobody filled in -- so it falls through to the pin. + ref = str(explicit_ref).strip() + if not ref: + ref = pinned_ref if pinned_ref is not None else load_source_pin().ref + + if ref.strip().lower() == LATEST_REF: + return fetch_latest_release_ref() + return ref.strip() + + def main() -> int: args = _parse_args() - values = { - "repository": WLED_REPOSITORY, - "ref": resolve_wled_ref(args.wled_ref), - } - sys.stdout.write(f"{values[args.print_field]}\n") + # The repository is a constant, so answering it must not depend on -- or + # pay for -- resolving a ref. The workflows ask for both, one call each. + if args.print_field == "repository": + sys.stdout.write(f"{WLED_REPOSITORY}\n") + return 0 + sys.stdout.write(f"{resolve_wled_ref(args.wled_ref)}\n") return 0 diff --git a/scripts/wled_source.py b/scripts/wled_source.py new file mode 100644 index 0000000..18daf6f --- /dev/null +++ b/scripts/wled_source.py @@ -0,0 +1,94 @@ +"""The pinned WLED sources a RaceLink firmware image is built from. + +``wled_source.json`` names the upstream tag and the pull requests applied on +top of it. Both workflows read it, and that is the whole point: with the ref +resolved fresh on every run, the compile-only build on a pull request and the +release built weeks later could rest on entirely different upstream trees, and +neither would say so. A patch list that lives only in a ``workflow_dispatch`` +input has the same problem from the other side -- the rehearsal never carries +it, and a release carries it only if whoever pressed the button remembered to +type it. + +Both remain overridable per run. The inputs win when set; the sentinels below +are how a run says "ignore the pin" explicitly rather than by leaving a field +blank, which is indistinguishable from not having thought about it. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SOURCE_PIN_FILENAME = "wled_source.json" + +# Asking for the newest published upstream release instead of the pinned tag. +LATEST_REF = "latest" +# Asking for a stock build of that tag, with none of the pinned patches. +NO_PATCHES = "none" + +# "notes" is prose for whoever opens the file next -- JSON has nowhere else to +# record *why* a pull request is pinned. Everything else is a typo. +_KNOWN_KEYS = frozenset({"ref", "patch_prs", "notes"}) + + +@dataclass(frozen=True) +class WledSource: + """The pinned upstream ref and patch list, as the CLIs take them.""" + + ref: str + patch_prs: str # comma-separated, in application order; "" for none + + +def source_pin_path(repo_root: Path | None = None) -> Path: + return (repo_root or REPO_ROOT) / SOURCE_PIN_FILENAME + + +def parse_source_pin(payload: object) -> WledSource: + """Validate a decoded ``wled_source.json`` and normalise it for the CLIs.""" + if not isinstance(payload, dict): + raise ValueError(f"{SOURCE_PIN_FILENAME} must contain a JSON object") + + unknown = sorted(set(payload) - _KNOWN_KEYS) + if unknown: + raise ValueError( + f"{SOURCE_PIN_FILENAME}: unknown key(s) {', '.join(unknown)}. " + f"Known keys: {', '.join(sorted(_KNOWN_KEYS))}." + ) + + ref = payload.get("ref") + if not isinstance(ref, str) or not ref.strip(): + raise ValueError( + f"{SOURCE_PIN_FILENAME}: 'ref' must be a wled/WLED tag or '{LATEST_REF}'" + ) + + raw_prs = payload.get("patch_prs", []) + if not isinstance(raw_prs, list): + raise ValueError(f"{SOURCE_PIN_FILENAME}: 'patch_prs' must be a list of numbers") + + numbers: list[int] = [] + for entry in raw_prs: + # bool is an int subclass, and `true` in this list is a mistake, not a + # pull request number. + if isinstance(entry, bool) or not isinstance(entry, int) or entry <= 0: + raise ValueError( + f"{SOURCE_PIN_FILENAME}: {entry!r} is not a pull request number" + ) + numbers.append(entry) + + return WledSource(ref=ref.strip(), patch_prs=",".join(str(n) for n in numbers)) + + +def load_source_pin(path: Path | None = None) -> WledSource: + """Read and validate the pin file.""" + pin_path = path or source_pin_path() + try: + payload = json.loads(pin_path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise FileNotFoundError( + f"Missing {pin_path}. Both workflows build from it; see its 'notes'." + ) from error + except json.JSONDecodeError as error: + raise ValueError(f"{pin_path} is not valid JSON: {error}") from error + return parse_source_pin(payload) diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index 78e952f..71b3e3a 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -86,6 +86,23 @@ def test_release_notes_do_not_promise_files_that_no_longer_ship(self): with self.subTest(gone=gone): self.assertNotIn(gone, source) + def test_both_workflows_default_to_the_committed_source_pin(self): + # The whole point of the pin: a rehearsal that resolves its own ref and + # carries no patches rehearses a tree no release ever ships. Leaving + # both inputs blank has to mean "what wled_source.json says", and the + # descriptions are where an operator finds that out. + for workflow in (BUILD_WORKFLOW, RELEASE_WORKFLOW): + with self.subTest(workflow=workflow.name): + descriptions = [ + line + for line in workflow.read_text(encoding="utf-8").splitlines() + if line.strip().startswith("description:") + and ("WLED tag/ref" in line or "pull requests to apply" in line) + ] + self.assertEqual(len(descriptions), 2) + for description in descriptions: + self.assertIn("wled_source.json", description) + def test_both_workflows_can_apply_upstream_pull_requests(self): for workflow in (BUILD_WORKFLOW, RELEASE_WORKFLOW): with self.subTest(workflow=workflow.name): @@ -151,10 +168,7 @@ def test_release_workflow_has_required_manual_inputs(self): source, ) self.assertIn('description: "Branch to release from"', source) - self.assertIn( - 'description: "Optional WLED tag/ref override. Leave empty to use the latest published WLED release."', - source, - ) + self.assertIn("Optional WLED tag/ref override.", source) def test_release_workflow_resolves_versions_guards_duplicates_and_publishes_release(self): source = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") diff --git a/tests/test_wled_source.py b/tests/test_wled_source.py new file mode 100644 index 0000000..24feb3f --- /dev/null +++ b/tests/test_wled_source.py @@ -0,0 +1,157 @@ +import json +import pathlib +import tempfile +import unittest + +from scripts.apply_wled_patches import resolve_pr_numbers +from scripts.resolve_wled_release import resolve_wled_ref +from scripts.wled_source import ( + LATEST_REF, + NO_PATCHES, + WledSource, + load_source_pin, + parse_source_pin, + source_pin_path, +) + + +def _write(payload: object) -> pathlib.Path: + handle = tempfile.NamedTemporaryFile( + "w", suffix=".json", delete=False, encoding="utf-8" + ) + with handle: + json.dump(payload, handle) + return pathlib.Path(handle.name) + + +class SourcePinFileTests(unittest.TestCase): + """The committed pin is what both workflows build from.""" + + def test_the_committed_pin_parses(self): + pin = load_source_pin() + self.assertTrue(pin.ref) + + def test_the_committed_pin_names_a_tag_rather_than_a_branch(self): + # A branch would put the pin back where it started: the tree moves and + # nothing here records that it did. 'latest' is allowed but has to be + # chosen, and is not what this repository ships pinned to. + ref = load_source_pin().ref + self.assertNotIn("/", ref, "the pin should be a tag, not a branch or a path") + self.assertNotEqual(ref, LATEST_REF, "shipping unpinned defeats the pin") + + def test_the_pin_lives_where_the_workflows_look_for_it(self): + self.assertTrue(source_pin_path().is_file()) + + def test_the_readme_tells_people_to_check_out_the_pinned_tag(self): + # The quick start hands out a clone command with the tag in it. A + # reader following a stale one builds against a WLED these profiles + # were never tested on -- and finds out at the compiler, or later. + readme = (pathlib.Path(__file__).resolve().parents[1] / "readme.md").read_text( + encoding="utf-8" + ) + self.assertIn( + f"git clone --branch {load_source_pin().ref} " + "https://github.com/wled/WLED.git", + readme, + ) + + +class ParseSourcePinTests(unittest.TestCase): + def test_patch_prs_are_normalised_for_the_cli(self): + pin = parse_source_pin({"ref": "v16.0.1", "patch_prs": [5521, 5533]}) + self.assertEqual(pin, WledSource(ref="v16.0.1", patch_prs="5521,5533")) + + def test_patch_prs_may_be_absent_or_empty(self): + for payload in ({"ref": "v16.0.1"}, {"ref": "v16.0.1", "patch_prs": []}): + with self.subTest(payload=payload): + self.assertEqual(parse_source_pin(payload).patch_prs, "") + + def test_notes_are_prose_and_not_a_typo(self): + # JSON has nowhere else to record why a pull request is pinned. + pin = parse_source_pin({"ref": "v16.0.1", "notes": ["why 5521 is here"]}) + self.assertEqual(pin.ref, "v16.0.1") + + def test_an_unknown_key_is_a_typo_rather_than_a_setting(self): + # A misspelled key would otherwise be ignored in silence, and the build + # would quietly use the default it was meant to change. + with self.assertRaisesRegex(ValueError, "unknown key"): + parse_source_pin({"ref": "v16.0.1", "patch_pr": [5521]}) + + def test_a_missing_or_empty_ref_fails(self): + for payload in ({}, {"ref": ""}, {"ref": " "}, {"ref": 16}): + with self.subTest(payload=payload): + with self.assertRaisesRegex(ValueError, "'ref' must be"): + parse_source_pin(payload) + + def test_patch_prs_must_be_pull_request_numbers(self): + for entry in ("5521", -1, 0, True, None): + with self.subTest(entry=entry): + with self.assertRaisesRegex(ValueError, "not a pull request number"): + parse_source_pin({"ref": "v16.0.1", "patch_prs": [entry]}) + + def test_a_non_object_payload_fails(self): + with self.assertRaisesRegex(ValueError, "must contain a JSON object"): + parse_source_pin([5521]) + + +class LoadSourcePinTests(unittest.TestCase): + def test_a_missing_pin_names_the_file_it_wants(self): + missing = pathlib.Path(tempfile.gettempdir()) / "no_such_wled_source.json" + with self.assertRaisesRegex(FileNotFoundError, "no_such_wled_source.json"): + load_source_pin(missing) + + def test_malformed_json_fails_with_the_path(self): + broken = _write("") + broken.write_text("{not json", encoding="utf-8") + try: + with self.assertRaisesRegex(ValueError, "not valid JSON"): + load_source_pin(broken) + finally: + broken.unlink() + + +class ResolutionPrecedenceTests(unittest.TestCase): + """An input overrides the pin; a blank input is not an input.""" + + def test_an_explicit_ref_wins_over_the_pin(self): + self.assertEqual( + resolve_wled_ref("v16.0.0", pinned_ref="v16.0.1"), "v16.0.0" + ) + + def test_a_blank_ref_falls_through_to_the_pin(self): + # This is the case that matters: the workflow always passes the input, + # and it is empty on every run nobody filled it in. + for blank in ("", " "): + with self.subTest(blank=repr(blank)): + self.assertEqual( + resolve_wled_ref(blank, pinned_ref="v16.0.1"), "v16.0.1" + ) + + def test_an_explicit_patch_list_wins_over_the_pin(self): + self.assertEqual(resolve_pr_numbers("5533", pinned="5521"), [5533]) + + def test_a_blank_patch_list_falls_through_to_the_pin(self): + for blank in ("", " "): + with self.subTest(blank=repr(blank)): + self.assertEqual(resolve_pr_numbers(blank, pinned="5521"), [5521]) + + def test_building_unpatched_has_to_be_said_out_loud(self): + # Blank means "not specified", so "no patches" needs a word of its own + # -- otherwise the two are indistinguishable and #5521 goes missing + # without anything failing. + for spelling in (NO_PATCHES, "NONE", " none "): + with self.subTest(spelling=spelling): + self.assertEqual(resolve_pr_numbers(spelling, pinned="5521"), []) + + def test_latest_is_still_reachable_from_the_pin_and_the_input(self): + # Not exercised against the network here -- resolve_wled_ref would call + # the GitHub API. What is asserted is that both routes agree on the + # sentinel, so the escape hatch cannot rot. + self.assertEqual(LATEST_REF, "latest") + self.assertEqual( + parse_source_pin({"ref": LATEST_REF}).ref, LATEST_REF + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/wled_source.json b/wled_source.json new file mode 100644 index 0000000..3ebc954 --- /dev/null +++ b/wled_source.json @@ -0,0 +1,24 @@ +{ + "ref": "v16.0.1", + "patch_prs": [5521], + "notes": [ + "The WLED sources a RaceLink firmware image is built from. Both workflows", + "read this file, so the compile-only build on a pull request rehearses the", + "exact tree a release would ship -- which it does not do when the upstream", + "ref is resolved fresh on every run.", + "", + "ref: a wled/WLED tag, or the string 'latest' to resolve the newest", + "published upstream release at build time. A pinned tag is the point: WLED", + "17 switches the shared esp32/esp32s2/esp32s3/esp32c3 build sections from", + "ESP-IDF 4.4 to 5.5, and every RaceLink profile inherits from them. With", + "'latest' that toolchain jump would land in a release without a commit here.", + "", + "patch_prs: upstream pull requests applied on top of that tag, in order.", + " 5521 -- 'Integrate um_data structure to battery usermod'. Still open.", + " Without it usermods/Battery exposes no um_data, so the RaceLink", + " usermod's getUMData(USERMOD_ID_BATTERY) never succeeds and the", + " node reports no battery state -- silently, since the lookup is", + " retried rather than failed. Watch wled/WLED#5399, which rewrites", + " the same file: once it merges this patch stops applying." + ] +}