feat(toolchain): make opsm.toml [runtime] the single source of truth - #67
Conversation
Five sources disagreed on OPSM's own toolchain versions (.tool-versions, opsm.toml, .gitlab-ci.yml on :latest, and two different hardcoded sets across GitHub workflows). This makes opsm.toml [runtime] canonical and derives or drift-checks everything else. - opsm.toml [runtime]: correct stale pins (erlang 26.2.0 -> 28.3.1, elixir 1.16.0 -> 1.19.5-otp-28); complete the set with rust 1.97.0, nickel 1.16.0 (previously unpinned anywhere), idris2 0.8.0; drop the vestigial nodejs pin (nothing uses Node; Deno is the sole JS runtime) - scripts/toolchain.sh: sync (generate .tool-versions from opsm.toml) and check (fail on drift in .tool-versions or GitLab image pins); wired as just toolchain-sync / toolchain-check and gated in Mustfile - .tool-versions: now generated; idris2 (no asdf plugin; pack/Chez route) and rust (asdf-global, cargo-audit conflict) excluded with comments - .gitlab-ci.yml: pin elixir:1.19.5-otp-28 and rust:1.97 off :latest (tags verified on Docker Hub); fix invalid '//' comment on line 2; scanner images deliberately float for fresh detection DBs - GitHub workflows: setup-beam now reads .tool-versions (version-file, strict) instead of three disagreeing hardcoded version sets; rust steps pin toolchain 1.97.0 - manager.ex parse_runtime_section/1: skip comment lines and strip inline comments (previously mis-parsed as pins); tests added - docs/TOOLCHAIN.adoc: derivation chain, per-tool provisioners (incl. estate asdf-tool-plugins for zig/deno/nickel), the bootstrap layering under the escript, and the honest Idris2 story (pack today, guix.scm is still a stub) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kq24sZCEohSrNFXSuEuz6C
… alignment
Moving CI from Elixir 1.17 to the canonical 1.19.5-otp-28 surfaced 9
warnings the older compiler never flagged; the e2e workflows compile
with --warnings-as-errors, so these were hard failures. Two were real
bugs, the rest dead code the new type checker proved unreachable.
Real bug fixes (package/cleanup.ex):
- cleanup/2: 'actions = actions ++ ...' rebinds inside the if block,
which does not escape Elixir scoping — config/data/cache removal
actions were silently dropped from the returned list
- remove_systemd_units/1: the for comprehension discarded its results,
so the function always returned [] and daemon-reload never fired;
now collects action tuples via comprehension filter
Dead-clause removals (callees verified infallible; a future callee
change fails loudly via MatchError/type check):
- trust/pipeline.ex: {:error, _} on Slsa.Provenance.generate/2 and on
Oikos.analyze_package/4 (deliberately falls back to heuristic score)
- wiring.ex: same Oikos dead clause in run_sustainability_check/2
- package/installer.ex: {:error, _} on Pipeline.verify/2 (failed checks
arrive inside {:ok, results}; service failures raise into rescue)
- runtime/source_builder.ex: :both strategy called a prebuilt_available?
stub that always returned false — call source_install directly and
drop the stub
- cli.ex: {:error, _} on Wiring.run_audit/2 (always {:ok, _})
Clause grouping (cli.ex): moved interleaved helpers (resolve_installed_version,
find_tui_binary, show_dependencies_*, collect_transitive_deps,
do_install_*) below the run/1 group so all 47 clauses are contiguous.
Pure reorder — content-identical lines, verified by sorted diff.
Verified locally on OTP 28.3.1 / Elixir 1.19.5 (builds.hex.pm):
mix compile --warnings-as-errors passes; 805 tests, 1 failure that is
a sandbox-proxy artifact (api.github.com 401 in a no-network test),
not reproducible on GitHub runners.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kq24sZCEohSrNFXSuEuz6C
|
CI status notes (after
Generated by Claude Code |
…h path (#68) Follow-up to #67. The e2e workflow has been red on `main` for weeks — first at compile (`--warnings-as-errors`), which #67 fixed; with compile green, the `runtime-api` job then failed on `{:error, {:no_plugin, "zig"}}` from tests that were never meant to run there. Two pre-existing defects, both fixed at source: ## 1. Tag taxonomy — download tests ran in PR CI despite the exclusion The live-download tests carried **both** `@tag :external_api` and `@tag :live_download`. ExUnit's `--include` re-includes tests dropped by `--exclude`, so ``` mix test --include external_api --exclude live_download ``` ran the ~10–50MB download tests that `e2e.yml`'s own header reserves for manual dispatch. They now carry only `:live_download` (run manually via `--include live_download`), matching the taxonomy defined at the top of the test file. ## 2. Plugin search path — `Manager.install` couldn't resolve plugins from a checkout `@plugin_search_dirs` was a **module attribute**, so `:code.priv_dir(:opsm)` was frozen at *compile* time, pointing inside whatever `_build` compiled the module — its own comment said "resolved at runtime", an intent that never held. Result: `runtime/core/*.ncl` plugins were unreachable from any repo checkout (this also breaks the `opsm runtime install` dogfooding path that reads `opsm.toml [runtime]`). Search dirs are now computed per call, with repo-checkout fallbacks (`cwd/../runtime/core` under mix, `cwd/runtime/core` as escript) and a fail-safe guard on `:code.priv_dir/1`. `find_plugin_ncl/1` is `@doc false` public with unit tests covering checkout resolution. Also hardened: `System.cmd("nickel", …)` raises `ErlangError :enoent` when nickel isn't installed — now returns `{:error, {:nickel_not_installed, tool}}`. ## Verified (OTP 28.3.1 / Elixir 1.19.5) - `mix compile --warnings-as-errors` — green - `mix test test/opsm/runtime/manager_test.exs` — 17/17 (2 new `find_plugin_ncl` tests) - The exact `runtime-api` CI command — **9 tests, 0 failures, 6 excluded**, live against ziglang.org / go.dev / nodejs.org; both previously-failing tests correctly excluded Note: the branch was restarted from `main` after #67's squash-merge (the old remote branch was auto-deleted on merge), so this PR contains only the follow-up commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Kq24sZCEohSrNFXSuEuz6C --- _Generated by [Claude Code](https://claude.ai/code/session_01Kq24sZCEohSrNFXSuEuz6C)_ Co-authored-by: Claude <noreply@anthropic.com>
Problem
OPSM had no single source of truth for its own toolchain versions — five sources disagreed:
.tool-versions(what actually builds it)opsm.toml [runtime](read byopsm runtime install).gitlab-ci.ymlelixir:latestrust:lateste2e.yml+nif-build.ymltrust-pipeline-e2e.ymlPlus: Nickel heavily used (
runtime/**/*.ncl) but pinned nowhere, Idris2 (a real build dep —src/abi/*.idr) unpinned, Rust floating onstable.Fix
opsm.toml [runtime]is now canonical (dogfooding —opsm runtime installalready consumes it). Everything else derives from it or is drift-checked against it:syncregenerates.tool-versions;checkfails loudly on any drift, incl. GitLab image tags. Wired asjust toolchain-sync/just toolchain-check+ Mustfile check.tool-versions: now generated, with idris2 (no asdf plugin — pack/Chez route) and rust (asdf-global, cargo-audit conflict) excluded via documented skip-list. zig/deno/nickel provision via the estate'sasdf-tool-pluginselixir:1.19.5-otp-28+rust:1.97(tags verified on Docker Hub); also fixed the invalid//comment on line 2 that broke YAML parsing. Scanner images (trivy/semgrep/trufflehog) deliberately float — fresh detection DBs beat cosmetic pinningsetup-beamnow reads.tool-versions(version-file+version-type: strict) in e2e, nif-build, and trust-pipeline-e2e; rust steps pintoolchain: 1.97.0(nif-build, echidna-validation)parse_runtime_section/1(manager.ex): hardened — comment lines containing=were mis-parsed as pins and inline comments corrupted versions; tests addedguix.scmis still a stub, documented as TODO rather than overclaimed)Verified
sh scripts/toolchain.sh sync && sh scripts/toolchain.sh check→ green.tool-versionsversion mutated; GitLab image reverted to:latest) → both exit 1trust-pipeline-e2emoving 26.2 → 28.3.1 (accepted risk per owner decision)Known follow-ups (out of scope here)
guix.scmis a stub (source #f) — growing it into a real manifest is recorded in STATE.a2ml next-actions🤖 Generated with Claude Code
https://claude.ai/code/session_01Kq24sZCEohSrNFXSuEuz6C
Generated by Claude Code