diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b828a4a3 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index 60639c32..5f438f5a 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -30,4 +30,4 @@ jobs: repo: context.repo.repo, issue_number: context.payload.pull_request.number, assignees: [context.payload.pull_request.user.login] - }); + }); diff --git a/.github/workflows/book-build.yml b/.github/workflows/book-build.yml index 5a1887b9..52773b12 100644 --- a/.github/workflows/book-build.yml +++ b/.github/workflows/book-build.yml @@ -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: @@ -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: # ═══════════════════════════════════════════════════ @@ -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: | @@ -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 }}" \ @@ -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 \ @@ -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}\ @@ -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}" \ diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index e8507e51..a201167d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -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/scorecard-action@v2.4.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba01756..9a339e52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `.exe` on Windows), but `ebuild test` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecda1e24..29dfb670 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) diff --git a/core/eos/docs/three-way-alignment.md b/core/eos/docs/three-way-alignment.md index 767c720f..7f9c8c15 100644 --- a/core/eos/docs/three-way-alignment.md +++ b/core/eos/docs/three-way-alignment.md @@ -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 | @@ -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 │ diff --git a/ebuild/packages/index_sync.py b/ebuild/packages/index_sync.py index 42f417f5..f273935e 100644 --- a/ebuild/packages/index_sync.py +++ b/ebuild/packages/index_sync.py @@ -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", []), } diff --git a/ebuild/packages/recipe.py b/ebuild/packages/recipe.py index 6cbdb382..87cd4585 100644 --- a/ebuild/packages/recipe.py +++ b/ebuild/packages/recipe.py @@ -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], diff --git a/tests/ebuild/test_package_recipe.py b/tests/ebuild/test_package_recipe.py index 3c25922d..962de8f2 100644 --- a/tests/ebuild/test_package_recipe.py +++ b/tests/ebuild/test_package_recipe.py @@ -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, @@ -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"] diff --git a/tests/unit/test_index_sync.py b/tests/unit/test_index_sync.py index 7265fd2d..fa3d2962 100644 --- a/tests/unit/test_index_sync.py +++ b/tests/unit/test_index_sync.py @@ -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 @@ -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)