From 6810b85e2c4f1d1407ddaacf7bbf747410d9d6f3 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 20 Jun 2026 09:54:17 -0400 Subject: [PATCH] Add environment-aware artifact fetching --- README.md | 8 ++-- docs/tdeck-flashing-recovery.md | 14 +++++++ docs/tdeck-release-artifacts.md | 22 +++++++++- docs/tdeck-release-checklist.md | 14 +++++++ docs/tdeck-troubleshooting.md | 8 ++++ docs/tdeck-upgrade-path.md | 8 ++++ scripts/fetch_tdeck_artifact.py | 71 +++++++++++++++++++++++++++------ scripts/release_evidence.py | 37 +++++++++++++---- 8 files changed, 157 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index bfcf95d..890b6a5 100644 --- a/README.md +++ b/README.md @@ -340,10 +340,10 @@ python scripts/tdm_airtime_smoke.py --port /dev/ttyACM0 It drives `net`, `airtime`, and `rf`, asserts the 60/40, 50/50, and 40/60 dwell splits, checks that the TDM switch counter advances, and fails clearly if the flashed firmware still has MeshCore gated. -downloads the matching successful `Firmware CI` artifact with `gh`. It refuses -to use an older run unless `--allow-latest-success` is passed, and verifies the -downloaded `FLASH_MANIFEST.txt` before printing the local flash command: artifact -SHA, Actions run id, and T-Deck budget status must match. +The fetch helper refuses to use an older run unless `--allow-latest-success` is +passed, and verifies the downloaded `FLASH_MANIFEST.txt` before printing the +local flash command: artifact SHA, Actions run id, environment, and T-Deck +budget status must match. CI runs the native simulator build, native codec selftest, deterministic simulator scenario, screenshot generation, T-Deck firmware build, and T-Deck size report diff --git a/docs/tdeck-flashing-recovery.md b/docs/tdeck-flashing-recovery.md index d53a06a..53820d7 100644 --- a/docs/tdeck-flashing-recovery.md +++ b/docs/tdeck-flashing-recovery.md @@ -48,6 +48,12 @@ Download the artifact for the exact commit: python scripts/fetch_tdeck_artifact.py --repo ItsLimitlezz/LimitlezzOS --branch --commit --out .pio/ci-artifacts/tdeck ``` +For the MeshCore-enabled Phase 3 validation artifact: + +```sh +python scripts/fetch_tdeck_artifact.py --env tdeck-meshcore --repo ItsLimitlezz/LimitlezzOS --branch --commit +``` + Check `FLASH_MANIFEST.txt` before flashing: ```powershell @@ -63,6 +69,14 @@ Flash and run the standard serial smoke: python scripts/tdeck_smoke.py --port COM8 --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/tdeck --open-timeout 60 --boot-timeout 60 --timeout 30 ``` +For a MeshCore-enabled TDM run, use the matching environment and artifact +directory: + +```sh +python scripts/tdeck_smoke.py --port COM8 --env tdeck-meshcore --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/tdeck-meshcore --open-timeout 60 --boot-timeout 60 --timeout 30 +python scripts/tdm_airtime_smoke.py --port COM8 --open-timeout 60 --boot-timeout 60 --timeout 30 +``` + Use `/dev/ttyACM0` or `/dev/ttyUSB0` instead of `COM8` on non-Windows hosts. ## Local Build Flash Path diff --git a/docs/tdeck-release-artifacts.md b/docs/tdeck-release-artifacts.md index 6fa21cd..8d1c975 100644 --- a/docs/tdeck-release-artifacts.md +++ b/docs/tdeck-release-artifacts.md @@ -11,9 +11,10 @@ Release binaries must come from the `Firmware CI` workflow for the exact commit being released. Do not attach locally built firmware as the release binary unless GitHub Actions is unavailable and the release notes explicitly say so. -The workflow uploads two artifact families: +The workflow uploads three artifact families: - `tdeck-firmware-` +- `tdeck-meshcore-firmware-` for opt-in Phase 3 split-airtime validation - `native-screenshots-` For pull requests, `` is the PR head SHA. The workflow also records the @@ -21,7 +22,7 @@ GitHub pull-request merge SHA in `FLASH_MANIFEST.txt` as `github_sha`. ## Firmware Bundle Contents -The T-Deck firmware artifact must include: +The T-Deck firmware artifacts must include: - `bootloader.bin` - `boot_app0.bin` @@ -49,6 +50,9 @@ The T-Deck firmware artifact must include: - static RAM size - budget status +The MeshCore-enabled artifact manifest must also include `env=tdeck-meshcore` +and `meshcore_enabled=1`. + ## Release Candidate Checklist Before attaching binaries to a release: @@ -80,6 +84,12 @@ For an explicit release candidate: python scripts/fetch_tdeck_artifact.py --repo ItsLimitlezz/LimitlezzOS --branch --commit --out .pio/ci-artifacts/ ``` +For the opt-in MeshCore TDM validation bundle: + +```sh +python scripts/fetch_tdeck_artifact.py --env tdeck-meshcore --repo ItsLimitlezz/LimitlezzOS --branch --commit +``` + To inspect the manifest: ```sh @@ -136,6 +146,14 @@ For the maintainer Windows host, flash the release artifact on `COM8`: python scripts/tdeck_smoke.py --port COM8 --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/ ``` +For Phase 3 MeshCore TDM validation, flash the opt-in artifact and then run +the split-airtime probe: + +```sh +python scripts/tdeck_smoke.py --port COM8 --env tdeck-meshcore --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/tdeck-meshcore +python scripts/tdm_airtime_smoke.py --port COM8 +``` + If the first serial attach times out after a verified flash, retry without reflashing: diff --git a/docs/tdeck-release-checklist.md b/docs/tdeck-release-checklist.md index 459bc6a..55f468c 100644 --- a/docs/tdeck-release-checklist.md +++ b/docs/tdeck-release-checklist.md @@ -52,6 +52,12 @@ gh run watch --repo ItsLimitlezz/LimitlezzOS --exit-status --interval 1 python scripts/fetch_tdeck_artifact.py --repo ItsLimitlezz/LimitlezzOS --branch --commit --out .pio/ci-artifacts/tdeck ``` +For Phase 3 MeshCore TDM validation, fetch the opt-in build instead: + +```sh +python scripts/fetch_tdeck_artifact.py --env tdeck-meshcore --repo ItsLimitlezz/LimitlezzOS --branch --commit +``` + Confirm `FLASH_MANIFEST.txt` contains the expected `repo=`, `sha=`, `workflow=`, `run_id=`, `flash_offsets=`, and size-budget lines. @@ -66,6 +72,14 @@ Flash the downloaded Actions artifact: python scripts/tdeck_smoke.py --port COM8 --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/tdeck --open-timeout 60 --boot-timeout 60 --timeout 30 ``` +For the MeshCore-enabled artifact, pass the matching environment and then run +the TDM probe: + +```sh +python scripts/tdeck_smoke.py --port COM8 --env tdeck-meshcore --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/tdeck-meshcore --open-timeout 60 --boot-timeout 60 --timeout 30 +python scripts/tdm_airtime_smoke.py --port COM8 --open-timeout 60 --boot-timeout 60 --timeout 30 +``` + Capture: - ESP32-S3 revision and MAC. diff --git a/docs/tdeck-troubleshooting.md b/docs/tdeck-troubleshooting.md index b3eabac..520937d 100644 --- a/docs/tdeck-troubleshooting.md +++ b/docs/tdeck-troubleshooting.md @@ -74,6 +74,14 @@ python scripts/fetch_tdeck_artifact.py --repo ItsLimitlezz/LimitlezzOS --branch python scripts/tdeck_smoke.py --port COM8 --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/tdeck ``` +For the MeshCore-enabled Phase 3 bundle, use the artifact environment so the +downloaded manifest is checked against `env=tdeck-meshcore`: + +```sh +python scripts/fetch_tdeck_artifact.py --env tdeck-meshcore --repo ItsLimitlezz/LimitlezzOS --branch --commit +python scripts/tdeck_smoke.py --port COM8 --env tdeck-meshcore --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/tdeck-meshcore +``` + ## Flash Succeeds But Smoke Times Out USB CDC may disconnect briefly during boot. If flashing completed and all hashes diff --git a/docs/tdeck-upgrade-path.md b/docs/tdeck-upgrade-path.md index 2eb59c5..43be82d 100644 --- a/docs/tdeck-upgrade-path.md +++ b/docs/tdeck-upgrade-path.md @@ -96,6 +96,14 @@ python scripts/fetch_tdeck_artifact.py --repo ItsLimitlezz/LimitlezzOS --branch python scripts/tdeck_smoke.py --port COM8 --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/ ``` +For the opt-in MeshCore TDM validation image: + +```sh +python scripts/fetch_tdeck_artifact.py --env tdeck-meshcore --repo ItsLimitlezz/LimitlezzOS --branch --commit +python scripts/tdeck_smoke.py --port COM8 --env tdeck-meshcore --no-stub-upload --skip-build --artifact-dir .pio/ci-artifacts/tdeck-meshcore +python scripts/tdm_airtime_smoke.py --port COM8 +``` + The artifact fetch is strict by default. It refuses to download an artifact when there is no successful `Firmware CI` run for the requested commit. diff --git a/scripts/fetch_tdeck_artifact.py b/scripts/fetch_tdeck_artifact.py index 80a10a0..e7aa57b 100644 --- a/scripts/fetch_tdeck_artifact.py +++ b/scripts/fetch_tdeck_artifact.py @@ -15,6 +15,22 @@ from pathlib import Path +ARTIFACT_BUNDLE_FILES = ( + "bootloader.bin", + "boot_app0.bin", + "firmware.bin", + "firmware.elf", + "firmware.map", + "partitions.bin", + "FLASH_MANIFEST.txt", + "SIZE_BUDGET.md", + "SIZE_BUDGET.txt", + "size-budget.json", + "tdeck-build.txt", + "tdeck-size.txt", +) + + def run_text(cmd: list[str], cwd: Path) -> str: try: return subprocess.check_output(cmd, cwd=cwd, text=True, stderr=subprocess.STDOUT).strip() @@ -106,6 +122,26 @@ def load_artifacts(project_dir: Path, repo: str, run_id: int) -> list[dict]: return json.loads(data).get("artifacts", []) +def artifact_prefix(env_name: str) -> str: + if env_name == "tdeck": + return "tdeck-firmware" + if env_name == "tdeck-meshcore": + return "tdeck-meshcore-firmware" + raise SystemExit(f"Unsupported artifact environment: {env_name}") + + +def default_out_dir(env_name: str) -> Path: + leaf = "tdeck-meshcore" if env_name == "tdeck-meshcore" else "tdeck" + return Path(".pio") / "ci-artifacts" / leaf + + +def clear_existing_bundle(out_dir: Path) -> None: + for name in ARTIFACT_BUNDLE_FILES: + path = out_dir / name + if path.is_file(): + path.unlink() + + def choose_run(runs: list[dict], commit: str, allow_latest_success: bool) -> dict: successful = [r for r in runs if r.get("status") == "completed" and r.get("conclusion") == "success"] for run in successful: @@ -157,10 +193,11 @@ def load_flash_manifest(bundle: Path) -> dict[str, str]: return values -def validate_flash_manifest(bundle: Path, expected_sha: str, expected_run_id: int) -> dict[str, str]: +def validate_flash_manifest(bundle: Path, expected_sha: str, expected_run_id: int, expected_env: str) -> dict[str, str]: values = load_flash_manifest(bundle) actual_sha = values.get("sha", "") actual_run_id = values.get("run_id", "") + actual_env = values.get("env") or "tdeck" if actual_sha.lower() != expected_sha.lower(): raise SystemExit( f"Downloaded artifact SHA mismatch: manifest has {actual_sha or ''}, " @@ -176,19 +213,26 @@ def validate_flash_manifest(bundle: Path, expected_sha: str, expected_run_id: in f"Downloaded artifact did not pass the T-Deck budget gate: " f"{values.get('budget_status', '')}" ) + if actual_env != expected_env: + raise SystemExit( + f"Downloaded artifact environment mismatch: manifest has {actual_env}, " + f"expected {expected_env}." + ) return values def main() -> int: parser = argparse.ArgumentParser(description="Download the current branch T-Deck firmware artifact.") parser.add_argument("--project-dir", default=Path(__file__).resolve().parents[1]) + parser.add_argument("--env", default="tdeck", choices=("tdeck", "tdeck-meshcore"), + help="Firmware environment artifact to download.") parser.add_argument("--repo", help="GitHub repo to read Actions artifacts from, e.g. ItsLimitlezz/LimitlezzOS.") parser.add_argument("--workflow", default="Firmware CI") parser.add_argument("--branch", help="Branch name. Defaults to the current git branch.") parser.add_argument("--commit", help="Commit SHA. Defaults to HEAD.") parser.add_argument("--run-id", type=int, help="Download a specific run instead of searching.") - parser.add_argument("--artifact-name", help="Artifact name. Defaults to tdeck-firmware-.") - parser.add_argument("--out", default=Path(".pio") / "ci-artifacts" / "tdeck") + parser.add_argument("--artifact-name", help="Artifact name. Defaults from --env and SHA.") + parser.add_argument("--out", help="Output directory. Defaults from --env.") parser.add_argument("--allow-latest-success", action="store_true", help="Allow newest successful run if HEAD has none.") args = parser.parse_args() @@ -196,7 +240,8 @@ def main() -> int: repo = args.repo or default_repo(project_dir) branch = args.branch or current_branch(project_dir) commit = args.commit or current_commit(project_dir) - out_dir = (project_dir / args.out).resolve() if not Path(args.out).is_absolute() else Path(args.out) + out_arg = Path(args.out) if args.out else default_out_dir(args.env) + out_dir = (project_dir / out_arg).resolve() if not out_arg.is_absolute() else out_arg if args.run_id is not None: run_id = args.run_id @@ -209,18 +254,19 @@ def main() -> int: artifact_sha = chosen["headSha"] run_url = chosen["url"] - artifact_name = args.artifact_name or f"tdeck-firmware-{artifact_sha}" + prefix = artifact_prefix(args.env) + artifact_name = args.artifact_name or f"{prefix}-{artifact_sha}" if args.artifact_name is None: artifacts = load_artifacts(project_dir, repo, run_id) names = [a.get("name", "") for a in artifacts] if artifact_name not in names: - tdeck_names = [name for name in names if name.startswith("tdeck-firmware-")] - if len(tdeck_names) == 1: + env_names = [name for name in names if name.startswith(f"{prefix}-")] + if len(env_names) == 1: print( - f"[artifact] expected {artifact_name}, using run artifact {tdeck_names[0]}", + f"[artifact] expected {artifact_name}, using run artifact {env_names[0]}", file=sys.stderr, ) - artifact_name = tdeck_names[0] + artifact_name = env_names[0] else: available = "\n".join(f" {name}" for name in names) raise SystemExit( @@ -228,6 +274,7 @@ def main() -> int: f"Available artifacts:\n{available}" ) out_dir.mkdir(parents=True, exist_ok=True) + clear_existing_bundle(out_dir) run_text( [ @@ -246,16 +293,16 @@ def main() -> int: ) bundle = bundle_dir(out_dir) - manifest = validate_flash_manifest(bundle, artifact_sha, run_id) + manifest = validate_flash_manifest(bundle, artifact_sha, run_id, args.env) print(f"[artifact] run: {run_url}") print(f"[artifact] name: {artifact_name}") print(f"[artifact] dir: {bundle}") print( f"[artifact] manifest: sha={manifest.get('sha')} run_id={manifest.get('run_id')} " - f"budget={manifest.get('budget_status')}" + f"env={manifest.get('env') or 'tdeck'} budget={manifest.get('budget_status')}" ) print("[artifact] flash:") - print(f" python scripts/tdeck_smoke.py --port COM8 --no-stub-upload --skip-build --artifact-dir {bundle}") + print(f" python scripts/tdeck_smoke.py --port COM8 --env {args.env} --no-stub-upload --skip-build --artifact-dir {bundle}") return 0 diff --git a/scripts/release_evidence.py b/scripts/release_evidence.py index 141b9b6..6b6f4fe 100644 --- a/scripts/release_evidence.py +++ b/scripts/release_evidence.py @@ -18,6 +18,11 @@ REQUIRED_FLASH_FILES = ("bootloader.bin", "boot_app0.bin", "partitions.bin", "firmware.bin") +def default_artifact_dir(env_name: str) -> Path: + leaf = "tdeck-meshcore" if env_name == "tdeck-meshcore" else "tdeck" + return Path(".pio") / "ci-artifacts" / leaf + + def run_text(cmd: list[str], cwd: Path, *, required: bool = False) -> str | None: try: return subprocess.check_output(cmd, cwd=cwd, text=True, stderr=subprocess.STDOUT).strip() @@ -143,7 +148,9 @@ def main() -> int: parser.add_argument("--workflow", default="Firmware CI") parser.add_argument("--branch", help="Branch name. Defaults to the current git branch.") parser.add_argument("--commit", help="Commit SHA. Defaults to HEAD.") - parser.add_argument("--artifact-dir", default=Path(".pio") / "ci-artifacts" / "tdeck") + parser.add_argument("--env", default="tdeck", choices=("tdeck", "tdeck-meshcore"), + help="Firmware artifact environment.") + parser.add_argument("--artifact-dir", help="Downloaded artifact directory. Defaults from --env.") parser.add_argument("--port", default=default_port()) args = parser.parse_args() @@ -152,7 +159,7 @@ def main() -> int: commit = args.commit or git(project_dir, "rev-parse", "HEAD") short_commit = commit[:12] repo = args.repo or default_repo(project_dir) - artifact_dir = Path(args.artifact_dir) + artifact_dir = Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.env) if not artifact_dir.is_absolute(): artifact_dir = (project_dir / artifact_dir).resolve() @@ -160,19 +167,29 @@ def main() -> int: manifest_path = find_manifest(artifact_dir) manifest = parse_manifest(manifest_path) files = present_flash_files(artifact_dir) + manifest_env = manifest.get("env") or "tdeck" + manifest_sha = manifest.get("sha", "") + manifest_matches = ( + bool(manifest_path) and + manifest_sha.lower() == commit.lower() and + manifest_env == args.env + ) actions_status = "passed" if run and run.get("status") == "completed" and run.get("conclusion") == "success" else "pending" - artifact_status = "downloaded" if manifest_path and len(files) == len(REQUIRED_FLASH_FILES) else "pending" + artifact_status = "downloaded" if manifest_matches and len(files) == len(REQUIRED_FLASH_FILES) else "pending" print("## T-Deck Release Evidence") print() print(f"- Repository: {md_code(repo)}") print(f"- Branch: {md_code(branch)}") print(f"- Commit: {md_code(commit)}") + print(f"- Environment: {md_code(args.env)}") print(f"- Workflow: {md_code(args.workflow)}") print(f"- Actions run: {run_line(run, commit)}") print(f"- Artifact dir: {md_code(artifact_dir)}") print(f"- Artifact manifest: {md_code(manifest_path) if manifest_path else 'not downloaded yet'}") - print(f"- Manifest SHA: {md_code(manifest.get('sha', 'not recorded'))}") + print(f"- Manifest SHA: {md_code(manifest_sha or 'not recorded')}") + print(f"- Manifest env: {md_code(manifest_env)}") + print(f"- Manifest matches request: {md_code('yes' if manifest_matches else 'no')}") print(f"- Flash bundle files: {md_code(', '.join(files) if files else 'not downloaded yet')}") print(f"- Hardware port: {md_code(args.port)}") print() @@ -189,19 +206,25 @@ def main() -> int: print(f"- [ ] Wait for exact-commit Actions success: `gh run watch --repo {repo} --exit-status --interval 10`") print( "- [ ] Fetch exact artifact: " - f"`python scripts/fetch_tdeck_artifact.py --repo {repo} --branch {branch} --commit {commit} --out .pio/ci-artifacts/tdeck`" + f"`python scripts/fetch_tdeck_artifact.py --env {args.env} --repo {repo} --branch {branch} --commit {commit} --out {artifact_dir}`" ) print("- [ ] Confirm `FLASH_MANIFEST.txt` `sha=` matches the PR head commit.") + if args.env == "tdeck-meshcore": + print("- [ ] Confirm `FLASH_MANIFEST.txt` has `env=tdeck-meshcore` and `meshcore_enabled=1`.") print() print("### COM8 Hardware Smoke") print() print( "- [ ] Flash exact artifact: " - f"`python scripts/tdeck_smoke.py --port {args.port} --no-stub-upload --skip-build " - "--artifact-dir .pio/ci-artifacts/tdeck --open-timeout 60 --boot-timeout 60 --timeout 30`" + f"`python scripts/tdeck_smoke.py --port {args.port} --env {args.env} --no-stub-upload --skip-build " + f"--artifact-dir {artifact_dir} --open-timeout 60 --boot-timeout 60 --timeout 30`" ) print("- [ ] Record flash chip, MAC, byte counts, and hash verification.") print("- [ ] Record serial `id`, `sys`, `net`, `rf`, `stats`, `wifi`, and `companion test` output.") + if args.env == "tdeck-meshcore": + print("- [ ] Run split-airtime smoke: " + f"`python scripts/tdm_airtime_smoke.py --port {args.port} --open-timeout 60 --boot-timeout 60 --timeout 30`") + print("- [ ] Record 60/40, 50/50, 40/60 dwell checks and switch-count motion.") print("- [ ] Manually verify display, touch, keyboard, trackball, SD/appfs browsing, Wi-Fi state, and sleep/wake.") print() print("### PR Evidence Snippet")