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
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# YAML is linted with yamllint's default new-lines rule (LF). The Windows
# runners check out with core.autocrlf=true, which turned every LF file into
# CRLF and failed the rule on line 1 of the first file -- except a file that
# already carried a stray CR, which autocrlf leaves alone. Pin the encoding
# instead of guessing at the checkout's, so the same bytes are linted on
# every OS.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion .github/workflows/auto-assign.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ jobs:
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
assignees: [context.payload.pull_request.user.login]
});
});
60 changes: 47 additions & 13 deletions .github/workflows/book-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ on:
branches: [master, main]
paths:
- 'docs/book/**'
# The workflow runs on changes to itself, so a toolchain change here
# gets the run that proves it. Before this, the job only fired on
# docs/book/** and a workflow-only PR could not be tested at all.
- '.github/workflows/book-build.yml'
tags:
- 'v*'
pull_request:
paths:
- 'docs/book/**'
- '.github/workflows/book-build.yml'
release:
types: [published, created]
workflow_dispatch:
Expand All @@ -23,6 +28,14 @@ permissions:

env:
PANDOC_VERSION: "3.6.1"
# 3.4.0, not the newest: 3.5.0 (2026-06-28) migrated the default font from
# the `sourcesanspro` package to `sourcesans`, which the runner's
# texlive-fonts-extra (ubuntu-latest, 24.04 at the time of writing) does
# not ship, so 3.5.x fails with "File `sourcesans.sty' not found" -- seen
# on the first run of this change. 3.4.0 is the last release that uses
# sourcesanspro (present in texlive-fonts-extra, installed below) and
# already carries pandoc's default template merged through 3.9.
EISVOGEL_VERSION: "3.4.0"

jobs:
# ═══════════════════════════════════════════════════
Expand Down Expand Up @@ -95,12 +108,23 @@ jobs:
echo "pandoc $(pandoc --version | head -1)"

- name: Install Eisvogel template
# Eisvogel 2.4.0 with pandoc 3.6.1 fails every run with
# "LaTeX Error: Command \chead already defined" (exit 43). The 2.4.0
# template predates pandoc 3.6; the 3.x line merges pandoc's default
# LaTeX template up to 3.6.1 and beyond (its CHANGELOG names the
# merges) and its README states pandoc 3 compatibility. The exact
# LaTeX-level cause of the clash is not established here -- the run
# on the change is what shows whether the pair works. The 3.x
# tarball extracts into a versioned directory, not flat, so the copy
# names it.
# No `|| true` on tar: a missing or reshaped tarball must fail here,
# at the step that broke, not at the copy after it.
run: |
mkdir -p ~/.local/share/pandoc/templates
wget -q https://github.com/Wandmalfarbe/pandoc-latex-template/releases/download/v2.4.0/Eisvogel-2.4.0.tar.gz
tar xzf Eisvogel-2.4.0.tar.gz 2>/dev/null || true
cp eisvogel.latex ~/.local/share/pandoc/templates/eisvogel.latex
echo "Eisvogel template installed"
wget -q https://github.com/Wandmalfarbe/pandoc-latex-template/releases/download/v${EISVOGEL_VERSION}/Eisvogel-${EISVOGEL_VERSION}.tar.gz
tar xzf Eisvogel-${EISVOGEL_VERSION}.tar.gz
cp Eisvogel-${EISVOGEL_VERSION}/eisvogel.latex ~/.local/share/pandoc/templates/eisvogel.latex
echo "Eisvogel ${EISVOGEL_VERSION} template installed"

- name: Clean source
run: |
Expand Down Expand Up @@ -145,6 +169,22 @@ jobs:
CITE_ARGS="--citeproc --bibliography=references.bib"
fi

# Header and footer go through Eisvogel's own variables (header-left,
# header-right, footer-center; footer-right is the template's default
# page number). The template builds them with KOMA's scrlayer-scrpage,
# which defines \chead. This step used to inject \usepackage{fancyhdr}
# and \fancyhead/\fancyfoot through header-includes, which the template
# places BEFORE its own header setup -- so fancyhdr defined \chead first
# and scrlayer-scrpage's \newcommand failed: "Command \chead already
# defined". Two header packages, one command. Every run since both
# arrived in ef0dc50 died there, whatever the Eisvogel or pandoc version.
#
# No -V author=...: a -V value is inserted into the LaTeX verbatim, and
# the author string contains '&', which LaTeX reads as an alignment tab
# ("Misplaced alignment tab character &", run 3 of #135). book.md's
# metadata already carries the same author, and metadata is escaped by
# pandoc. Any -V value must be LaTeX-safe; title/subtitle/date are.

