Skip to content
Open
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
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@ Desktop.ini
build-*/
node_modules/
target/
_build/
56 changes: 31 additions & 25 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 detect_backend(source_dir: Path) -> str:
"""Auto-detect the build system from project files.
Expand Down Expand Up @@ -100,7 +124,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 @@ -121,23 +145,11 @@ def configure(
elif backend == "cargo":
pass # Cargo does not have a separate configure step

elif backend in ("make", "kbuild", "ninja"):
elif backend in ("make", "kbuild"):
pass # No separate configure step

else:
raise ValueError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)

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 invoked directly and requires 'targets' in "
"build.yaml -- add targets or choose another backend."
)
raise _unknown_backend(backend, "configured")

def build(
self,
Expand All @@ -154,7 +166,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 @@ -185,10 +197,7 @@ def build(
_run_or_log(cmd, dry_run)

else:
raise ValueError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)
raise _unknown_backend(backend, "built")

def clean(
self,
Expand All @@ -203,7 +212,7 @@ def clean(
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.
"""
if backend == "cmake":
_run_or_log(
Expand Down Expand Up @@ -237,7 +246,4 @@ def clean(
check=False,
)
else:
raise ValueError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)
raise _unknown_backend(backend, "cleaned")
54 changes: 46 additions & 8 deletions ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ class PackagePaths:
"-fno-pie", "-fno-PIE"}


def _ninja_path(path) -> str:
"""Escape *path* for use in a Ninja build statement.

Ninja splits build statements on unescaped spaces and colons, so a Windows
absolute path writes a drive letter that Ninja reads as the output/rule
separator:

build C:\\...\\main.o: cc main.c
^ "expected build command name"

`$` is escaped first so the escapes introduced below are not re-escaped.
Only build statements need this; variable values (cflags, ldflags) are read
to end of line and must not be escaped, or the flags reach the compiler
mangled.
"""
text = str(path)
return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ")


class NinjaBackend:
"""Generate build.ninja from a ProjectConfig and resolved toolchain.

Expand Down Expand Up @@ -104,6 +123,22 @@ def _resolve_target_cflags(self, target) -> List[str]:

return cflags

def _object_path(self, target, src: str) -> Path:
"""Object file path for *src* as compiled by *target*.

Object paths are namespaced by target name. Two targets may legitimately
list the same source: a library and a test binary sharing a helper, or
one source built twice with different defines. Each needs its own
object, because each compiles with its own cflags. Keying only on the
source made both targets claim one output, which ninja rejects with
"multiple rules generate ...".

Example:
>>> backend._object_path(target, "src/main.c") # target.name == "app"
PosixPath('_build/obj/app/src/main.o')
"""
return (self.build_dir / "obj" / target.name / src).with_suffix(".o")

def _write_ninja(self) -> None:
"""Write the build.ninja file."""
ninja_path = self.build_dir / "build.ninja"
Expand All @@ -123,10 +158,6 @@ def _write_ninja(self) -> None:
" command = $cc $ldflags $in -o $out $libs",
" description = LINK $out",
"",
"rule link_shared",
" command = $cc -shared $ldflags $in -o $out $libs",
" description = LINK_SHARED $out",
"",
"rule ar_rule",
" command = $ar rcs $out $in",
" description = AR $out",
Expand All @@ -143,7 +174,7 @@ def _write_ninja(self) -> None:
obj = str(self._object_path(target, src))
obj_files.append(obj)
lines.append(
f"build {obj}: cc {src}"
f"build {_ninja_path(obj)}: cc {_ninja_path(src)}"
)
if cflags:
lines.append(f" cflags = {' '.join(cflags)}")
Expand All @@ -170,7 +201,8 @@ def _write_ninja(self) -> None:
link_inputs = obj_files + dep_archives
out = str(self.build_dir / target.name)
lines.append(
f"build {out}: link {' '.join(link_inputs)}"
f"build {_ninja_path(out)}: link "
f"{' '.join(_ninja_path(i) for i in link_inputs)}"
)
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
Expand All @@ -188,7 +220,10 @@ def _write_ninja(self) -> None:
out = str(self.build_dir / f"lib{target.name}{ext}")

if target.target_type == "static_library":
lines.append(f"build {out}: ar_rule {' '.join(obj_files)}")
lines.append(
f"build {_ninja_path(out)}: ar_rule "
f"{' '.join(_ninja_path(o) for o in obj_files)}"
)
else:
# Shared libraries need the platform's "build a shared
# object" flag and the same -L/-l wiring executables get,
Expand All @@ -204,7 +239,10 @@ def _write_ninja(self) -> None:
for lib in pkg.libraries:
libs.append(f"-l{lib}")

lines.append(f"build {out}: link {' '.join(obj_files)}")
lines.append(
f"build {_ninja_path(out)}: link "
f"{' '.join(_ninja_path(o) for o in obj_files)}"
)
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
if libs:
Expand Down
14 changes: 12 additions & 2 deletions ebuild/packages/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ def fetch(self, recipe: PackageRecipe, extract_to: str | Path) -> Path:
Raises:
FetchError: If download or verification fails.
"""
# PackageRecipe.validate() rejects a recipe without a checksum, but
# fetch() is reachable with a hand-built recipe too, so refuse here as
# well rather than falling through to an unverified extract.
if not recipe.checksum:
raise FetchError(
f"Refusing to fetch {recipe.name} v{recipe.version}: the recipe "
f"carries no checksum, so there is nothing to verify the "
f"download against."
)

