Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b0a6422
fix: restore the package import and reconcile the merged backend beha…
Kartikey1306 Aug 28, 2026
e6cb4f9
ci: make the Windows leg of the test matrix able to run at all
Kartikey1306 Aug 28, 2026
49cd368
fix(ninja): escape paths in build statements so Windows builds are valid
Kartikey1306 Aug 28, 2026
842ca8d
ci: add a concurrency group so superseded runs stop holding runners
Kartikey1306 Aug 28, 2026
14928ab
ci: move the test matrix off the retired macos-13 runner image
Kartikey1306 Aug 28, 2026
53ca57f
fix(packages): make the third-party supply chain verifiable, and its …
Kartikey1306 Aug 28, 2026
73e4f43
chore: drop an accidentally committed _build/ directory
Kartikey1306 Aug 28, 2026
7a57abd
Fix string termination in node name assignment
Akhil498-coder Aug 28, 2026
9d5831e
Implement test for max-length node name in graph
Akhil498-coder Aug 28, 2026
8e52709
Initialize node name with non-zero data in test
Akhil498-coder Aug 28, 2026
e9a719c
config: reject a non-list packages section instead of dropping it
JoaoMorais03 Aug 29, 2026
527c0bb
fix(deps): resolve sibling eBoot as well as eboot
ShrenikMensinkai Aug 29, 2026
a260ac2
Merge branch 'e68' into HEAD
allanturner1234 Aug 29, 2026
57fff5b
Merge branch 'e69' into HEAD
allanturner1234 Aug 29, 2026
4a307b2
Merge branch 'e73' into HEAD
allanturner1234 Aug 29, 2026
996a028
Merge branch 'e76' into HEAD
allanturner1234 Aug 29, 2026
d844ebf
Merge branch 'e63' into HEAD
allanturner1234 Aug 29, 2026
23dc8c1
feat(build): report flash and RAM footprint after a build
srpatcha Aug 29, 2026
4e900af
feat(cli): add `ebuild doctor` — one-command environment diagnosis
srpatcha Aug 29, 2026
3a46f1e
feat(cli): refuse packages that cannot resolve, and summarise what th…
srpatcha Aug 29, 2026
9693375
feat(cli): add `ebuild package` — assemble the eFirmware .efw image
srpatcha Aug 30, 2026
0bf65de
Merge branch 'master' into feat/package-efw
srpatcha Sep 1, 2026
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
28 changes: 26 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ on:
pull_request:
branches: [master, main]

# Every other workflow in this repo declares a concurrency group; ci.yml,
# the heaviest one, did not. Pushing twice to a PR left the earlier run
# queued, and both competed for the same scarce windows/macos runners --
# three superseded runs sat ahead of the current one for over an hour.
# cancel-in-progress because a superseded commit's result is not wanted.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
# ── Test Matrix ───────────────────────────────────────────────────────────
test:
Expand All @@ -17,7 +26,12 @@ jobs:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
os: [ubuntu-22.04, macos-13, windows-2022]
# macos-latest, not macos-13: the macos-13 image is retired, so jobs
# requesting it queue forever and never get a runner. Across four runs
# on this branch the ubuntu-22.04 and windows-2022 jobs all started and
# finished while the three macos-13 jobs sat queued for over two hours.
# Every other workflow in this repo already uses macos-latest.
os: [ubuntu-22.04, macos-latest, windows-2022]
fail-fast: false

steps:
Expand All @@ -42,8 +56,12 @@ jobs:
run: ruff check . --select=E,F,W --ignore=E501
continue-on-error: true

# --exclude: layers/eosuite/ vendors its own tests/ package, so a bare
# `mypy .` sees two modules named "tests" and bails with "Duplicate
# module named 'tests'" before checking anything. continue-on-error hid
# that the type check was doing no work at all.
- name: Type check (mypy)
run: mypy . --ignore-missing-imports --no-strict-optional
run: mypy . --ignore-missing-imports --no-strict-optional --exclude '^layers/'
continue-on-error: true

# Runs the whole tests/ tree. The previous steps ran only tests/unit/
Expand All @@ -57,7 +75,13 @@ jobs:
# coverage policy already lives in codecov.yml; whether to also
# enforce it here is a maintainer decision, so this change leaves
# both of those numbers alone.
#
# shell: bash — the matrix includes windows-2022, where the default
# shell is PowerShell and the backslash line continuations below are a
# syntax error ("Missing expression after unary operator '--'"), so the
# Windows leg of this job failed before pytest ever started.
- name: Run test suite
shell: bash
run: |
python -m pytest tests/ -v --tb=short \
--cov=ebuild --cov-report=xml --cov-report=term-missing \
Expand Down
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,4 @@ Desktop.ini
build-*/
node_modules/
target/

