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
16 changes: 10 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
45 changes: 43 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 31 additions & 2 deletions scripts/apply_wled_patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"

Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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
Expand Down
56 changes: 43 additions & 13 deletions scripts/resolve_wled_release.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -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


Expand Down
94 changes: 94 additions & 0 deletions scripts/wled_source.py
Original file line number Diff line number Diff line change
@@ -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)
Loading