Skip to content
Closed
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
15 changes: 15 additions & 0 deletions .github/workflows/parity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@

name: parity

# tan-cli#213: surface `client_payload.sdk_ref` in the Actions API's own
# `display_title` (what `run-name` sets) rather than only a job-log `::notice::`
# (see the "resolve alp-sdk ref" step below) -- alp-sdk's dispatch-confirmation
# poll can then filter on the exact ref it sent instead of "any run created
# after our dispatch epoch", which a concurrent push or other sender also
# satisfies. Only `repository_dispatch` carries `client_payload`; the other
# three triggers below (`push`, `pull_request`, and `workflow_call` from
# release.yml) render an empty string instead, which is NOT a blank/broken
# title -- GitHub's own rule is that an omitted-or-whitespace-only `run-name`
# falls back to the event-specific default (the commit message on `push`, the
# PR title on `pull_request`), and that default is strictly more informative
# than a constant `parity (push)` string would be. Same empty-string-means-
# "use the default" idiom as the `ref:` step below.
run-name: ${{ github.event_name == 'repository_dispatch' && format('parity (sdk {0})', github.event.client_payload.sdk_ref) || '' }}

on:
# Direct pushes to `main` (an admin merge, a hotfix, a back-merge) open no PR,
# so without this the three jobs below never ran on those commits at all --
Expand Down
1,323 changes: 1,323 additions & 0 deletions contract/issue-codes.json

Large diffs are not rendered by default.

