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
64 changes: 61 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ on:
- 'CMakeLists.txt'
- 'library.json'
- '.github/workflows/release.yml'
# The web installer + landing page are served from Pages by the deploy-pages job
# below; a change to them must trigger a deploy or it never reaches the live site
# (the eth-only-provisioning fix shipped a commit that didn't auto-deploy because
# docs/install was missing here). src/ui/install-picker*.js is already covered by src/**.
- 'docs/install/**'
- 'docs/landing/**'
workflow_dispatch:
inputs:
tag:
Expand Down Expand Up @@ -102,6 +108,10 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
# Full history: compute_version.py counts commits since the last v* tag
# for the `latest` build's `-dev.<N>` suffix. A shallow clone (the default)
# has no tags / partial history and would yield a wrong count.
fetch-depth: 0

- name: Cache ESP-IDF tooling
uses: actions/cache@v4
Expand Down Expand Up @@ -129,6 +139,27 @@ jobs:
elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT"
else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi

# The semver burned into the binary + stamped on the assets/manifest. A
# `latest` build gets `<core>-dev.<N>` (N = commits since the last v* tag)
# so successive latest builds are orderable; a stable tag gets the core.
# Computed once here and reused by build + staging so all three agree.
- name: Compute version
id: ver
# The channel (latest vs stable) and the -rc handling both live in
# compute_version.py — pass only the tag, the helper derives the rest, so
# this step and the release job's identical step can't disagree. Raw
# `python` (not `uv run`): this job has no setup-uv (the ESP-IDF docker
# action provides Python) and the script is stdlib-only.
# Tag passed via env (not inline ${{ }}) so it reaches the script as a
# plain shell variable, never spliced into the command text — no shell
# injection from a crafted tag/ref.
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(python scripts/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"

- name: Build firmware
uses: espressif/esp-idf-ci-action@v1
with:
Expand All @@ -150,13 +181,13 @@ jobs:
# We run our own builder (not the action's default `idf.py build`)
# so the sdkconfig fragments and EXCLUDE_COMPONENTS go through the
# same code path as local builds. --release burns the channel tag in.
command: python ../scripts/build/build_esp32.py --firmware ${{ matrix.firmware }} --release "${{ steps.tag.outputs.tag }}"
command: python ../scripts/build/build_esp32.py --firmware ${{ matrix.firmware }} --release "${{ steps.tag.outputs.tag }}" --version "${{ steps.ver.outputs.version }}"

- name: Stage release artifacts
run: |
set -euo pipefail
mkdir -p dist
V=$(jq -r .version library.json)
V="${{ steps.ver.outputs.version }}" # computed once above; matches the binary's MM_VERSION
# Per-firmware build dir under build/esp32-<firmware>/ (plan-19.1).
# build_esp32.py points idf.py at this dir via -B, so the build
# tree lives outside esp32/ and multiple firmwares can coexist —
Expand Down Expand Up @@ -242,6 +273,10 @@ jobs:
# the "Re-create latest" step below force-pushes the `latest` tag with git,
# which needs the token in .git/config.
- uses: actions/checkout@v4
with:
# Full history: compute_version.py counts commits since the last v* tag
# for the manifest's `-dev.<N>` version (must match the binary's).
fetch-depth: 0

- uses: astral-sh/setup-uv@v3

Expand Down Expand Up @@ -272,13 +307,28 @@ jobs:
echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"
fi

# Same computation as the build job's "Compute version" — the manifest's
# version must match the binary's MM_VERSION + the asset names. Channel +
# -rc handling live in compute_version.py; pass only the tag (this job has
# setup-uv, so `uv run`).
- name: Compute version
id: ver
# Tag via env (not inline ${{ }}) to keep it out of the command text —
# no shell injection from a crafted tag/ref.
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(uv run python scripts/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"

- name: Generate ESP Web Tools manifests (release-asset URLs)
env:
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
V=$(jq -r .version library.json)
V="${{ steps.ver.outputs.version }}" # computed once above; matches binary + asset names
# Absolute GitHub release-asset URLs. Uploaded as release assets;
# read by the on-device OTA picker (device fetches the .bin directly
# — no CORS). The Pages-relative manifests are generated in the
Expand Down Expand Up @@ -342,6 +392,14 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
# Release `name` is the computed semver (e.g. "2.1.0-dev.7"). The device-
# hosted UI's dev-channel update check reads it from the CORS-readable
# GitHub API (releases/tags/latest) — the manifest-*.json asset that also
# carries the version is fetched via a release-asset URL that redirects to
# release-assets.githubusercontent.com, which sends no CORS header, so the
# browser blocks that read from the device origin. The API exposes `name`
# cross-origin, so surfacing the version here is what makes the badge work.
name: ${{ steps.ver.outputs.version }}
# latest and vX.Y.Z-rcN tags are prerelease — they sort below stable
# on the Releases page and aren't picked up by tooling that asks for
# "latest release". Stable vX.Y.Z tags publish normally.
Expand Down
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ See `docs/architecture.md` for system design. This file contains only rules and
- **Default to subtraction.** The reflex on most changes (a bug fix, a review finding, a refactor) should be *can this remove or replace code, or land net-neutral?*, not *what do I add?* If a change only ever grows the line count and the doc count, that's the smell this rule exists to catch. Prefer removing code over adding it; a deletion that preserves behaviour is the best kind of change.
- **Continuous refactor, no hacks.** Improvement is not a scheduled phase; it happens *the moment* a hack, a divergence, or a duplicated pattern is spotted, in whatever change is already open. The bar is absolute: **never** leave a hack, a workaround, or a bespoke one-off in place because "it works for now" — the fix is the *recognisable, standard* one. So when you reach for a clever shortcut, an environment sniff, a duplicated block, a stub that papers over a broken dependency, stop and ask *what's the textbook construct here?* and do that instead. This is the union of three principles applied as a working reflex rather than a checklist: *[Common patterns first](#principles)* (use the construct a new contributor recognises in 30s), *[Industry standards, our own code](#principles)* (the textbook algorithm AND the textbook name, written fresh against our architecture), and *[Minimalism means elegance](#principles)* (consistency, reuse, no duplication, the fast hot path). What this bullet adds over those: the **timing** (on sight, continuously, not deferred to a "cleanup later" that never comes) and the **no-hacks floor** (a workaround is never the destination; if the standard fix is genuinely out of scope right now, the hack doesn't ship — it's backlogged with the standard fix named, per *[Mandatory subtraction](#process-rules)*). The product-owner-initiated counterpart, for larger restructures, is the *[Refactor for simplicity](#process-rules)* process rule; this principle is the small-scale, agent-initiated version of the same instinct.
- **No duplication, in code or docs.** Same logic in two places belongs in one shared function; same fact in two docs belongs in one place the other links to. A comment or doc paragraph that restates what the code already says is duplication too; delete it. (Reuse a recognisable shape rather than inventing one; see *Common patterns first* above.)
- **Document a thing once, reference it generically.** A module lives in one home (its `.h` + one `docs/moonmodules/*.md`), its registration, and its tests. Don't name it elsewhere: in other prose say "a modifier"/"a driver", not `FooModifier`, and don't re-explain what it does — the reader studies its spec. Naming a thing across unrelated files multiplies rename cost and teaches nothing a link wouldn't. *No duplication* applied to names.
- **Data over objects in the hot path.** This is minimalism's hot-path corollary — the same "minimal memory, fastest hot path" test (see *Minimalism means elegance*), applied where speed and memory matter most and resolved to one answer: design around plain contiguous data, not an object graph. A flat buffer of elements that one stage writes and the next stage reads, following the producer/consumer data flow in [docs/architecture.md](docs/architecture.md). A contiguous buffer is cache-friendly and lets a stage do integer math straight on the array, whereas per-element objects with virtual accessors are cache-hostile and allocation-heavy, exactly what the hot-path rules forbid. So in the render loop: no object graph, no inheritance, don't wrap buffer data in objects. The **one deliberate class hierarchy** is the module tree (one `MoonModule` base, shallow subclasses, a single virtual-dispatch boundary), because uniform polymorphism is what lets the UI render any module generically with zero per-module UI code. **Outside the hot path**, a small *recognizable* adapter interface with a couple of virtuals is allowed when it passes the *Common patterns first* test — e.g. `ListSource` is the textbook data-source/adapter shape (UITableView's data source, Qt's `QAbstractItemModel`): the view is generic, the rows stay with their owner. That is not "adding inheritance" in the sense this rule forbids; a *bespoke* hierarchy outside the module tree still is. The line: hot-path data is flat and object-free, period; off the hot path, prefer flat data but a proven adapter interface beats a hand-rolled callback table when it's more consistent and reusable.
- **Concrete first, abstract later.** Build one working feature end-to-end before extracting patterns into shared abstractions. Don't build the framework before the domain logic works.
- **Robust to any input.** A running device tolerates any sequence of UI actions or API calls: add, delete, replace, or reconfigure any module in any order, at any grid size, and it keeps running. Degraded or idle is acceptable; crashed is not. This robustness is a defining strongpoint of projectMM, and it's guarded by the test framework, not by hope: a discovered crash drives a new test that pins the fix (see the Hard Rule). Out of scope: power loss, malformed OTA, brown-out, and other physical/electrical faults the firmware can't intercept; this principle is about what the software accepts as input.
Expand Down Expand Up @@ -178,7 +179,7 @@ The "end users will use this" moment. Per-release criteria are defined by the pr

5. **Changelog / release notes**: drafted in the GitHub release body. Skip only for unreleased pre-1.0 tags.
6. **Cross-platform smoke**: run scenarios on every supported platform (today: PC + ESP32; later: + Teensy, RPi), if the release claims new platform support or the version bumps a major or minor.
7. **Principles audit**: sweep `docs/` (except `docs/backlog/` and `docs/history/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/backlog.md` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty.
7. **Principles audit**: sweep `docs/` (except `docs/backlog/` and `docs/history/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty.

What the agent reads:
- Always: `CLAUDE.md`, `architecture.md`
Expand All @@ -196,8 +197,10 @@ docs/
testing.md ← test inventory and strategy
performance.md ← per-module timing, memory, sizeof for each platform
backlog/ ← forward-looking: what to build next (not present-tense)
README.md ← index: what's here (to-build list + design studies + in-flight draft specs)
backlog.md ← the prioritised to-build list
README.md ← landing page: overview of every item + index (the rest of the system links here, not into items)
backlog-core.md ← to-build list, core / infrastructure domain (+ UI)
backlog-light.md ← to-build list, light domain (drivers, effects, preview, sensors)
backlog-mixed.md ← to-build list, items spanning both domains
history/ ← backward-looking: accumulated wisdom
README.md ← index: what's here + cross-repo trends + digest prompt
decisions.md ← actions, lessons, proven patterns
Expand All @@ -220,7 +223,7 @@ Do **not** repeat facts the `.h` already states: the controls list (the .h has `

The `history/` folder is the distilled experience of years of building LED/light systems, from WLED, WLED-MM, StarLight, MoonLight, through projectMM. It contains proven patterns, memory tricks, control mechanisms, and hard-won lessons, studied under the [*Industry standards, our own code*](#principles) principle. Per-project credits live in the `history/` digests and the per-module "Prior art" sections.

The `backlog/` folder is its forward-looking counterpart: `backlog.md` is the prioritised to-build list, design studies sit alongside it, and a spec for a not-yet-built module can live here as a plain draft `.md` until it ships (its final spec then goes to `moonmodules/` and the draft is deleted). Both `history/` and `backlog/` are exempt from the present-tense rule and agents don't read them automatically; only when planning new work. Neither folder only accumulates: per [*Mandatory subtraction*](#process-rules), both shrink as well — shipped backlog items and absorbed history entries are deleted, since the git commits are the permanent record and these folders are just the working narrative above it.
The `backlog/` folder is its forward-looking counterpart: the to-build list is split by domain (`backlog-core.md` / `backlog-light.md` / `backlog-mixed.md`) with `README.md` as the landing page the rest of the docs link to, design studies sit alongside it, and a spec for a not-yet-built module can live here as a plain draft `.md` until it ships (its final spec then goes to `moonmodules/` and the draft is deleted). Both `history/` and `backlog/` are exempt from the present-tense rule and agents don't read them automatically; only when planning new work. Neither folder only accumulates: per [*Mandatory subtraction*](#process-rules), both shrink as well — shipped backlog items and absorbed history entries are deleted, since the git commits are the permanent record and these folders are just the working narrative above it.

## Code Style

Expand Down
Loading
Loading