# ebuild build output (the default --build-dir)
_build/
1 change: 1 addition & 0 deletions core/eos/core/src/graph.c
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ EosResult eos_graph_add_node(EosGraph *g, const char *name, EosNodeType type,
int id = g->node_count;
EosNode *n = &g->nodes[id];
strncpy(n->name, name, EOS_MAX_NAME - 1);
n->name[EOS_MAX_NAME-1] = '\0';
n->type = type;
n->build_type = build_type;
n->status = EOS_NODE_PENDING;
Expand Down
40 changes: 40 additions & 0 deletions core/eos/tests/test_graph.c
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,46 @@ static void test_graph_add_nodes(void) {

ASSERT(g.node_count == 3, "graph has 3 nodes");
}
static void test_graph_max_length_node_name(void) {
printf("test_graph_max_length_node_name:\n");

EosGraph g;
eos_graph_init(&g);

char name[EOS_MAX_NAME];

/* Create a name that fills the buffer except for the null terminator. */
memset(name, 'A', EOS_MAX_NAME - 1);
name[EOS_MAX_NAME - 1] = '\0';

/*
* Fill the destination with non-zero data so the test can detect
* a missing null terminator even though eos_graph_init() zeroes
* the graph initially.
*/
memset(g.nodes[0].name, 'X', EOS_MAX_NAME);

int id;
EosResult r = eos_graph_add_node(
&g,
name,
EOS_NODE_PACKAGE,
EOS_BUILD_CMAKE,
&id
);

ASSERT(r == EOS_OK, "maximum-length node name can be added");

ASSERT(
g.nodes[id].name[EOS_MAX_NAME - 1] == '\0',
"node name is null terminated"
);

ASSERT(
eos_graph_find_node(&g, name) == id,
"maximum-length node name can be found"
);
}
static void test_graph_find_node(void) {
printf("test_graph_find_node:\n");
EosGraph g;
Expand Down Expand Up @@ -168,6 +207,7 @@ int main(void) {
test_graph_init();
test_graph_add_nodes();
test_graph_find_node();
test_graph_max_length_node_name();
test_topological_sort_linear();
test_topological_sort_diamond();
test_cycle_detection();
Expand Down
51 changes: 30 additions & 21 deletions ebuild/build/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@

ALL_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"}

#: Backends this dispatcher actually drives. "ninja" is ebuild's own backend --
#: the CLI invokes NinjaBackend directly and never routes it through here.
DISPATCHED_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild"}


class UnknownBackendError(ValueError, RuntimeError):
"""Raised when a backend reaches the dispatcher that it cannot drive.

Subclasses both ValueError and RuntimeError: callers treat an unrecognized
backend name as a bad argument, while the CLI treats a backend it failed to
route (notably "ninja") as a routing failure. Silently doing nothing here is
what made `ebuild build` report "Build completed successfully" without ever
running a compiler.
"""


def _unknown_backend(backend: str, step: str) -> UnknownBackendError:
return UnknownBackendError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(DISPATCHED_BACKENDS))}. "
"ebuild's own 'ninja' backend is invoked directly by the CLI and is "
f"not dispatched here, so it cannot be {step} through BackendDispatcher."
)