pandoc \
book.md \
-o "${{ steps.meta.outputs.pdf_name }}" \
Expand All @@ -163,7 +203,6 @@ jobs:
-V page-background-color="ffffff" \
-V "title=${{ steps.meta.outputs.title }}" \
-V "subtitle=Version ${{ steps.meta.outputs.version }}" \
-V "author=Srikanth Patchava & EmbeddedOS Contributors" \
-V "date=$(date +'%B %Y')" \
-V toc=true \
-V toc-depth=3 \
Expand All @@ -179,6 +218,9 @@ jobs:
-V float-placement-figure=H \
-V caption-justification=centering \
-V table-use-row-colors=true \
-V "header-left=\leftmark" \
-V "header-right=${{ steps.meta.outputs.version }}" \
-V "footer-center=EmbeddedOS Press — embeddedos-org.github.io" \
-V "header-includes=\
\usepackage{float}\
\usepackage{booktabs}\
Expand All @@ -187,14 +229,6 @@ jobs:
\captionsetup{font=small,labelfont=bf,format=hang}\
\captionsetup[figure]{name=Figure}\
\captionsetup[table]{name=Table}\
\usepackage{fancyhdr}\
\pagestyle{fancy}\
\fancyhead[L]{\small\leftmark}\
\fancyhead[R]{\small ${{ steps.meta.outputs.version }}}\
\fancyfoot[C]{\small EmbeddedOS Press — embeddedos-org.github.io}\
\fancyfoot[R]{\thepage}\
\renewcommand{\headrulewidth}{0.4pt}\
\renewcommand{\footrulewidth}{0.2pt}\
\usepackage{graphicx}\
\makeatletter\def\maxwidth{\ifdim\Gin@nat@width>\linewidth\linewidth\else\Gin@nat@width\fi}\makeatother\
\setkeys{Gin}{width=\maxwidth,keepaspectratio}" \
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ jobs:
security-events: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: ossf/[email protected].0
- uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@v3
- uses: github/codeql-action/upload-sarif@faaca9a8f6edddba5725ffe5adefdab6669a2eca # v3
with:
sarif_file: results.sarif
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@
`cjson` (v1.7.18), `nanopb` (v0.4.9.1), `lvgl` (v9.2.2), `tinyusb` (v0.18.0), and `unity` (v2.6.1).