92 changes: 81 additions & 11 deletions python/tan/commands/build/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
mod.rs`, trimmed to this port's current scope: no `tan build --pristine`
manual override (`force_pristine` in the Rust oracle -- this port's `build`
command has no `--pristine` flag yet, so the automatic stamp comparison is
the only path that can ever fire), and no Zephyr-boilerplate-loaded guard.
the only path that can ever fire).
What IS ported: the unknown-backend / null-command / unsafe-cwd / missing-tool
skip-vs-fail policy and dispatch order, the build-dir-must-exist-before-the-
tool-runs precondition, the `tool == "west"` rewrite to the workspace venv's
Expand All @@ -17,8 +17,12 @@
`tan.core.venv`), the sdk-switch-pristine guard (issue #52: wipe a slice's
build dir before dispatch when it was configured against a different SDK
root than this run resolved, then re-stamp it -- see
[_maybe_pristine_stale_sdk_build_dir]), and (see [`last_manifest_write`]) the
post-build `system-manifest.yaml` write.
[_maybe_pristine_stale_sdk_build_dir]), (tan-cli#309, upstream tan-cli #97)
the Zephyr-boilerplate-loaded guard -- an `os: zephyr` slice that exits 0
without ever loading Zephyr's CMake boilerplate (`tan.commands.build.
manifest.zephyr_boilerplate_loaded`) is reported `failed`, not `ok`, since a
real exit code alone is not evidence the build produced firmware -- and (see
[`last_manifest_write`]) the post-build `system-manifest.yaml` write.

**tan-cli#307, a DELIBERATE divergence from the frozen Rust oracle.**
`crates/` is frozen (`docs/ROADMAP.md`'s standing rule), and the oracle's own
Expand Down Expand Up @@ -61,6 +65,7 @@
resolve_zephyr_artefact,
write_post_build_manifest,
write_sdk_stamp,
zephyr_boilerplate_loaded,
)
from tan.commands.build.materialise import MaterialiseError, confine_to_build_root
from tan.core.plan_exec import (
Expand All @@ -73,6 +78,7 @@
)
from tan.core.system_manifest import SliceRunResult
from tan.core.venv import west_program, west_workspace_dir, with_venv_on_path
from tan.core.zephyr_env import zephyr_env_overrides
from tan.envelope import Issue

if os.name != "nt":
Expand Down Expand Up @@ -498,9 +504,17 @@ def execute_slices(
# resolves (CI, an activated venv, the contract harness) -- every west
# slice below then keeps its old cwd, matching the pre-fix behaviour
# exactly (see [`_pin_west_workspace`]).
workspace_dir = west_workspace_dir(
str(build_root), Path(sdk_root) if sdk_root is not None else None
)
sdk_root_path = Path(sdk_root) if sdk_root is not None else None
workspace_dir = west_workspace_dir(str(build_root), sdk_root_path)
# tan-cli#308: port of the oracle's `resolve_zephyr_base` -- the
# workspace's own `zephyr/` checkout, filtered to a real directory so a
# `workspace_dir` that resolved but was never `west update`d (no
# `zephyr/` yet) does not hand `west` a `ZEPHYR_BASE` that does not
# exist. `None` propagates through [`zephyr_env_overrides`] as "nothing
# to fill", matching every other `workspace_dir` consumer's fallback.
zephyr_base = workspace_dir / "zephyr" if workspace_dir is not None else None
if zephyr_base is not None and not zephyr_base.is_dir():
zephyr_base = None

for sl in plan.slices:
if sl.backend not in KNOWN_BACKENDS:
Expand Down Expand Up @@ -583,8 +597,26 @@ def execute_slices(
)
)

# tan-cli#308: the zephyr gap-fillers are computed PER SLICE (not
# once for the whole run, unlike `workspace_dir`/`zephyr_base`
# themselves) because "plan wins" depends on THIS slice's own
# `env`/`env_append_path` -- a heterogeneous plan can have one slice
# that already pins `EXTRA_ZEPHYR_MODULES` (an SDK-emitted plan's
# `envAppendPath`) alongside one that doesn't, and the caller's
# `gap_fillers` merge (`assemble_slice_env`) OVERWRITES a key
# unconditionally -- computing this once, outside the loop, would
# silently clobber the plan's own richer module list on every slice
# that DOES pin it.
slice_gap_fillers = [
*gap_fillers,
*zephyr_env_overrides(
zephyr_base, sdk_root_path, sl.env, sl.env_append_path, env_lookup
),
]
env = dict(os.environ)
env.update(dict(assemble_slice_env(sl.env, sl.env_append_path, env_lookup, gap_fillers)))
env.update(
dict(assemble_slice_env(sl.env, sl.env_append_path, env_lookup, slice_gap_fillers))
)
# tan-cli#289/#106: the venv `west` spawns nested `west`/`bitbake`
# (via `alp_orchestrate`) that resolve purely via PATH -- without
# this they fail to find `west` exactly like the parent process
Expand Down Expand Up @@ -649,22 +681,60 @@ def execute_slices(
)
continue

status = "succeeded" if code == 0 else "failed"
message = None if code == 0 else f"slice `{sl.core_id}` terminated with exit code: {code}"

# tan-cli#309 (upstream tan-cli #97): a core declared `os: zephyr`
# whose CMakeLists.txt never calls `find_package(Zephyr ...)` still
# configures and links fine under `west build -b <board>` (CMake
# only emits a *dev* warning about the missing `project()` call), so
# a real exit code 0 is NOT sufficient evidence -- without this the
# out-of-the-box scaffold was reported `[+] ok` for a plain host
# binary with no Zephyr in it at all. Checked only on an otherwise-
# successful slice (a genuine build failure already speaks for
# itself) and skipped when the slice redirects west's own build dir
# (`-d`/`--build-dir`), where the evidence lives somewhere this
# cannot see -- the same refusal `resolve_zephyr_artefact` below
# already makes.
if (
status == "succeeded"
and sl.backend == "zephyr"
and not build_dir_overridden(sl.command.args)
and not zephyr_boilerplate_loaded(cwd)
):
status = "failed"
message = (
f"core `{sl.core_id}` is declared `os: zephyr`, but the build in "
f"`{sl.command.cwd or '.'}` never loaded Zephyr (no ZEPHYR_BASE in its "
f"CMakeCache.txt and no zephyr/ output) — its CMakeLists.txt must call "
f"`find_package(Zephyr REQUIRED HINTS $ENV{{ZEPHYR_BASE}})` before `project()`; "
f"without it CMake builds a plain host binary, not firmware. Scaffold a working "
f"app with `tan init --template zephyr-app`, or point the core's `app:` at one "
f"that does."
)

# On success, resolve the real on-disk artefact west produced so the
# post-build manifest points downstream consumers (`run`/`size`/
# `flash`/`image`) at the elf that exists, not a plan-time guess.
# Gated on the FINAL `status` (after the guard above), not the raw
# exit code: a guard-failed slice has no real Zephyr artefact to
# report even though the tool itself exited 0.
output_artefact, slice_build_dir = (
resolve_zephyr_artefact(cwd, sl.command.args) if code == 0 else (None, None)
resolve_zephyr_artefact(cwd, sl.command.args) if status == "succeeded" else (None, None)
)
outcomes.append(
SliceOutcome(
sl.core_id,
"succeeded" if code == 0 else "failed",
status,
# A negative POSIX return code means the process died from a
# signal -- Rust's `ExitStatus::code()` returns `None` for
# that case (it has no single-integer exit code), so the
# envelope's `rc` must be null too, not the raw `-N`.
# envelope's `rc` must be null too, not the raw `-N`. Stays
# the tool's REAL exit code even when the guard above
# overrode `status` to "failed" -- `west build` really did
# exit 0, the guard is refusing the RESULT, not the exit.
None if code < 0 else code,
None if code == 0 else f"slice `{sl.core_id}` terminated with exit code: {code}",
message,
output_artefact,
slice_build_dir,
)
Expand Down
67 changes: 62 additions & 5 deletions python/tan/commands/build/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
manifest.yaml` the downstream `tan run`/`tan flash`/`tan size`/`tan image`
contract reads, and resolve the real on-disk `zephyr.elf` a slice produced.