def ninja_command():
"""Return the argv prefix that runs ninja on this machine.
Expand Down Expand Up @@ -117,7 +141,7 @@ def configure(
dry_run: If True, log commands instead of executing them.

Raises:
ValueError: If the backend is not recognized.
UnknownBackendError: If the backend is not one this dispatcher drives.
"""
config = config or {}
self.build_dir.mkdir(parents=True, exist_ok=True)
Expand All @@ -142,12 +166,7 @@ def configure(
pass # No separate configure step

else:
raise RuntimeError(
f"BackendDispatcher cannot configure backend '{backend}'. "
"This dispatcher only handles cmake, meson, and cargo "
"(make/kbuild need no configure step). ebuild's own ninja "
"backend is generated and invoked by the CLI, not here."
)
raise _unknown_backend(backend, "configured")

def build(
self,
Expand All @@ -164,7 +183,7 @@ def build(
dry_run: If True, log commands instead of executing them.

Raises:
ValueError: If the backend is not recognized.
UnknownBackendError: If the backend is not one this dispatcher drives.
"""
config = config or {}

Expand Down Expand Up @@ -195,12 +214,7 @@ def build(
_run_or_log(cmd, dry_run)

else:
raise RuntimeError(
f"BackendDispatcher cannot build backend '{backend}'. "
"Supported here: cargo, cmake, kbuild, make, meson. ebuild's own "
"ninja backend is generated and invoked by the CLI, not through "
"this dispatcher."
)
raise _unknown_backend(backend, "built")

def clean(
self,
Expand All @@ -215,9 +229,7 @@ def clean(
dry_run: If True, log commands instead of executing them.

Raises:
RuntimeError: If the backend is not recognized. configure() and
build() raise the same type for the same condition, so a
caller can guard all three with one ``except``.
UnknownBackendError: If the backend is not one this dispatcher drives.
"""
if backend == "cmake":
_run_or_log(
Expand Down Expand Up @@ -251,7 +263,4 @@ def clean(
check=False,
)
else:
raise RuntimeError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)
raise _unknown_backend(backend, "cleaned")
149 changes: 149 additions & 0 deletions ebuild/build/firmware_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

"""Assemble an eFirmware `.efw` image from a built artifact.

§29's development-to-device flow ends:

eBuild -> {EoS, eBoot, application} -> eFirmware artifact -> {EoSim, hardware}

Every piece of that existed except the arrow into eFirmware. The
`embeddedos-org/eFirmware` repository implements the image format and ships
`efwtool` to pack, inspect and verify one; nothing in ebuild referenced it, so
a developer had to know the tool existed, build it themselves, and run it by
hand.

This drives `efwtool` rather than re-implementing the header in Python. The
header is a packed C struct with a magic, a version, a size and a SHA-256, and
a second implementation of it in another language is a second thing to keep in
step — the exact failure this repository has been repairing all week. If the
format changes, the tool changes with it and this keeps working.
"""

from __future__ import annotations

import os
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional

#: Where `ebuild setup` caches the sibling repositories.
_EFW_REPO = "efirmware"

#: Built once and reused, inside the clone, so `ebuild setup` stays the only
#: thing that owns ~/.ebuild/repos.
_BUILD_DIRNAME = "_ebuild"


class FirmwareImageError(RuntimeError):
"""Raised when an image cannot be assembled."""


def find_efwtool(repos_dir: Path) -> Optional[Path]:
"""Locate `efwtool`, building the cached eFirmware checkout if needed.

Returns None when the checkout is absent or cannot be built, so the caller
can say what to run rather than failing with a stack trace.
"""
on_path = shutil.which("efwtool")
if on_path:
return Path(on_path)

root = Path(repos_dir) / _EFW_REPO
if not (root / "CMakeLists.txt").is_file():
return None

build_dir = root / _BUILD_DIRNAME
existing = _first_efwtool(build_dir)
if existing:
return existing

if shutil.which("cmake") is None:
return None
try:
subprocess.run(["cmake", "-S", str(root), "-B", str(build_dir)],
check=True, capture_output=True, timeout=600)
subprocess.run(["cmake", "--build", str(build_dir), "-j",
str(os.cpu_count() or 1)],
check=True, capture_output=True, timeout=1800)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
return None
return _first_efwtool(build_dir)


def _first_efwtool(build_dir: Path) -> Optional[Path]:
if not build_dir.is_dir():
return None
for candidate in sorted(build_dir.rglob("efwtool")):
if candidate.is_file() and os.access(candidate, os.X_OK):
return candidate
return None


def pack(efwtool: Path, payload: Path, output: Path, *,
version: str = "0.0.0",
load_addr: Optional[str] = None,
entry_addr: Optional[str] = None) -> None:
"""Wrap ``payload`` in an eFirmware header, writing ``output``."""
if not payload.is_file():
raise FirmwareImageError(f"no artifact to package at {payload}")

argv: List[str] = [str(efwtool), "pack", str(payload), str(output),
"--version", version]
if load_addr:
argv += ["--load", load_addr]
if entry_addr:
argv += ["--entry", entry_addr]

proc = _run(argv, f"packing {payload.name}")
if not output.is_file():
raise FirmwareImageError(
f"efwtool reported success but wrote no image at {output}: "
f"{proc.stdout.strip()[:200]}"
)


def verify(efwtool: Path, image: Path) -> str:
"""Re-read the image through efwtool. Returns its verdict line.

Packing and verifying with the same tool does not prove the format is
right, but it does prove the file on disk parses — which catches a
truncated write, a wrong path, and a payload that never got copied.
"""
return _run([str(efwtool), "verify", str(image)],
f"verifying {image.name}").stdout.strip()


def inspect(efwtool: Path, image: Path) -> str:
return _run([str(efwtool), "inspect", str(image)],
f"inspecting {image.name}").stdout.strip()


def _run(argv: List[str], what: str) -> subprocess.CompletedProcess:
try:
proc = subprocess.run(argv, capture_output=True, text=True, timeout=300)
except (OSError, subprocess.TimeoutExpired) as exc:
raise FirmwareImageError(f"{what} failed: {exc}") from exc
if proc.returncode != 0:
raise FirmwareImageError(
f"{what} failed: {(proc.stderr or proc.stdout).strip()[:300]}"
)
return proc


def missing_tool_message(repos_dir: Path) -> str:
"""What to tell a developer who has no efwtool."""
root = Path(repos_dir) / _EFW_REPO
if not root.is_dir():
return (
"eFirmware is not in the local cache, so no .efw image can be "
"assembled.\n"
" Run 'ebuild setup' to fetch it, or put efwtool on PATH."
)
return (
f"eFirmware is cached at {root} but efwtool could not be built.\n"
" Build it by hand with:\n"
f" cmake -S {root} -B {root / _BUILD_DIRNAME} && "
f"cmake --build {root / _BUILD_DIRNAME}"
)
Loading
Loading