### Fixed
- **CI on master runs to completion again.** ruff stopped the pipeline at its
first step on findings the merges had introduced (an F811 duplicate import,
W292, E402). `PackageRecipe.to_dict()` -- defined by #111, deleted by #112's
replay of the same file -- is restored; nine `test_index_sync` cases and mypy
had failed without it. It now emits `install_args`, which the original never
did, and hands back copies of its list fields rather than the live lists;
the index sync mapping carries `install_args` through to the cached recipe.
The vendored `core/eos/docs/three-way-alignment.md` is reverted to its pin
(the correction #109 made there is filed upstream as embeddedos-org/eos#149).
The OSSF Scorecard action moved to the ghcr.io-hosted release and is pinned
by commit. yamllint on the Windows legs: YAML is pinned to LF in
`.gitattributes`, so an existing Windows clone needs its files checked out
again once (`git rm --cached -r . && git reset --hard HEAD`, or a re-clone;
see CONTRIBUTING.md). On Python 3.10 and 3.11 `ebuild/plugins/__init__.py`
now type-checks: the `entry_points()` fallback is spelled out with a cast
instead of a `# type: ignore` naming the wrong error code.
(`ebuild/packages/recipe.py`, `ebuild/packages/index_sync.py`,
`ebuild/plugins/__init__.py`, `core/eos/docs/three-way-alignment.md`,
`.github/workflows/scorecard.yml`, `.gitattributes`, `.yamllint.yml`, and
the three lint-fixed test files.)
- **`ebuild test` now finds Windows test binaries.** The Ninja edge for a
native `type: test` target already carried the platform suffix
(`_exe_suffix()` names it `<name>.exe` on Windows), but `ebuild test`
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ This certifies you have the right to submit the code under the MIT license.
6. Commit with DCO sign-off: git commit -s
7. Push and create Pull Request

**Windows contributors:** `.gitattributes` pins `*.yml` and `*.yaml` to LF so
yamllint sees the same bytes on every platform. The attribute governs future
checkouts, not files already sitting in a working tree, and `git add
--renormalize .` rewrites only the index, never the files. After pulling that
change, from a clean tree run `git rm --cached -r . && git reset --hard HEAD`
once (or re-clone) so the YAML files are checked out again as LF; otherwise
yamllint will still see CRLF locally.

## Coding Standards

### C (ISO C11)
Expand Down
4 changes: 2 additions & 2 deletions core/eos/docs/three-way-alignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This document tracks alignment between all three EoS components to ensure they r

| Dimension | eos | eboot | ebuild | Status |
|-----------|-----|-------|--------|--------|
| Board definitions | 84 board YAMLs in `eos/boards/` (upstream) | 83 board dirs / 138 `eboot_add_board()` calls in `eboot/boards/` (upstream, pinned rev) | `TARGET_ARCH` 14 + `MCU_TO_EBOOT_BOARD` 138 in `ebuild/sdk_generator.py` | ⚠️ Unverified: 84 vs 83 vs 14/138 — three inventories describe the same set and nothing cross-checks them; see the resolver drift note in PR #109 |
| Board definitions | 25 YAML files in `eos/boards/` | 25 board ports in `eboot/boards/` | `MCU_TO_EBOOT_BOARD` + `EOS_BOARD_MAP` in project generator | ✅ Aligned |
| Product profiles | 41 profiles in `eos/products/*.h` | — | `PRODUCT_MAP` (41 entries) in project generator | ✅ Aligned |
| Platform enum | — | 24 `eos_platform_t` entries in `eos_hal.h` | MCU_DATABASE (100+ MCUs) in hw analyzer | ✅ Aligned |
| Peripheral keywords | 33 HAL APIs in `hal.h` + `hal_extended.h` | — | `PERIPHERAL_KEYWORDS` (24 types) + `ComponentDB` (200+ parts) | ✅ Aligned |
Expand Down Expand Up @@ -118,7 +118,7 @@ Customer Input
│ LLMClient (optional) ──► deep analysis │
│ │
│ 2. EosProjectGenerator │
│ MCU_TO_EBOOT_BOARD (alias of ebuild/sdk_generator.py) ► eboot board dir │
│ MCU_TO_EBOOT_BOARD ──────────────────► eboot board dir │
│ EOS_BOARD_MAP ──────────────────────► eos board YAML │
│ PRODUCT_MAP (41 entries) ──────────► eos product .h │
│ MULTICORE_MCUS ─────────────────────► multicore config │
Expand Down
1 change: 1 addition & 0 deletions ebuild/packages/index_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ def sync(
"dependencies": entry.get("dependencies", []),
"configure_args": entry.get("configure_args", []),
"build_args": entry.get("build_args", []),
"install_args": entry.get("install_args", []),
"patches": entry.get("patches", []),
}

Expand Down
31 changes: 31 additions & 0 deletions ebuild/packages/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,37 @@ def validate(self) -> None:
f"Must be one of {self.VALID_BUILD_SYSTEMS}."
)

def to_dict(self) -> Dict[str, Any]:
"""Convert recipe to dictionary for YAML serialization."""
data: Dict[str, Any] = {
"package": self.name,
"version": self.version,
}
if self.description:
data["description"] = self.description
if self.license:
data["license"] = self.license
data["url"] = self.url
if self.checksum:
data["checksum"] = self.checksum
data["build"] = self.build_system
# Copies, not the live lists: a caller that appends to what it got
# back must not edit the recipe behind its back. The key order is the
# one index_sync's recipe_dict uses, with install_args after
# build_args; parse_recipe() reads every key by name, so a dump and a
# reload agree field for field regardless of order.
if self.dependencies:
data["dependencies"] = list(self.dependencies)
if self.configure_args:
data["configure_args"] = list(self.configure_args)
if self.build_args:
data["build_args"] = list(self.build_args)
if self.install_args:
data["install_args"] = list(self.install_args)
if self.patches:
data["patches"] = list(self.patches)
return data