archive_path = self._download(recipe)
if recipe.checksum:
try:
Expand All @@ -66,10 +76,10 @@ def _download(self, recipe: PackageRecipe) -> Path:
"""Download the source archive if not already cached."""
if not recipe.url:
raise FetchError(f"No URL specified for package {recipe.name}")
if not recipe.url.startswith(("http://", "https://")):
if not recipe.url.startswith("https://"):
raise FetchError(
f"Invalid URL scheme for {recipe.name}: {recipe.url} "
f"(only http:// and https:// are allowed)"
f"(only https:// is allowed)"
)

archive_path = self._archive_path(recipe)
Expand Down
30 changes: 30 additions & 0 deletions ebuild/packages/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from pathlib import Path
from typing import Any, Dict, List, Optional

import re

import yaml


Expand All @@ -39,6 +41,9 @@ class PackageRecipe:

VALID_BUILD_SYSTEMS = ("cmake", "autoconf", "make", "meson", "custom")

#: A bare SHA-256 digest, with or without the "sha256:" prefix.
_SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$")

@property
def slug(self) -> str:
"""Unique identifier: name-version."""
Expand All @@ -52,6 +57,31 @@ def validate(self) -> None:
raise RecipeError(f"Package '{self.name}' must have a 'version' field.")
if not self.url:
raise RecipeError(f"Package '{self.name}' must have a 'url' field.")

# A recipe with a checksum is a pin: a URL plus the digest of exactly
# what should be at it. The digest is not required here -- a recipe is
# also used to model packages that are never downloaded -- but a
# checksum that is present has to be a real one. "sha256:placeholder"
# parsed fine and then failed every single fetch with a mismatch, which
# is how two shipped recipes stayed unfetchable. PackageFetcher.fetch()
# separately refuses to download anything with no checksum at all.
if self.checksum and not self._SHA256_RE.match(self.checksum):
raise RecipeError(
f"Package '{self.name}': checksum '{self.checksum}' is not a "
f"sha256 digest. Expected 64 hex characters, optionally "
f"prefixed with 'sha256:'. Placeholder values are rejected — "
f"they turn every fetch of this package into a checksum "
f"mismatch."
)

# Plaintext HTTP defeats the pin's purpose in the common case where a
# recipe is edited without recomputing the digest, and it leaks what is
# being built. Every shipped recipe already uses https.
if self.url.startswith("http://"):
raise RecipeError(
f"Package '{self.name}': plaintext http:// is not accepted for "
f"'{self.url}'. Use https://."
)
if self.build_system not in self.VALID_BUILD_SYSTEMS:
raise RecipeError(
f"Package '{self.name}': invalid build system '{self.build_system}'. "
Expand Down
2 changes: 1 addition & 1 deletion recipes/freertos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ version: "11.1.0"
description: "Real-time operating system kernel for embedded devices"
license: MIT
url: https://github.com/FreeRTOS/FreeRTOS-Kernel/releases/download/V11.1.0/FreeRTOS-KernelV11.1.0.zip
checksum: sha256:e36e5a2fcef99b83e8adbb8f8d5e4181a42c9ff0b604dc6fa7aad2a2ef0e3140
checksum: sha256:eebd58aa71a623c9381f25f77b708c0ed14ef995a8913e2460fe9f286bb271eb
build: cmake
configure_args:
- -DFREERTOS_HEAP=4
Expand Down
2 changes: 1 addition & 1 deletion recipes/littlefs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ version: "2.9.3"
description: "Little fail-safe filesystem designed for microcontrollers"
license: BSD-3-Clause
url: https://github.com/littlefs-project/littlefs/archive/refs/tags/v2.9.3.tar.gz
checksum: sha256:placeholder
checksum: sha256:9cf2e7db673ea27d967a54cdafe8f55a7ffe27c63a2070ff7424fadd559cad67
build: make
build_args:
- CC=$(CROSS_COMPILE)gcc
Expand Down
2 changes: 1 addition & 1 deletion recipes/lwip.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ version: "2.2.0"
description: "Lightweight TCP/IP stack for embedded systems"
license: BSD-3-Clause
url: https://download.savannah.nongnu.org/releases/lwip/lwip-2.2.0.zip
checksum: sha256:placeholder
checksum: sha256:c79255f6cb550eaa07d6e90d859b8c1abe81658115ae8175e74b67ac22c7ed87
build: cmake
configure_args:
- -DLWIP_DIR=${SOURCE_DIR}
Expand Down
2 changes: 1 addition & 1 deletion recipes/mbedtls.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ version: "3.6.0"
description: "Lightweight TLS/SSL library for embedded systems"
license: Apache-2.0
url: https://github.com/Mbed-TLS/mbedtls/releases/download/v3.6.0/mbedtls-3.6.0.tar.bz2
checksum: sha256:3ecf94fcfdaacafb757786a01b7538a61750ebd85c4b024f56ff8ba1490fcd73
checksum: sha256:3ecf94fcfdaacafb757786a01b7538a61750ebd85c4b024f56ff8ba1490fcd38
build: cmake
configure_args:
- -DENABLE_TESTING=OFF
Expand Down
Loading
Loading