Port of `crates/tan-cli/src/commands/build/execute/manifest.rs`, trimmed to
this port's current scope the same way `tan.commands.build.execute` already
is (see that module's docstring): no Zephyr-boilerplate-loaded guard. What IS
Port of `crates/tan-cli/src/commands/build/execute/manifest.rs`. What's
ported: the post-build `write_post_build_manifest` seam and its two in-memory
signals (`write_failed_reason` / `native_sim_target`), `resolve_zephyr_artefact`'s
default-nested-build-dir artefact resolution, and the SDK-identity stamp
default-nested-build-dir artefact resolution, the SDK-identity stamp
(`sdk_stamp_path`/`read_sdk_stamp`/`write_sdk_stamp`/`cmake_cache_configured`)
the sdk-switch-pristine guard in `execute.py` reads and writes (issue #52).
the sdk-switch-pristine guard in `execute.py` reads and writes (issue #52),
and (tan-cli#309) the Zephyr-boilerplate-loaded guard (tan-cli #97 upstream)
[`zephyr_boilerplate_loaded`] -- `execute.py`'s status assembly applies it.

**Why `sdk_root`/`board_yaml` stay ACCEPTED overrides here rather than always
required.** The Rust oracle's `write_post_build_manifest` takes a
Expand Down Expand Up @@ -51,6 +51,7 @@
"sdk_stamp_path",
"write_post_build_manifest",
"write_sdk_stamp",
"zephyr_boilerplate_loaded",
]


Expand Down Expand Up @@ -318,3 +319,59 @@ def cmake_cache_configured(slice_cwd: Path) -> bool:
`sdk_stamp_action` needs before it treats a missing stamp as stale. Port
of `manifest.rs::cmake_cache_configured`."""
return (slice_cwd / "build" / "CMakeCache.txt").is_file()


def _dir_shows_zephyr(directory: Path) -> bool:
"""The two per-directory signals behind [`zephyr_boilerplate_loaded`]:
a `ZEPHYR_BASE:` entry in `directory`'s own `CMakeCache.txt` (the primary
signal -- what `find_package(Zephyr)` caches, verified against a real
`<slice>/build/CMakeCache.txt`; a plain host configure never writes one),
OR a `zephyr/` subdirectory (Zephyr's boilerplate binary dir, kept as an
OR fallback so the guard can only ever fail a build it is SURE about).
Port of `manifest.rs::dir_shows_zephyr`, whose `std::fs::read_to_string`
folds invalid-UTF-8 into the SAME `io::Error` a missing file raises
(`.is_ok_and(...)` then just falls through to the `zephyr/` fallback);
`UnicodeDecodeError` is a `ValueError`, not an `OSError`, so `except
OSError` alone let a non-UTF-8 `CMakeCache.txt` escape this function as
an uncaught exception -- the same lesson `read_sdk_stamp` above already
records for the sibling `.tan-sdk-root` read."""
try:
cache = (directory / "CMakeCache.txt").read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
cache = ""
if any(line.startswith("ZEPHYR_BASE:") for line in cache.splitlines()):
return True
return (directory / "zephyr").is_dir()


def zephyr_boilerplate_loaded(slice_cwd: Path) -> bool:
"""tan-cli#309 (upstream tan-cli #97): whether this slice's build dir
shows that Zephyr's CMake boilerplate actually ran -- the signal behind
the `os: zephyr` guard `execute.py`'s status assembly applies.

The reported defect: a project whose `CMakeLists.txt` never calls
`find_package(Zephyr ...)` still configures and links fine under `west
build -b <board>` (CMake only emits a *dev* warning about the missing
`project()` call), so a core declared `os: zephyr` produced a host
binary and the executor reported it `[+] ok`. The board name is never
even validated, because nothing loaded the code that would validate it.

Checked one level down too (not just `<slice_cwd>/build` itself),
because `--sysbuild` nests the real per-image Zephyr builds one
directory deeper under its own superbuild -- Zephyr's own
`share/sysbuild/CMakeLists.txt` calls `find_package(Sysbuild ...)`, not
`find_package(Zephyr)`, so a sysbuild top level carries neither signal
and only the nested per-image build does. One level is enough (sysbuild
nests per-image, not recursively).

Callers must skip this check when [`build_dir_overridden`] -- west then
wrote somewhere this can't see, the same refusal [`resolve_zephyr_artefact`]
already makes. Port of `manifest.rs::zephyr_boilerplate_loaded`."""
build = slice_cwd / "build"
if _dir_shows_zephyr(build):
return True
try:
children = [p for p in build.iterdir() if p.is_dir()]
except OSError:
return False
return any(_dir_shows_zephyr(child) for child in children)
14 changes: 8 additions & 6 deletions python/tan/commands/build_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,12 +805,14 @@ def _dispatch(
replace(plan, slices=runnable),
build_root=build_root,
env_lookup=os.environ.get,
# NOT YET PORTED: Rust fills ZEPHYR_BASE and EXTRA_ZEPHYR_MODULES
# here from the resolved west workspace, so `west build -b
# <alp-board>` finds the SDK's boards without the user wiring
# -DEXTRA_ZEPHYR_MODULES. Plans emitted by the SDK carry both on
# the slice's own envAppendPath, so this is a gap only for a host
# relying on the CLI to fill them.
# tan-cli#308: no build_cmd.py-level gap fillers of our own --
# `execute_slices` fills ZEPHYR_BASE/EXTRA_ZEPHYR_MODULES
# itself, PER SLICE, from the west workspace it resolves
# internally (`tan.core.zephyr_env.zephyr_env_overrides`),
# exactly where the Rust oracle's own `execute_slices`
# computes them (`execute/mod.rs`, inside its own per-slice
# loop) -- not from an outer caller. This parameter stays for
# a caller-supplied override this port has none of yet.
gap_fillers=(),
on_output=heartbeat,
sdk_root=sdk_root,
Expand Down
Loading
Loading