def _parse_string_list(
raw: Dict[str, Any],
Expand Down
65 changes: 65 additions & 0 deletions tests/ebuild/test_package_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
"""Tests for ebuild.packages.recipe."""

import pytest
import yaml

from ebuild.packages.recipe import (
PackageRecipe,
RecipeError,
_parse_recipe,
load_recipe_from_string,
Expand Down Expand Up @@ -115,3 +117,66 @@ def test_depends_alias_must_be_a_list():

with pytest.raises(RecipeError, match="dependencies"):
load_recipe_from_string(content)


def _fully_populated_recipe() -> PackageRecipe:
"""A recipe with every field set, so a round trip has to carry them all."""
return PackageRecipe(
name="demo",
version="1.2.3",
url="https://example.com/demo-1.2.3.tar.gz",
checksum="sha256:" + "ab" * 32,
build_system="autoconf",
dependencies=["zlib", "openssl"],
patches=["fix-build.patch"],
configure_args=["--enable-static"],
build_args=["VERBOSE=1"],
install_args=["DESTDIR=/tmp/stage"],
description="A demo package",
license="MIT",
)


def test_to_dict_round_trips_every_field():
"""Dumping to YAML and parsing it back must reproduce the recipe exactly.

to_dict() predates install_args and never emitted it, so a recipe cached
by index_sync came back with install_args == [] while every other field
survived. A field-for-field comparison catches the next one too.
"""
recipe = _fully_populated_recipe()

reloaded = parse_recipe(yaml.safe_load(yaml.safe_dump(recipe.to_dict())))

assert reloaded == recipe
assert reloaded.install_args == ["DESTDIR=/tmp/stage"]

# Key order is not a correctness property -- parse_recipe() reads every
# key by name, as recipe.py says -- but it is a stability property: the
# cached recipe YAML that index_sync writes is diffed by humans, and this
# keeps install_args next to build_args, where index_sync's recipe_dict
# puts it. If this fails after a deliberate reordering, update both
# emitters together and then this line; it is not a bug in to_dict().
keys = list(recipe.to_dict())
assert keys.index("install_args") == keys.index("build_args") + 1


def test_to_dict_returns_copies_not_live_lists():
"""Mutating a list from to_dict() must not reach into the recipe."""
recipe = _fully_populated_recipe()

data = recipe.to_dict()
for field_name in (
"dependencies",
"patches",
"configure_args",
"build_args",
"install_args",
):
data[field_name].append("injected")

assert recipe.dependencies == ["zlib", "openssl"]
assert recipe.patches == ["fix-build.patch"]
assert recipe.configure_args == ["--enable-static"]
assert recipe.build_args == ["VERBOSE=1"]
assert recipe.install_args == ["DESTDIR=/tmp/stage"]
37 changes: 37 additions & 0 deletions tests/unit/test_index_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from unittest.mock import MagicMock, patch

import pytest
import yaml
from click.testing import CliRunner

from ebuild.cli.commands import cli
Expand Down Expand Up @@ -110,6 +111,42 @@ def test_index_sync_success(tmp_path):
assert "1.0.0" in content


def test_index_sync_caches_install_args(tmp_path):
"""An index entry's install_args must reach the cached recipe YAML.

The entry-to-recipe mapping listed every list field except this one, and
to_dict() never emitted it, so the cached copy of a package silently lost
the arguments its install step needs.
"""
mgr = IndexSyncManager(index_dir=tmp_path)

sample_index = [
{
"name": "staged-pkg",
"version": "2.0.0",
"url": "https://example.com/staged-pkg-2.0.0.tar.gz",
"checksum": "sha256:" + "ab" * 32,
"build_system": "make",
"install_args": ["DESTDIR=/tmp/stage", "PREFIX=/usr"],
}
]
raw_json = json.dumps(sample_index).encode("utf-8")

mock_resp = MagicMock()
mock_resp.read.return_value = raw_json
mock_resp.headers = {"Content-Length": str(len(raw_json))}
mock_resp.__enter__.return_value = mock_resp

with patch("urllib.request.urlopen", return_value=mock_resp):
res = mgr.sync(url="https://example.com/index.json", force=True)

assert res.package_count == 1
cached = yaml.safe_load(
(mgr.recipes_dir / "staged-pkg.yaml").read_text(encoding="utf-8")
)
assert cached["install_args"] == ["DESTDIR=/tmp/stage", "PREFIX=/usr"]


def test_index_sync_corrupted_json(tmp_path):
mgr = IndexSyncManager(index_dir=tmp_path)

Expand Down
Loading