diff --git a/.agents/skills/testing-pilot-corpora-gate/SKILL.md b/.agents/skills/testing-pilot-corpora-gate/SKILL.md index eeeeacca87..4ce54f070c 100644 --- a/.agents/skills/testing-pilot-corpora-gate/SKILL.md +++ b/.agents/skills/testing-pilot-corpora-gate/SKILL.md @@ -183,8 +183,8 @@ gate's own helpers are package-private but reusable (`pilotCorporaGate.files(t)` `actionlint`, `shellcheck`, `python3 scripts/check-doc-links.py`, `gofmt`, `go vet`, `go run -C tools ./cmd/pilot-diff` (validators pre-downloaded; ~4min, prints e.g. -the headline the committed baseline holds — `379 file(s), 347 fully agreeing; 38 agreed -diagnostic(s), 38 only ours, 1582 only the pilot's` at the `2026-08` pin, so read it from +the headline the committed baseline holds — `380 file(s), 345 fully agreeing; 38 agreed +diagnostic(s), 41 only ours, 1614 only the pilot's` at the `2026-08` pin, so read it from `docs/project/pilot-differential-baseline.json` rather than from this line) and `make lint` (staticcheck+gosec, ~2min) all work. There is **no** `yamllint` and **no** `circleci` CLI, so `.circleci/config.yml` can only be parsed as YAML, not schema-validated — say so diff --git a/.agents/skills/testing-pilot-differential/SKILL.md b/.agents/skills/testing-pilot-differential/SKILL.md index bea4275b73..5089fde81a 100644 --- a/.agents/skills/testing-pilot-differential/SKILL.md +++ b/.agents/skills/testing-pilot-differential/SKILL.md @@ -21,13 +21,16 @@ GNU-format diagnostics **relative to `--root`**. Consequences for testing: - The pin `tools/referee/diff` reports comes from `build/pilot-sysml-validator/pilot-pin.txt` (written by the new script), not from the DeciSym `pom.xml`. - `-validator /nonexistent` now says `run ./scripts/download-pilot-sysml-validator.sh`. -- Measured at the `2026-08` pin, with a fresh library cache: `379 file(s), 347 fully agreeing; 38 agreed, - 38 only ours, 1582 only the pilot's`, JSON totals `openSysMLDiagnostics 79 / pilotDiagnostics - 1623 / severityMismatch 3`; ~2 min wall, byte-identical across runs *and* after a from-scratch - rebuild of `build/pilot-validator`. The six `kerml-examples` pilot-only rows the `2026-07` run - carried (`The opposite features 'owningType' … do not refer to each other`) are gone: the pilot - fixed its `ownedDisjoining` delegate, and nothing on our side moved. `kerml-examples` carries no `syntax` diagnostic on either - side. Refresh this paragraph with every rebaseline, and treat a stale one as a finding. +- Measured at the `2026-08` pin after bare parameters took their effective range `[0..*]`, removing + the adjudicated `Behaviors.kerml:14` multiplicity warning (the `[1]` `RocketEquation` inputs keep + its warning at `delta-v-budget.sysml:93`): `380 file(s), 345 fully agreeing; 38 agreed, 41 only + ours, 1614 only the pilot's`, JSON totals `openSysMLDiagnostics 82 / pilotDiagnostics 1655 / + severityMismatch 3`; ~2 min wall, byte-identical across runs *and* after a from-scratch rebuild of + `build/pilot-validator`. The six `kerml-examples` pilot-only rows the `2026-07` run carried (`The + opposite features 'owningType' … do not refer to each other`) are gone: the pilot fixed its + `ownedDisjoining` delegate, which did not move our side at that time. `kerml-examples` carries no + `syntax` diagnostic on either side. Refresh this paragraph with every rebaseline, and treat a + stale one as a finding. - **`tools/referee/diff` has no `-jobs` flag.** Its full flag set is `-repo -validator -kerml-validator -syside -out -timeout`; passing `-jobs` exits **2** with `flag provided but not defined: -jobs`. Only `tools/referee/xpect` is job-parallel. So a PR that @@ -136,14 +139,19 @@ The harness compares OpenSysML diagnostics against the OMG SysML v2 Pilot Implem (via two pinned plain-Java bridges over the pilot's own validators) over four corpus roots and writes `build/pilot-diff/pilot-diff.{txt,json}`. `docs/project/pilot-differential-baseline.json` is the committed result of the *last refreshed* run, so **the harness is testable by reproduction** — -but only while the baseline is current. Check that first. As of the rebaseline that came when the Legend of the Red Dragon example left for its own repository it **is** -current: a live run gives `379 file(s), 347 fully agreeing; 38 agreed, 38 only ours, 1582 only the -pilot's`, byte-identical to the committed baseline, and `docs/project/pilot-differential.md`'s -"Results" table matches. The rebaseline before it, at the architecture self-model's landing, covered two rounds, because the succession-shorthand -removal before it landed without refreshing the baseline; a control run of its merge commit gives -`32 agreed, 54 only ours, 79 only the pilot's`. When it is stale (it was at `ac4ac4fb`, and again while the F60–F69 fix -PRs were in flight), a non-empty `jq -S` baseline diff is *not* by itself evidence of a -regression — see "Isolating one change's effect" below. +but only while the baseline is current. Check that first. The latest rebaseline, after bare +parameters took their effective range `[0..*]` and removed the adjudicated `Behaviors.kerml:14` +warning (the `[1]` `RocketEquation` inputs still produce the warning at +`delta-v-budget.sysml:93`), is current: a live run gives `380 file(s), 345 fully agreeing; 38 +agreed, 41 only ours, 1614 only the pilot's`, byte-identical to the committed baseline, and +`docs/project/pilot-differential.md`'s "Results" table matches. The prior rebaseline, when the +Legend of the Red Dragon example left for its own repository, gave +`380 file(s), 344 fully agreeing; 38 agreed, 42 only ours, 1614 only the pilot's`. +The rebaseline before that, at the architecture self-model's landing, covered two rounds, because +the succession-shorthand removal before it landed without refreshing the baseline; a control run +of its merge commit gives `32 agreed, 54 only ours, 79 only the pilot's`. When it is stale (it was +at `ac4ac4fb`, and again while the F60–F69 fix PRs were in flight), a non-empty `jq -S` baseline +diff is *not* by itself evidence of a regression — see "Isolating one change's effect" below. ## Prerequisites diff --git a/.agents/skills/testing-pilot-execution-referee/SKILL.md b/.agents/skills/testing-pilot-execution-referee/SKILL.md index 01e3443194..895d0dca09 100644 --- a/.agents/skills/testing-pilot-execution-referee/SKILL.md +++ b/.agents/skills/testing-pilot-execution-referee/SKILL.md @@ -148,8 +148,8 @@ pilot answers the representation's own. See `pilot-exec-diff: :: model no/such/model.sysml: stat : no such file or directory`. - **Additivity.** `go run -C tools ./cmd/pilot-diff` must still print the headline the - committed baseline holds (`379 file(s), 347 fully agreeing; 38 agreed - diagnostic(s), 38 only ours, 1582 only the pilot's` at the `2026-08` pin — read it from the baseline JSON, not from this line, since each + committed baseline holds (`380 file(s), 345 fully agreeing; 38 agreed + diagnostic(s), 41 only ours, 1614 only the pilot's` at the `2026-08` pin — read it from the baseline JSON, not from this line, since each fix round moves it) and `jq -S` diff clean against `docs/project/pilot-differential-baseline.json`; `git status --porcelain` empty at the end. diff --git a/.agents/skills/testing-pilot-rejection/SKILL.md b/.agents/skills/testing-pilot-rejection/SKILL.md index 08b71cd055..469792c9e6 100644 --- a/.agents/skills/testing-pilot-rejection/SKILL.md +++ b/.agents/skills/testing-pilot-rejection/SKILL.md @@ -9,7 +9,7 @@ Sibling of `testing-pilot-differential` and `testing-pilot-xpect` (same pin `scripts/pilot-pin.sh`, same committed-baseline shape), but pointed the other way: the differential measures what the reference accepts and we reject; this oracle measures what the reference **rejects and we accept** — permissiveness gaps. Its corpus is committed under -`tools/referee/reject/testdata/negative/` (306 hand-written invalid models, one violated rule + citation +`tools/referee/reject/testdata/negative/` (310 hand-written invalid models, one violated rule + citation in each file's mandatory `// Invalid: ...` first line), so no corpus download exists. Method and findings: `docs/project/pilot-rejection.md`. @@ -35,8 +35,8 @@ As of the `semantic/` source (named pilot constraints, KerML and SysML, the cont succession rules, the feature-value overriding rule, the enumeration-variation rules, the send-action cases, the metadata typing, annotated-element and body rules, the trigger-argument typing rules, the owning-body member rules, the cross-subsetting rules, the variant port rule, the association arity, binary-link end count and multiplicity-bound typing rules, the result-expression ownership rules, and the annotation-ownership, binding-arity, feature-chain conformance, -conjugated typing and end-feature rules, the end-multiplicity, return-owner and single-conjugator rules, the keyword-first relationship end kinds, the prefix and body-context cases, and the redefinition name-resolution cases), with a fresh library cache: -`306 case(s): 297 both reject, 0 only the pilot rejects, 9 only we reject, 0 both accept`, +conjugated typing and end-feature rules, the end-multiplicity, return-owner and single-conjugator rules, the keyword-first relationship end kinds, the prefix and body-context cases, the redefinition name-resolution cases, and the transition accept usage-typing rule), with a fresh library cache: +`310 case(s): 301 both reject, 0 only the pilot rejects, 9 only we reject, 0 both accept`, byte-identical to the committed baseline. Any `both accept` case is a bug in the corpus (the case is not actually invalid under the loaded standard library) — fix the case, never ignore it. A candidate the pilot accepts because it does not enforce the named constraint is not a case either: diff --git a/.agents/skills/testing-pilot-xpect/SKILL.md b/.agents/skills/testing-pilot-xpect/SKILL.md index e36f10d4e1..ced55c2a43 100644 --- a/.agents/skills/testing-pilot-xpect/SKILL.md +++ b/.agents/skills/testing-pilot-xpect/SKILL.md @@ -416,8 +416,8 @@ census in `w5c_census_test.go` is live two ways: perturb one pinned triple (e.g. ## Regression neighbour `go run -C tools ./cmd/pilot-diff` (~1m12s) must still print the headline the *committed* baseline holds — -at the `2026-08` pin that is `379 file(s), 347 fully agreeing; 38 agreed diagnostic(s), 38 -only ours, 1582 only the pilot's`. Read the number out of +at the `2026-08` pin that is `380 file(s), 345 fully agreeing; 38 agreed diagnostic(s), 41 +only ours, 1614 only the pilot's`. Read the number out of `docs/project/pilot-differential-baseline.json` rather than trusting this line, since a landing fix round moves it. When the baseline is itself stale (it was at `19a3ce03`, holding 273 / 281 / 317), a failing `cmp` against it is *not* evidence of an Xpect regression — compare the summary line, and see diff --git a/.agents/skills/testing-sysml-repl/SKILL.md b/.agents/skills/testing-sysml-repl/SKILL.md index 62daf15a23..019d918fc3 100644 --- a/.agents/skills/testing-sysml-repl/SKILL.md +++ b/.agents/skills/testing-sysml-repl/SKILL.md @@ -661,6 +661,32 @@ Also note the parser rejects `require constraint { … }` (a *named* nest `require`/`assume`): `expected '{' after 'require constraint'`. Only the anonymous form and the `require R { … }` reference form parse, so an export test cannot cover the named variant. +## Python multi-document parsing (`Connection.parse_sources`) + +- Drive the public `opensysml.Connection.parse_sources` against a real service + with a library document and a second document that imports it. Assert more + than the absence of diagnostics: the dependent symbol's `specializations` + entry with `kind == "typing"` must have `target_id` naming the library's + definition (`Symbol` has no `.type` property), and `model.documents` must + keep document order, `model.roots` one root per document. +- Use `opensysml.loads` on the dependent document alone as the negative + control: its import must fail. A diagnostic's `.file` is the document's + name as given, including inline names that look like relative paths. +- To prove validation happens before any RPC, attach a delegating + `grpc.UnaryUnaryClientInterceptor` to the connection's channel and stub, + count both GetServerInfo and ParseSources, and follow the invalid input with + a valid call so the instrumentation cannot yield a vacuous pass. +- A conformance fixture containing `choice c;` also needs an outgoing + transition, or it fails on an unrelated semantic error. For an isolated + extension warning use + `package P { state def S { choice c; state a; transition first c then a; } }`; + check the diagnostic goes from warning to error with + `strict_conformance=True`, then that `strict=True` raises `ModelError`. +- The module-level `opensysml.parse_sources` opens the default connection, + which starts a private service unless a port is named; close it afterwards + (`opensysml._default_connection.close()`), or a later test that asserts no + private service is running fails. + ## Argument order and the `--` marker `cmd/sysml/args.go` permutes arguments before `flag.Parse`, so flags may be written **after** the @@ -2530,9 +2556,66 @@ its output rather than in an exit code — so assert on the exact rendered text: ## Multi-file projects: `%load ...` and positional dirs/globs (PR #146) +### Per-file document isolation probes + +- Give only one file a root-level `private import ScalarValues::*;`. A second + file's bare `Real` must stay unresolved, as must a later prompt declaration's + bare `Real`. A qualified expression such as `%eval A::x + 1.0` should still + work, proving isolation did not remove the loaded package from the index. +- Two loaded files declaring the same root package are two root namespaces, not + a duplicate. References select the declaration in the document whose name + sorts first, independent of CLI argument order (see the CLI reference's + Multiple Files section). Put `A::X` in `first.sysml` and `A::Y` in + `second.sysml`, then reverse arguments: `A::X` must resolve and `A::Y` must + remain unresolved in both orders. Do not confuse reference precedence with + document rendering order. +- For rendering order, `%view` takes a **view**, not an ordinary package. + `%render #table` renders the loaded documents without a declared view; reverse + two nonalphabetical package names and assert their member groups reverse. + A declared view with `render asElementTable;` needs `private import Views::*;` + in its scope. +- `%save` passes notation through the formatter. Test source retention separately + from byte equality: tabs can become four spaces even while comments, members, + file order and typed declarations survive. Compare with a `develop` build + before attributing such formatting to a load-path regression. +- Both debugger fixtures in `internal/frontend/repl/testdata/` are load-ready: + `action_debug.sysml` (`%action Debug::tally`, `%step`, type `part def Z;`, + `%continue`) ends at `total = 5`; `state_debug.sysml` (`%state Debug::Cycle`, + `%advance 1`, type `part def Z;`, `%advance 9`, `%advance 5`) reaches working + at t=10 and done at t=15. This tests symbol rebinding across prompt edits. + +#### Devin Secrets Needed + +None for local multi-file CLI/REPL tests. + +### Parallel file-load verification + +- The shared concurrency knob is `-jobs N` / `OPENSYSML_JOBS`, also observable + with `%jobs`. It bounds both plan execution and files parsed/validated in one + load. Compare stdout, stderr and exit status at 1, 2 and 8 jobs plus an + environment override; test invalid values against a nonexistent path to + distinguish startup rejection from a load failure. `-workers` is not a + supported replacement flag. +- Generate a small multi-file model from the nested tools module: + `go run -C tools ./cmd/stress-model -planes 4 -satellites 10 -ground-stations 8 -split-planes `. + Verify SHA256/name manifest records, then shrink the model: unchanged surplus + output should disappear, while an edited surplus plane and user file survive. + Editing a still-current output should refuse the entire regeneration with + `nothing written`; compare all directory bytes before and after. +- A load containing `part component : Needed::T;` gives a non-vacuous batch + diagnostic. In `%verbosity debug`, declare `package Needed { part def T; }`, + then `package Needed {}`, then restore `T`. Whole-buffer diagnostics must + change 1→0→1→0. An empty package removes its members; a nonempty declaration + merges with existing members and is not a suitable deletion probe. +- For save/reload, retain comments and loaded-file declarations and assert + only the latest prompt redeclaration survives. Re-evaluate a compound + expression after `%clear` and reloading the saved file. + `sysml ...` and `%load ...` expand to model files via `internal/workspace/project.Expand`, and every file is accepted before one analysis pass -(`Session.SubmitAll`), so load order does not affect name resolution. Shapes to expect: +(`Session.SubmitAll`), each file a workspace document of its own indexed with the +others. Repeated root names resolve by document-name order, not load order. +Shapes to expect: - More than one file prints a `loaded N files:` header listing each path (a single file prints no header — a good tell that the multi-file path was taken). diff --git a/.circleci/config.yml b/.circleci/config.yml index 417972c7cc..bc5ae218af 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,10 +3,19 @@ version: 2.1 orbs: go: circleci/go@1.10.0 +parameters: + release_rehearsal: + type: boolean + default: false + description: Run the release workflow on a branch without publishing anything (docs/project/releasing.md). + executors: go-executor: docker: - - image: cimg/go:1.25 + # The -node variant of the same image: the WebAssembly gate runs the wasm + # binaries under Node, and an absent runtime would make it skip. + # It carries Node LTS, 24 or later, which the gate needs. + - image: cimg/go:1.25-node resource_class: medium python-executor: @@ -32,7 +41,61 @@ executors: - image: cimg/node:22.12 resource_class: medium + # Julia 1.10 is the client's declared [compat] floor. + julia-executor: + docker: + - image: julia:1.10 + resource_class: medium + + # GNU Octave on the smallest base: MATLAB itself has no CI image, and + # Octave >= 7 is the floor the client supports. + octave-executor: + docker: + - image: cimg/base:current + resource_class: medium + commands: + # In a rehearsal (the release_rehearsal pipeline parameter) CIRCLE_TAG is + # unset; this stands in for the tag the release steps read, before any of + # them runs. sed rather than python: the go and cibuilds images may lack it. + rehearsal-tag: + steps: + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: "REHEARSAL: stand in for the release tag" + command: | + version=$(sed -n 's/^VERSION *= *"\(.*\)" *$/\1/p' \ + client/python/opensysml/_version.py) + # _version.py is PEP 440; the tag spells SemVer. + case "$version" in + *a[0-9]*) semver="${version%%a*}-alpha.${version##*a}" ;; + *b[0-9]*) semver="${version%%b*}-beta.${version##*b}" ;; + *rc[0-9]*) semver="${version%%rc*}-rc.${version##*rc}" ;; + *) semver="$version" ;; + esac + if ! echo "$semver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$'; then + echo "Error: VERSION '$version' is not a release or an a/b/rc" + echo "pre-release; there is no SemVer tag spelling for it." + exit 1 + fi + echo "export CIRCLE_TAG=v${semver}" >> "$BASH_ENV" + echo "REHEARSAL: standing in for tag v${semver} (no tag exists; nothing will be published)" + + # Names the irreversible step a rehearsal leaves out. + rehearsal-skip: + parameters: + what: + type: string + steps: + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: "REHEARSAL: skip << parameters.what >>" + command: 'echo "REHEARSAL: skipping << parameters.what >>"' + # The solver is an external process: without one on PATH the # solver-dependent tests would skip rather than run. install-solvers: @@ -57,6 +120,19 @@ commands: sudo install -m 0755 /tmp/cvc5-bin /usr/local/bin/cvc5 cvc5 --version | head -1 + # The FMI gate runs `python3` over FMPy; the executor's python3 has no pip and is + # externally managed, so FMPy goes in a venv that later steps put first on PATH. + install-fmpy: + steps: + - run: + name: Install fmpy + command: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends python3-venv + python3 -m venv "$HOME/fmpy-venv" + "$HOME/fmpy-venv/bin/pip" install --only-binary ":all:" fmpy==0.3.32 + echo 'export PATH="$HOME/fmpy-venv/bin:$PATH"' >> "$BASH_ENV" + # The corpora are not vendored and the gates skip while they are absent, so a # release tag could be cut over a corpus regression without them. download-corpora: @@ -138,6 +214,21 @@ commands: paths: - build/pilot-library-xmi + # The Modelica Reference-FMUs, gated by tests/fmi and the Python runner's + # real test; pinned by tag and checksum like the corpora above. + - restore_cache: + keys: + - reference-fmus-v1-{{ checksum "scripts/download-reference-fmus.sh" }}-{{ checksum "scripts/reference-fmus-pin.sh" }} + + - run: + name: Download the Reference-FMUs + command: ./scripts/download-reference-fmus.sh + + - save_cache: + key: reference-fmus-v1-{{ checksum "scripts/download-reference-fmus.sh" }}-{{ checksum "scripts/reference-fmus-pin.sh" }} + paths: + - examples/reference-fmus + - run: name: Verify the OMG pilot corpora command: | @@ -217,6 +308,10 @@ jobs: name: Check changelog fragments command: python3 scripts/changelog-test.py && python3 scripts/changelog.py check + - run: + name: Check the release provenance writer + command: python3 scripts/release-provenance-test.py + # The compliance census is counted when the site is built, never committed. - run: name: Check the compliance census hook @@ -281,7 +376,7 @@ jobs: client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto # The race-enabled suite. It is the longest single step in the pipeline, so - # it gets the larger class and four executors, one package shard each. + # it gets the larger class and four executors, one named package shard each. go-race-test: executor: go-executor resource_class: large @@ -294,15 +389,25 @@ jobs: - install-solvers - download-corpora - # Sharded by package: the suite's slowest packages run near the 30m - # per-package timeout when they share one executor. + - install-fmpy + + # One scripts/race-shard.sh shard per node: internal/exec/runtime alone peaks near + # 6.5 GB under -race, so it shares its 8 GB node only sequentially (tests/wasm, tools). - run: name: Run Go race tests command: | set -o pipefail - pkgs=$(go list ./... | circleci tests split --split-by=name) + shards=(runtime model export rest) + if [ "$CIRCLE_NODE_TOTAL" != "${#shards[@]}" ]; then + echo "error: parallelism is $CIRCLE_NODE_TOTAL, but scripts/race-shard.sh has ${#shards[@]} shards" >&2 + exit 1 + fi + shard=${shards[$CIRCLE_NODE_INDEX]} + pkgs=$(scripts/race-shard.sh "$shard") + echo "shard $shard:"; echo "$pkgs" go test -v -race -pgo=off -timeout 30m $pkgs - if [ "$CIRCLE_NODE_INDEX" = "0" ]; then + if [ "$shard" = runtime ]; then + go test -v -race -pgo=off -timeout 30m ./tests/wasm go test -C tools -v -race -pgo=off -timeout 30m ./... fi no_output_timeout: 30m @@ -316,6 +421,12 @@ jobs: OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI: "1" # Same for the solver installed above: absent means failure, not skip. OPENSYSML_REQUIRE_SMT: "1" + # Same for Node, which the executor carries: the WebAssembly gate runs + # the wasm binaries rather than skipping without a runtime (tests/wasm). + OPENSYSML_REQUIRE_WASM: "1" + # Same for the Reference-FMUs and fmpy, fetched and installed above: + # the FMI gate runs rather than skips (tests/fmi). + OPENSYSML_REQUIRE_REFERENCE_FMUS: "1" # A second, race-free pass, because the profile the scan reads is written # with -coverpkg, which is too slow to instrument under -race. @@ -331,6 +442,8 @@ jobs: - install-solvers - download-corpora + - install-fmpy + - run: name: Write the coverage profile the scan reads command: make coverage @@ -342,6 +455,11 @@ jobs: OPENSYSML_REQUIRE_FUML_SUITE: "1" OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI: "1" OPENSYSML_REQUIRE_SMT: "1" + # Node is on the executor, so the WebAssembly gate runs rather than + # skips without a runtime (tests/wasm). + OPENSYSML_REQUIRE_WASM: "1" + # Same for the Reference-FMUs and fmpy (tests/fmi). + OPENSYSML_REQUIRE_REFERENCE_FMUS: "1" - persist_to_workspace: root: . @@ -537,6 +655,25 @@ jobs: } done + # The WebAssembly gate: both wasm targets compiled and vetted, the commands + # linked and then run. Node is on the executor, so the require variable makes + # an absent runtime a failure and the check below makes a skip one too. + # Compiling both targets takes this executor about ten minutes, so the gate + # needs more than go test's default ten-minute budget. + - run: + name: Run the WebAssembly gate + command: | + set -o pipefail + node --version + go test -count=1 -v -timeout 30m ./tests/wasm | tee wasm-gate.log + if grep -qE '^\s*--- SKIP' wasm-gate.log; then + echo "error: the WebAssembly gate skipped" >&2 + exit 1 + fi + no_output_timeout: 30m + environment: + OPENSYSML_REQUIRE_WASM: "1" + # The conformance suite: the runner builds and starts sysml-grpc itself, so # this gate proves the whole path a client takes, not a pre-started service. - run: @@ -805,6 +942,10 @@ jobs: command: | go version + # The FMI runner test reads the Reference-FMUs under examples/, downloaded + # by the same command the Go jobs run. + - download-corpora + - run: name: Install sysml-grpc command: | @@ -817,7 +958,7 @@ jobs: command: | make python-install # Pinned and wheel-only, as in .github/workflows/pr.yml. - pip install --only-binary :all: pytest==9.0.3 pytest-mock==3.15.1 psutil==7.2.2 pytest-cov==7.0.0 + pip install --only-binary :all: pytest==9.0.3 pytest-mock==3.15.1 psutil==7.2.2 pytest-cov==7.0.0 fmpy==0.3.32 # The integration tests connect to a service on the standard port with # auto_start=False, which is the explicit opt-in to one the client does not @@ -851,7 +992,7 @@ jobs: # service is provided here, so its absence is the bug, not a pass. - run: name: Run Python client tests - command: OPENSYSML_REQUIRE_SERVICE=1 make python-coverage + command: OPENSYSML_REQUIRE_SERVICE=1 OPENSYSML_REQUIRE_REFERENCE_FMUS=1 make python-coverage # The repository scripts the Go static checks run are Python too; running # them and their tests under coverage here credits what those checks execute. @@ -962,6 +1103,98 @@ jobs: path: bin/conformance-report-rust.json destination: conformance-report-rust.json + # The Julia client: Pkg.test plus the conformance suite, against a service + # binary built here (this executor has no Go toolchain, so the orb installs + # it like the Rust job does). + julia-client: + executor: julia-executor + steps: + - checkout + + # The julia image carries no make, which the build and conformance steps + # run; it runs as root, so apt needs no sudo. + - run: + name: Install make + command: | + apt-get update + apt-get install -y --no-install-recommends make + make --version | head -1 + + - go/install: + version: "1.25.0" + + - run: + name: Build sysml-grpc + command: make build-grpc + + - run: + name: Run Julia tests + command: | + cd client/julia/OpenSysML + OPENSYSML_GRPC_BINARY="$PWD/../../../bin/sysml-grpc" \ + julia --project=. -e 'using Pkg; Pkg.test()' + + - run: + name: Run Julia conformance + command: make conformance-julia + + - store_artifacts: + path: bin/conformance-report-julia.json + destination: conformance-report-julia.json + + # The MATLAB client under GNU Octave: MATLAB has no CI image, so Octave is + # the tested path (the matlab.net.http branch is exercised by no gate). + # An Octave built without Java (the snap and the CI build) cannot spawn a + # private child, so the job starts a service itself and hands the runner its + # address. + matlab-client: + executor: octave-executor + steps: + - checkout + + - go/install: + version: "1.25.0" + + - run: + name: Install GNU Octave + command: | + sudo apt-get update + sudo apt-get install -y octave + + - run: + name: Build sysml-grpc + command: make build-grpc + + - run: + name: Run MATLAB client tests + command: | + bin/sysml-grpc -port 0 -health-port 0 -report-address > bin/octave-svc-addr & + svc_pid=$! + for i in $(seq 1 100); do [ -s bin/octave-svc-addr ] && break; sleep 0.1; done + test -s bin/octave-svc-addr + cd client/matlab/tests + OPENSYSML_SERVICE="$(cat ../../../bin/octave-svc-addr)" octave --no-gui --eval "run_tests" + rc=$? + kill $svc_pid 2>/dev/null || true + exit $rc + + - run: + name: Run MATLAB conformance + command: | + bin/sysml-grpc -port 0 -health-port 0 -report-address > bin/octave-svc-addr & + svc_pid=$! + for i in $(seq 1 100); do [ -s bin/octave-svc-addr ] && break; sleep 0.1; done + test -s bin/octave-svc-addr + addr=$(cat bin/octave-svc-addr) + octave --no-gui --eval "addpath('client/matlab'); addpath('client/matlab/conformance'); addpath('client/matlab/conformance/private'); run_conformance('--address','$addr','--report','bin/conformance-report-matlab.json')" + rc=$? + kill $svc_pid 2>/dev/null || true + exit $rc + + - store_artifacts: + path: bin/conformance-report-matlab.json + destination: conformance-report-matlab.json + # The Java client: unit tests, the conformance suite over both Connect # encodings, and the committed stubs checked against what buf generates. java-test: @@ -1149,6 +1382,7 @@ jobs: executor: python-executor steps: - checkout + - rehearsal-tag - run: name: Install build tooling @@ -1161,8 +1395,21 @@ jobs: # package would publish, before anything is built. VERSION="$(python client/python/scripts/check_version.py)" PRE_RELEASE="$(python client/python/scripts/check_version.py --pre-release)" + # The Node client is published at the same version, so a + # client/node/package.json that disagrees fails the release here, + # before anything is built; the Java client's pom and the Rust + # crate's Cargo.toml the same. + NPM_VERSION="$(python client/python/scripts/check_version.py --node)" + JAVA_VERSION="$(python client/python/scripts/check_version.py --java)" + RUST_VERSION="$(python client/python/scripts/check_version.py --rust)" + # Nothing publishes the editors, but their manifests carry the same + # version, so a tag fails here when one disagrees; no export needed. + python client/python/scripts/check_version.py --editors > /dev/null echo "export OPENSYSML_VERSION=${VERSION}" >> "$BASH_ENV" - echo "Building opensysml ${VERSION} (pre-release: ${PRE_RELEASE})" + echo "export NPM_VERSION=${NPM_VERSION}" >> "$BASH_ENV" + echo "export JAVA_VERSION=${JAVA_VERSION}" >> "$BASH_ENV" + echo "export RUST_VERSION=${RUST_VERSION}" >> "$BASH_ENV" + echo "Building opensysml ${VERSION} (pre-release: ${PRE_RELEASE}, npm ${NPM_VERSION}, maven ${JAVA_VERSION}, crates ${RUST_VERSION})" - run: name: Build the wheel and the sdist @@ -1213,6 +1460,7 @@ jobs: executor: python-executor steps: - checkout + - rehearsal-tag - attach_workspace: at: . @@ -1283,23 +1531,35 @@ jobs: name: Check the metadata (twine check --strict) command: python -m twine check --strict client/python/dist/* - - run: - name: Upload to the index - command: | - # The token is read straight into twine's environment: nothing here - # echoes it, and nothing in this job prints the environment. - export TWINE_USERNAME=__token__ - if [ "$OPENSYSML_PRE_RELEASE" = "yes" ]; then - export TWINE_REPOSITORY_URL=https://test.pypi.org/legacy/ - export TWINE_PASSWORD="$TEST_PYPI_API_TOKEN" - echo "Uploading opensysml ${OPENSYSML_VERSION} to TestPyPI" - else - export TWINE_PASSWORD="$PYPI_API_TOKEN" - echo "Uploading opensysml ${OPENSYSML_VERSION} to PyPI" - fi - # No --skip-existing: a version that appeared since the check above - # must fail the job rather than pass silently. - python -m twine upload client/python/dist/* + - unless: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: Upload to the index + command: | + # The token is read straight into twine's environment: nothing here + # echoes it, and nothing in this job prints the environment. + export TWINE_USERNAME=__token__ + if [ "$OPENSYSML_PRE_RELEASE" = "yes" ]; then + export TWINE_REPOSITORY_URL=https://test.pypi.org/legacy/ + export TWINE_PASSWORD="$TEST_PYPI_API_TOKEN" + echo "Uploading opensysml ${OPENSYSML_VERSION} to TestPyPI" + else + export TWINE_PASSWORD="$PYPI_API_TOKEN" + echo "Uploading opensysml ${OPENSYSML_VERSION} to PyPI" + fi + # No --skip-existing: a version that appeared since the check above + # must fail the job rather than pass silently. + python -m twine upload client/python/dist/* + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: "REHEARSAL: no twine upload" + command: | + echo "REHEARSAL: PyPI has no read-only token check; the token's presence was checked above." + - rehearsal-skip: + what: "twine upload" # Publishes packaging/pypi-pysysml on a `pysysml-v` tag: the final # release of the pre-rename name, which raises on import to point at @@ -1396,6 +1656,7 @@ jobs: executor: go-executor steps: - checkout + - rehearsal-tag - attach_workspace: at: . - go/load-cache @@ -1518,43 +1779,88 @@ jobs: dist/sysml-lsp-linux-amd64 dist/sysml-lsp-linux-arm64 \ dist/grpc/sysml-grpc-linux-amd64 dist/grpc/sysml-grpc-linux-arm64 - # Signs the manifest keylessly: the certificate identity comes from this - # job's CircleCI OIDC token, so there is no signing key to store. - - run: - name: Sign the checksum manifest with cosign keyless - command: | - go install github.com/sigstore/cosign/v3/cmd/cosign@v3.0.3 - export PATH="$(go env GOPATH)/bin:$PATH" - - # Fulcio requires the 'sigstore' audience, which the ambient - # $CIRCLE_OIDC_TOKEN_V2 does not carry. - SIGSTORE_ID_TOKEN="$(circleci run oidc get --claims '{"aud": "sigstore"}')" - export SIGSTORE_ID_TOKEN - - cd dist - cosign sign-blob SHA256SUMS.txt \ - --oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ - --bundle SHA256SUMS.txt.bundle \ - --use-signing-config=false \ - --yes - - # Fails the release rather than publish a signature the client's pinned - # identity would reject, and prints the identity that signed. - - run: - name: Verify the signature against the identity clients pin - command: | - export PATH="$(go env GOPATH)/bin:$PATH" - cd dist - cosign verify-blob SHA256SUMS.txt \ - --bundle SHA256SUMS.txt.bundle \ - --certificate-oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ - --certificate-identity-regexp "^https://circleci\\.com/api/v2/projects/${CIRCLE_PROJECT_ID}/pipeline-definitions/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" - echo "Signed by:" - jq -r '.verificationMaterial.certificate.rawBytes' SHA256SUMS.txt.bundle \ - | base64 -d | openssl x509 -inform DER -noout -text \ - | grep -A1 -E '1\.3\.6\.1\.4\.1\.57264\.1\.8:|URI:' || true + - unless: + condition: << pipeline.parameters.release_rehearsal >> + steps: + # Signs the manifest keylessly: the certificate identity comes from this + # job's CircleCI OIDC token, so there is no signing key to store. + - run: + name: Sign the checksum manifest with cosign keyless + command: | + go install github.com/sigstore/cosign/v3/cmd/cosign@v3.0.3 + export PATH="$(go env GOPATH)/bin:$PATH" + + # Fulcio requires the 'sigstore' audience, which the ambient + # $CIRCLE_OIDC_TOKEN_V2 does not carry. + SIGSTORE_ID_TOKEN="$(circleci run oidc get --claims '{"aud": "sigstore"}')" + export SIGSTORE_ID_TOKEN + + cd dist + cosign sign-blob SHA256SUMS.txt \ + --oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ + --bundle SHA256SUMS.txt.bundle \ + --use-signing-config=false \ + --yes + + # Fails the release rather than publish a signature the client's pinned + # identity would reject, and prints the identity that signed. + - run: + name: Verify the signature against the identity clients pin + command: | + export PATH="$(go env GOPATH)/bin:$PATH" + cd dist + cosign verify-blob SHA256SUMS.txt \ + --bundle SHA256SUMS.txt.bundle \ + --certificate-oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ + --certificate-identity-regexp "^https://circleci\\.com/api/v2/projects/${CIRCLE_PROJECT_ID}/pipeline-definitions/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + echo "Signed by:" + jq -r '.verificationMaterial.certificate.rawBytes' SHA256SUMS.txt.bundle \ + | base64 -d | openssl x509 -inform DER -noout -text \ + | grep -A1 -E '1\.3\.6\.1\.4\.1\.57264\.1\.8:|URI:' || true + + # SLSA provenance over every artifact the manifest lists, signed under the + # same CircleCI identity as the manifest; the bundle carries the statement. + - run: + name: Attest the release's SLSA provenance with cosign keyless + command: | + export PATH="$(go env GOPATH)/bin:$PATH" + python3 scripts/release-provenance.py \ + --manifest dist/SHA256SUMS.txt --out dist/provenance.intoto.json + SIGSTORE_ID_TOKEN="$(circleci run oidc get --claims '{"aud": "sigstore"}')" + export SIGSTORE_ID_TOKEN + + cd dist + cosign attest-blob SHA256SUMS.txt \ + --statement provenance.intoto.json \ + --type slsaprovenance1 \ + --oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ + --bundle provenance.intoto.json.bundle \ + --use-signing-config=false \ + --yes + + # Verifies the attestation against a published artifact, so a statement + # whose subjects do not name the release's bytes fails the release here. + - run: + name: Verify the provenance names the artifacts under the identity clients pin + command: | + export PATH="$(go env GOPATH)/bin:$PATH" + cd dist + for artifact in opensysml-linux-amd64.tar.gz grpc/sysml-grpc-linux-amd64 opensysml-*-py3-none-any.whl; do + cosign verify-blob-attestation "$artifact" \ + --bundle provenance.intoto.json.bundle \ + --type slsaprovenance1 \ + --certificate-oidc-issuer "https://oidc.circleci.com/org/${CIRCLE_ORGANIZATION_ID}" \ + --certificate-identity-regexp "^https://circleci\\.com/api/v2/projects/${CIRCLE_PROJECT_ID}/pipeline-definitions/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + echo "ok: provenance names $artifact" + done + # Every manifest line must be a subject, and nothing else may be. + diff <(sed 's/^\([0-9a-f]*\) \(.*\)$/\2 \1/' SHA256SUMS.txt | sort) \ + <(jq -r '.dsseEnvelope.payload' provenance.intoto.json.bundle | base64 -d \ + | jq -r '.subject[] | "\(.name) \(.digest.sha256)"' | sort) + - rehearsal-skip: + what: "cosign keyless signing and attestation (they write to the public Rekor log)" # An artifact whose version disagrees with its tag looks identical on the # release page. The host-platform builds are asked what they report; the # cross-compiled ones cannot run here, so they are checked for the version @@ -1618,83 +1924,89 @@ jobs: paths: - dist - # The five sysml-grpc binaries the per-platform npm packages carry, built from - # the tagged revision in this pipeline so the published bytes never travel. - build-node-binaries: - executor: go-executor - steps: - - checkout - - go/load-cache - - go/mod-download - - - run: - name: Cross-compile sysml-grpc and write its checksum sidecars - command: | - VERSION=${CIRCLE_TAG} - COMMIT=${CIRCLE_SHA1} - BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') - GO_VERSION=$(go version | awk '{print $3}') - mkdir -p dist/grpc - build() { - GOOS=$1 GOARCH=$2 make build-grpc VERSION="$VERSION" COMMIT="$COMMIT" \ - BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" - mv bin/sysml-grpc "dist/grpc/sysml-grpc-$1-$2$3" - } - build linux amd64 "" - build linux arm64 "" - build darwin amd64 "" - build darwin arm64 "" - build windows amd64 ".exe" - cd dist/grpc - for asset in sysml-grpc-*; do sha256sum "$asset" > "$asset.sha256"; done - cat ./*.sha256 - - - run: - name: Verify the Linux binaries are statically linked - command: scripts/check-static-binaries.sh dist/grpc/sysml-grpc-linux-* - - - persist_to_workspace: - root: . - paths: - - dist/grpc - - # Publishes @opensysml/client and its five per-platform packages on a - # `client-node-v` tag. npm has no trusted publishing for CircleCI, so - # this authenticates with an automation token from the restricted `npm` - # context. Everything that can fail runs before the first publish: the - # tag/version check, the client's own gate, the digest check on every binary, - # and a refusal when the version is already published. The platform - # packages go first, because @opensysml/client's optionalDependencies name them - # at this exact version. + # Publishes @openmbee/opensysml and its five per-platform packages from the + # core `v` tag, carrying the sysml-grpc binaries build-release + # published — the same bytes as the GitHub release. npm has no trusted + # publishing for CircleCI, so this authenticates with a granular token from + # the restricted `npm` context. Everything that can fail runs before the + # first publish: the tag/version check, the registry check, the token, the + # client's own gate, and the digest check on every binary. The platform + # packages go first, because the client's optionalDependencies name them at + # this exact version. publish-npm: executor: node-executor steps: - checkout + - rehearsal-tag - attach_workspace: at: . - run: - name: Check the tag matches the package version + name: Resolve the version from the tag command: | + if [ -z "${CIRCLE_TAG}" ]; then + echo "Error: CIRCLE_TAG is empty; this job only runs on a core v* tag." + exit 1 + fi version=$(node -p "require('./client/node/package.json').version") - expected="client-node-v${version}" - if [ "${CIRCLE_TAG}" != "$expected" ]; then + # npm publishes the version package.json declares, so the tag must + # spell it exactly; build-python-package has already checked + # package.json against _version.py. + if [ "${CIRCLE_TAG}" != "v${version}" ]; then echo "Error: tag ${CIRCLE_TAG} does not match client/node/package.json" - echo "version ${version}; the tag for it is ${expected}. Nothing was published." + echo "version ${version}; the tag for it is v${version}. Nothing was published." + exit 1 + fi + # check_version.py admits only alpha/beta/rc suffixes, so a SemVer + # pre-release here is exactly a PEP 440 pre-release — the ones + # publish-pypi routes to TestPyPI. npm has no test registry, so a + # pre-release goes to the `next` dist-tag and `latest` is untouched. + case "$version" in + *-*) dist_tag=next ;; + *) dist_tag=latest ;; + esac + echo "export NPM_VERSION=${version}" >> "$BASH_ENV" + echo "export NPM_DIST_TAG=${dist_tag}" >> "$BASH_ENV" + echo "Publishing ${version} to the '${dist_tag}' dist-tag" + # Proves the workspace carries the release binaries and sidecars. + ls -l dist/grpc/sysml-grpc-* + + - run: + name: Require the publishing token + command: | + # Checked early so a missing token fails before anything else; + # never echoed. The 'npm' context supplies it — see + # docs/project/releasing.md. + if [ -z "${NPM_TOKEN}" ]; then + echo "Error: NPM_TOKEN is not set; the restricted 'npm' context supplies it." + echo "See docs/project/releasing.md for how to provision it." exit 1 fi - run: name: Refuse a version already on the registry command: | - version=$(node -p "require('./client/node/package.json').version") - for name in @opensysml/client @opensysml/sysml-grpc-linux-x64 \ - @opensysml/sysml-grpc-linux-arm64 @opensysml/sysml-grpc-darwin-x64 \ - @opensysml/sysml-grpc-darwin-arm64 @opensysml/sysml-grpc-win32-x64; do - if npm view "${name}@${version}" version > /dev/null 2>&1; then - echo "Error: ${name}@${version} is already published; a version cannot be" - echo "replaced. Bump client/node/package.json and tag again." + # The package names come from package.json, not a literal list: + # the client itself plus the optionalDependencies that carry the + # platform binaries. Anything but six means the manifest drifted. + names=$(node -e " + const pkg = require('./client/node/package.json'); + const platforms = Object.keys(pkg.optionalDependencies || {}) + .filter(n => n.startsWith(pkg.name + '-sysml-grpc-')); + console.log([pkg.name, ...platforms].join('\n')); + ") + count=$(echo "$names" | wc -l) + if [ "$count" -ne 6 ]; then + echo "Error: expected 6 packages to publish (client + 5 platform)," + echo "package.json yields $count:" + echo "$names" + exit 1 + fi + for name in $names; do + if npm view "${name}@${NPM_VERSION}" version > /dev/null 2>&1; then + echo "Error: ${name}@${NPM_VERSION} is already published; a version cannot be" + echo "replaced. Bump client/node/package.json and cut the next core release." exit 1 fi done @@ -1714,83 +2026,379 @@ jobs: - run: name: Build the per-platform packages command: | + # These are build-release's bytes; the generator checks each one + # against its .sha256 sidecar before packaging it. cd client/node npm run platform-packages -- --binaries ../../dist/grpc - run: name: Authenticate to npm command: | - if [ -z "${NPM_TOKEN}" ]; then - echo "Error: NPM_TOKEN is not set; the 'npm' context supplies it." - exit 1 - fi echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + # A bad or expired token fails here, before the first publish. + npm whoami + + - unless: + condition: << pipeline.parameters.release_rehearsal >> + steps: + # No --provenance: the npm CLI only mints attestations on GitHub Actions and + # GitLab CI/CD, and it fails rather than publish unattested when asked here. + - run: + name: Publish the per-platform packages + command: | + for directory in client/node/packages/sysml-grpc-*; do + npm publish "$directory" --access public --tag "$NPM_DIST_TAG" + done + + - run: + name: Publish @openmbee/opensysml + command: | + cd client/node + npm publish --access public --tag "$NPM_DIST_TAG" + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: "REHEARSAL: pack every package" + command: | + for directory in client/node/packages/sysml-grpc-*; do + npm pack "$directory" --dry-run + done + (cd client/node && npm pack --dry-run) + - rehearsal-skip: + what: "npm publish" + + # Publishes org.openmbee:opensysml and its parent pom to Maven Central + # from the core `v` tag. Central has no trusted publishing, so a + # portal token and the release signing key come from the + # restricted 'Maven Central' context. Everything that can fail runs before the upload: + # the tag/version check, the Central availability check, the credentials, and + # a proof the signing key and passphrase work. The deploy auto-publishes the + # validated deployment and waits until it is published. + publish-maven: + executor: java-executor + steps: + - checkout + - rehearsal-tag + + - restore_cache: + keys: + - maven-v1-{{ checksum "client/java/pom.xml" }}-{{ checksum "client/java/opensysml-client/pom.xml" }}-{{ checksum "client/java/opensysml-conformance/pom.xml" }} + - maven-v1- - # No --provenance: the npm CLI only mints attestations on GitHub Actions and - # GitLab CI/CD, and it fails rather than publish unattested when asked here. - run: - name: Publish the per-platform packages + name: Resolve the version from the tag command: | - for directory in client/node/packages/sysml-grpc-*; do - npm publish "$directory" --access public + if [ -z "${CIRCLE_TAG}" ]; then + echo "Error: CIRCLE_TAG is empty; this job only runs on a core v* tag." + exit 1 + fi + version=$(mvn -B -q -f client/java/pom.xml help:evaluate \ + -Dexpression=project.version -DforceStdout) + # Central publishes the version the pom declares, so the tag must + # spell it exactly; build-python-package has already checked the pom + # against _version.py. + if [ "${CIRCLE_TAG}" != "v${version}" ]; then + echo "Error: tag ${CIRCLE_TAG} does not match client/java/pom.xml" + echo "version ${version}; the tag for it is v${version}. Nothing was published." + exit 1 + fi + # Central has no test registry, so a pre-release (-rc1, -alpha.1…) + # is published as an ordinary, permanent version that Maven orders + # before the release; a snapshot is refused outright. + case "$version" in + *-SNAPSHOT) + echo "Error: ${version} is a snapshot; Central releases are immutable." + exit 1 ;; + esac + echo "export JAVA_VERSION=${version}" >> "$BASH_ENV" + echo "Publishing org.openmbee:opensysml:${version}" + + - run: + name: Require the publishing credentials + command: | + # Names only, never values; the 'Maven Central' context supplies them. + for name in CENTRAL_TOKEN_USERNAME CENTRAL_TOKEN_PASSWORD \ + GPG_PRIVATE_KEY GPG_PASSPHRASE; do + if [ -z "${!name:-}" ]; then + echo "Error: ${name} is not set; add it to the 'Maven Central' context (Organization Settings → Contexts)." + exit 1 + fi + done + + - run: + name: Refuse a version already on Central + command: | + for artifact in opensysml-parent opensysml; do + code=$(curl -s -o /dev/null -w '%{http_code}' \ + "https://repo1.maven.org/maven2/org/openmbee/${artifact}/${JAVA_VERSION}/") + case "$code" in + 200) + echo "Error: org.openmbee:${artifact}:${JAVA_VERSION} is already on Central;" + echo "a published version cannot be replaced. Cut the next core release." + exit 1 ;; + 404) ;; + *) + echo "Error: could not ask Central (HTTP $code); refusing rather than guessing." + exit 1 ;; + esac done - run: - name: Publish @opensysml/client + name: Import the signing key command: | - cd client/node - npm publish --access public + if ! command -v gpg > /dev/null; then + sudo apt-get update && sudo apt-get install -y gnupg + fi + # The context may hold the key ASCII-armoured or base64-encoded (the + # CircleCI UI does not keep newlines), so accept both. + case "$GPG_PRIVATE_KEY" in + *"-----BEGIN PGP PRIVATE KEY BLOCK-----"*) + printf '%s\n' "$GPG_PRIVATE_KEY" | gpg --batch --import ;; + *) + printf '%s' "$GPG_PRIVATE_KEY" | tr -d ' \n' | base64 -d | gpg --batch --import ;; + esac + # Prove key and passphrase work before anything uploads: a clearsign + # with an expired key or a wrong passphrase fails here. + exec 3\<<<"$GPG_PASSPHRASE" + echo check | gpg --batch --pinentry-mode loopback \ + --passphrase-fd 3 --clearsign > /dev/null + exec 3<&- + + - run: + name: Write the Maven settings + command: | + mkdir -p ~/.m2 + # Env interpolation keeps the portal token off disk. + printf '%s\n' \ + '' \ + ' ' \ + ' ' \ + ' central' \ + ' ${env.CENTRAL_TOKEN_USERNAME}' \ + ' ${env.CENTRAL_TOKEN_PASSWORD}' \ + ' ' \ + ' ' \ + '' > ~/.m2/settings.xml + chmod 600 ~/.m2/settings.xml + + - unless: + condition: << pipeline.parameters.release_rehearsal >> + steps: + # java-test ran the suite on this revision in this workflow, so tests are + # skipped; -am brings the parent pom the client's pom names, which Central + # must hold too; opensysml-conformance is not published. + # Never run this step with -X/debug output; the settings interpolation would print the token. + - run: + name: Sign, upload and publish + command: | + MAVEN_GPG_PASSPHRASE="$GPG_PASSPHRASE" mvn -B -f client/java/pom.xml \ + -Prelease deploy -pl :opensysml -am -DskipTests + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: "REHEARSAL: check the Central token" + command: | + auth=$(printf '%s:%s' "$CENTRAL_TOKEN_USERNAME" "$CENTRAL_TOKEN_PASSWORD" | base64 -w0) + code=$(curl -s -o /tmp/central.json -w '%{http_code}' \ + -H "Authorization: Bearer ${auth}" \ + "https://central.sonatype.com/api/v1/publisher/published?namespace=org.openmbee&name=opensysml&version=${JAVA_VERSION}") + case "$code" in + 200) echo "ok: the portal token authenticates" ;; + 401|403) + echo "Error: Central rejected the portal token (HTTP $code)." + exit 1 ;; + *) + echo "Error: could not ask Central (HTTP $code); refusing rather than guessing." + exit 1 ;; + esac + - run: + name: "REHEARSAL: build and sign without deploying" + command: | + # Never run this step with -X/debug output; the settings interpolation would print the token. + MAVEN_GPG_PASSPHRASE="$GPG_PASSPHRASE" mvn -B -f client/java/pom.xml \ + -Prelease verify -pl :opensysml -am -DskipTests + ls client/java/opensysml-client/target/*.jar.asc > /dev/null + ls client/java/*/target/*.pom.asc > /dev/null + - rehearsal-skip: + what: "mvn deploy (upload and publish to Central)" + + # Publishes the opensysml crate to crates.io from the core `v` tag. + # The token comes from the restricted 'crates.io' context. Everything that can fail runs + # before the upload: the tag/version check, the crates.io availability check, + # the credential, and the package dry run. + publish-crates: + executor: rust-executor + steps: + - checkout + - rehearsal-tag + + - run: + name: Resolve the version from the tag + command: | + if [ -z "${CIRCLE_TAG}" ]; then + echo "Error: CIRCLE_TAG is empty; this job only runs on a core v* tag." + exit 1 + fi + pkgid=$(cargo pkgid -p opensysml --manifest-path client/rust/Cargo.toml) + version=${pkgid##*[#@]} + # crates.io publishes the version Cargo.toml declares, so the tag + # must spell it exactly; build-python-package has already checked + # it against _version.py. + if [ "${CIRCLE_TAG}" != "v${version}" ]; then + echo "Error: tag ${CIRCLE_TAG} does not match client/rust/opensysml/Cargo.toml" + echo "version ${version}; the tag for it is v${version}. Nothing was published." + exit 1 + fi + # crates.io has no test registry, so a pre-release (0.9.1-rc.1…) + # is published as an ordinary version, which Cargo resolves only + # when asked for explicitly. + echo "export RUST_VERSION=${version}" >> "$BASH_ENV" + echo "Publishing opensysml ${version} to crates.io" + + - run: + name: Require the publishing credential + command: | + # Name only, never the value; the 'crates.io' context supplies it. + if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then + echo "Error: CARGO_REGISTRY_TOKEN is not set; add it to the 'crates.io' context (Organization Settings → Contexts)." + exit 1 + fi + + - run: + name: Refuse a version already on crates.io + command: | + code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H 'User-Agent: OpenSysML release (https://github.com/Open-MBEE/OpenSysML)' \ + "https://crates.io/api/v1/crates/opensysml/${RUST_VERSION}") + case "$code" in + 200) + echo "Error: opensysml ${RUST_VERSION} is already on crates.io;" + echo "a published version cannot be replaced, only yanked." + echo "Cut the next core release." + exit 1 ;; + 404) ;; + *) + echo "Error: could not ask crates.io (HTTP $code); refusing rather than guessing." + exit 1 ;; + esac + + - run: + name: Package the crate + command: | + cargo package -p opensysml --manifest-path client/rust/Cargo.toml --locked + + - unless: + condition: << pipeline.parameters.release_rehearsal >> + steps: + # The package step above already built and verified the crate; cargo + # reads CARGO_REGISTRY_TOKEN from the environment, so nothing is on disk. + - run: + name: Publish opensysml to crates.io + command: | + cargo publish -p opensysml --manifest-path client/rust/Cargo.toml \ + --locked --no-verify + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: "REHEARSAL: no cargo publish" + command: | + echo "REHEARSAL: crates.io has no read-only token check (GET /api/v1/me accepts only a browser session); the token's presence was checked above." + - rehearsal-skip: + what: "cargo publish" publish-github-release: docker: - image: cibuilds/github:0.13 steps: + # A rehearsal needs a checkout for _version.py; the tag path does not. + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - checkout + - rehearsal-tag - attach_workspace: at: . - - run: - name: Publish Release on GitHub - command: | - VERSION="${CIRCLE_TAG}" - if [ -z "$VERSION" ]; then - echo "Error: CIRCLE_TAG is empty" - exit 1 - fi - echo "Publishing release $VERSION" - ls -la dist/ + - unless: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: Publish Release on GitHub + command: | + VERSION="${CIRCLE_TAG}" + if [ -z "$VERSION" ]; then + echo "Error: CIRCLE_TAG is empty" + exit 1 + fi + echo "Publishing release $VERSION" + ls -la dist/ - # Move only tarballs to a release directory - mkdir -p dist/release - mv dist/*.tar.gz dist/*.zip dist/release/ 2>/dev/null || true - # The opensysml wheel, the same bytes publish-pypi uploads. - mv dist/*.whl dist/release/ - mv dist/grpc/* dist/release/ - mv dist/SHA256SUMS.txt dist/release/ - # The signature over that manifest, which the Python client verifies - # before it trusts a digest from it. - mv dist/SHA256SUMS.txt.bundle dist/release/ - echo "Release artifacts:" - ls -la dist/release/ + # Move only tarballs to a release directory + mkdir -p dist/release + mv dist/*.tar.gz dist/*.zip dist/release/ 2>/dev/null || true + # The opensysml wheel, the same bytes publish-pypi uploads. + mv dist/*.whl dist/release/ + mv dist/grpc/* dist/release/ + mv dist/SHA256SUMS.txt dist/release/ + # The signature over that manifest, which the Python client verifies + # before it trusts a digest from it. + mv dist/SHA256SUMS.txt.bundle dist/release/ + # The SLSA provenance statement over those assets and its signature. + mv dist/provenance.intoto.json dist/provenance.intoto.json.bundle dist/release/ + echo "Release artifacts:" + ls -la dist/release/ - # Try different token variable names - TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-${CIRCLE_TOKEN}}}" - if [ -z "$TOKEN" ]; then - echo "Error: No GitHub token found. Tried GITHUB_TOKEN, GH_TOKEN, CIRCLE_TOKEN" - exit 1 - fi + # Try different token variable names + TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-${CIRCLE_TOKEN}}}" + if [ -z "$TOKEN" ]; then + echo "Error: No GitHub token found. Tried GITHUB_TOKEN, GH_TOKEN, CIRCLE_TOKEN" + exit 1 + fi - # '-replace' reuses an existing release and only re-uploads assets of - # the same name, so hand-written notes, title and prerelease/latest - # flags survive a re-run. Never '-delete': that is an alias of - # '-recreate', which deletes the release *and its tag* and creates an - # empty one. A tag with no release yet still gets one created here. - ghr -t "${TOKEN}" \ - -u "${CIRCLE_PROJECT_USERNAME}" \ - -r "${CIRCLE_PROJECT_REPONAME}" \ - -c "${CIRCLE_SHA1}" \ - -replace \ - "${VERSION}" \ - dist/release/ + # '-replace' reuses an existing release and only re-uploads assets of + # the same name, so hand-written notes, title and prerelease/latest + # flags survive a re-run. Never '-delete': that is an alias of + # '-recreate', which deletes the release *and its tag* and creates an + # empty one. A tag with no release yet still gets one created here. + ghr -t "${TOKEN}" \ + -u "${CIRCLE_PROJECT_USERNAME}" \ + -r "${CIRCLE_PROJECT_REPONAME}" \ + -c "${CIRCLE_SHA1}" \ + -replace \ + "${VERSION}" \ + dist/release/ + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: "REHEARSAL: check the release assets and the GitHub token" + command: | + ls dist/SHA256SUMS.txt > /dev/null + set -- dist/opensysml-*-py3-none-any.whl + if [ ! -f "$1" ] || [ $# -ne 1 ]; then + echo "Error: expected exactly one dist/opensysml-*-py3-none-any.whl" + exit 1 + fi + ls dist/grpc/sysml-grpc-* > /dev/null + ls -la dist dist/grpc + TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-${CIRCLE_TOKEN}}}" + if [ -z "$TOKEN" ]; then + echo "Error: No GitHub token found. Tried GITHUB_TOKEN, GH_TOKEN, CIRCLE_TOKEN" + exit 1 + fi + code=$(curl -s -o /tmp/repo.json -w '%{http_code}' \ + -H "Authorization: Bearer ${TOKEN}" \ + "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}") + if [ "$code" != "200" ] || ! grep -Eq '"push": *true' /tmp/repo.json; then + echo "Error: the GitHub token cannot push releases (HTTP $code)." + exit 1 + fi + echo "ok: the release assets are present and the token can push releases" + - rehearsal-skip: + what: "ghr (the GitHub release)" workflows: version: 2 @@ -1827,6 +2435,14 @@ workflows: name: Rust client tests requires: *scan-inputs filters: *integration-branches + - julia-client: + name: Julia client tests + requires: *scan-inputs + filters: *integration-branches + - matlab-client: + name: MATLAB client tests + requires: *scan-inputs + filters: *integration-branches - java-test: name: Java client tests requires: *scan-inputs @@ -1854,14 +2470,19 @@ workflows: # Build and release on tags. The suite runs here too: a tag can point at any # commit, so a release is only published from a revision proven green. One tag - # publishes the binaries and the opensysml package, at the same version. + # publishes the binaries and every published client, at the same version. + # Branch pushes are kept out by the `when` below, not by job filters, so a + # release_rehearsal run can run the same jobs on a branch; the jobs still + # need a tag filter, because CircleCI runs no job on a tag without one. release: + when: + or: + - << pipeline.parameters.release_rehearsal >> + - matches: {pattern: "^v.*", value: << pipeline.git.tag >>} jobs: - go-static: name: Go static checks filters: &release-tags - branches: - ignore: /.*/ tags: only: /^v.*/ - go-race-test: @@ -1883,6 +2504,21 @@ workflows: - Go gates and binaries filters: *release-tags + - node-test: + name: Node client tests + requires: *go-suite + filters: *release-tags + + - java-test: + name: Java client tests + requires: *go-suite + filters: *release-tags + + - rust-test: + name: Rust client tests + requires: *go-suite + filters: *release-tags + # Fails the release before anything is built when the tag does not name # the version client/python/opensysml/_version.py declares. - build-python-package: @@ -1905,6 +2541,9 @@ workflows: requires: - Build release artifacts - Python client tests + - Node client tests + - Java client tests + - Rust client tests filters: *release-tags - publish-pypi: @@ -1921,54 +2560,49 @@ workflows: - Python client tests filters: *release-tags - # Publish the npm client on its own tag: the package resolves a service binary - # at run time, so a client fix should not need a core release and a core - # release should not need an npm publish. The binaries the platform packages - # carry are built here rather than downloaded from a release. - release-node: - jobs: - - go-static: - name: Go static checks - filters: &node-tags - branches: - ignore: /.*/ - tags: - only: /^client-node-v.*/ - - go-race-test: - name: Go race tests - filters: *node-tags - - go-coverage: - name: Go coverage profile - filters: *node-tags - - go-gates: - name: Go gates and binaries - filters: *node-tags - - - node-test: - name: Node client tests - requires: *go-suite - filters: *node-tags - - - build-node-binaries: - name: Build Node platform binaries - requires: *go-suite - filters: *node-tags - - publish-npm: - name: Publish npm client + name: Publish opensysml to npm + # Last of all, with publish-pypi: the npm publish cannot be undone, + # so it waits for the GitHub release the version promises to exist. + # It is independent of publish-pypi so neither registry refusing a + # re-run blocks the other. # The organization context is named 'npm'; a context reference is - # matched exactly, so the case here has to match it. A publish cannot - # be taken back, so it runs last and only on a revision proven green. + # matched exactly, so the case here has to match it. context: - npm requires: - - Go static checks - - Go race tests - - Go coverage profile - - Go gates and binaries + - Publish GitHub release - Node client tests - - Build Node platform binaries - filters: *node-tags + filters: *release-tags + + - publish-maven: + name: Publish opensysml to Maven Central + # Last of all, with publish-pypi and publish-npm: Central versions are + # immutable, so it waits for the GitHub release. It is independent of + # them so neither registry refusing a re-run blocks the other. + # The organization context is named 'Maven Central'; a context + # reference is matched exactly, so the case here has to match it. + context: + - "Maven Central" + requires: + - Publish GitHub release + - Java client tests + filters: *release-tags + + - publish-crates: + name: Publish opensysml to crates.io + # Last of all, with publish-pypi, publish-npm and publish-maven: + # a crates.io version cannot be replaced, so it waits for the + # GitHub release. It is independent of them so neither registry + # refusing a re-run blocks the other. The organization context is + # named 'crates.io'; a context reference is matched exactly, so the + # case here has to match it. + context: + - crates.io + requires: + - Publish GitHub release + - Rust client tests + filters: *release-tags # Publishes the final release of the pre-rename PyPI name on a `pysysml-v*` # tag. It is its own workflow because the artifact shares no code with the diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..7903bda725 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: 'https://numfocus.org/donate-to-openmbee' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 69045fbb27..40a1ee1b3b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -27,6 +27,12 @@ env: # z3 is installed below, so "no solver, therefore skip" would exercise nothing: # this turns an absent solver into a failure (see internal/exec/solve). OPENSYSML_REQUIRE_SMT: "1" + # The WebAssembly gate runs the wasm binaries under Node 24, set up by its jobs; + # without a runtime it would skip instead of proving they run (see tests/wasm). + OPENSYSML_REQUIRE_WASM: "1" + # Same for the Modelica Reference-FMUs, downloaded below and gated by + # tests/fmi and the Python runner's real test: absent means failure, not skip. + OPENSYSML_REQUIRE_REFERENCE_FMUS: "1" jobs: # Which areas the pull request touches. A path no area claims turns every area @@ -44,6 +50,8 @@ jobs: python: ${{ steps.areas.outputs.python }} java: ${{ steps.areas.outputs.java }} rust: ${{ steps.areas.outputs.rust }} + julia: ${{ steps.areas.outputs.julia }} + matlab: ${{ steps.areas.outputs.matlab }} vscode: ${{ steps.areas.outputs.vscode }} cameo: ${{ steps.areas.outputs.cameo }} syson: ${{ steps.areas.outputs.syson }} @@ -63,13 +71,18 @@ jobs: run: scripts/ci-changed-areas.sh "origin/$BASE_REF" HEAD | tee -a "$GITHUB_OUTPUT" race-tests: - name: Go race tests + name: Go race tests (${{ matrix.shard }}) needs: changes if: needs.changes.outputs.go == 'true' + # The shards split the suite's slowest packages across runners so each gets its own job cap (see scripts/race-shard.sh). + strategy: + fail-fast: false + matrix: + shard: [runtime, model, export, rest] runs-on: ubuntu-latest permissions: contents: read - timeout-minutes: 50 + timeout-minutes: 60 steps: - name: Check out repository uses: actions/checkout@v4 @@ -80,12 +93,19 @@ jobs: go-version-file: go.mod cache: true + # Node 24, as the WebAssembly gate pins: Node 22's WASI host crashes on the wasm binaries. + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Download Go modules run: go mod download # Pinned by digest, so a substituted asset is not run. The release binary rather # than `go run` keeps buf's own module tree out of the checksum-database path. - name: Install buf + if: matrix.shard == 'rest' run: | curl -fsSL --proto '=https' --proto-redir '=https' -o /tmp/buf \ https://github.com/bufbuild/buf/releases/download/v1.57.2/buf-Linux-x86_64 @@ -97,6 +117,7 @@ jobs: # classes, so without this check the committed stubs can drift from the schema. # `git add -N` puts a regenerated file the commit had deleted into the diff. - name: Verify the committed Go and Java stubs are current + if: matrix.shard == 'rest' run: | make proto-buf BUF=buf git add -N api/proto \ @@ -200,10 +221,63 @@ jobs: - name: Download the pilot library XMI run: ./scripts/download-pilot-library-xmi.sh - # Per-package timeout: under -race, passes and model run within 1% of go's 10m - # default. Matches `make test`. + # The FMI gate's simulator: the reference runner (client/python) is a + # Python subprocess, so the library it drives is installed here, pinned + # and wheel-only like every pip install of this workflow. + - name: Install fmpy + run: pip install --only-binary ":all:" fmpy==0.3.32 + + # The Modelica Reference-FMUs, checksummed; keyed on the download script + # and its pin. A restored cache is verified by the script's own check. + - name: Cache the Reference-FMUs + uses: actions/cache@v4 + with: + path: examples/reference-fmus + key: reference-fmus-${{ hashFiles('scripts/download-reference-fmus.sh', 'scripts/reference-fmus-pin.sh') }} + + - name: Download the Reference-FMUs + run: ./scripts/download-reference-fmus.sh + + # Per-package timeout: under -race the runtime package runs 22-29 minutes on + # these runners. Matches `make test-shard`. - name: Run Go race tests - run: make test + env: + SHARD: ${{ matrix.shard }} + run: make test-shard SHARD="$SHARD" + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.shard }} + path: coverage.txt + if-no-files-found: error + + race-coverage: + name: Go race coverage + needs: race-tests + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 5 + # The shard package sets are disjoint, so their blocks concatenate under one mode line. + steps: + - name: Download shard profiles + uses: actions/download-artifact@v4 + with: + pattern: coverage-* + path: shards + + - name: Merge the shard profiles + run: | + set -euo pipefail + profiles=(shards/coverage-*/coverage.txt) + # One per race-tests shard. + if [ "${#profiles[@]}" -ne 4 ]; then echo "error: expected 4 shard profiles, found ${#profiles[@]}" >&2; exit 1; fi + for f in "${profiles[@]}"; do + [ "$(head -n 1 "$f")" = "mode: atomic" ] || { echo "error: $f is not an atomic-mode profile" >&2; exit 1; } + done + { echo "mode: atomic"; for f in "${profiles[@]}"; do tail -n +2 "$f"; done; } > coverage.txt + wc -l coverage.txt - name: Upload coverage artifact uses: actions/upload-artifact@v4 @@ -356,6 +430,7 @@ jobs: git fetch --no-tags --depth=1 origin "$BASE_REF" make proto-breaking BUF_BREAKING_REF="origin/$BASE_REF" + # Race shards skip tests gated here; keep Makefile RACE_SHARD_SKIP and RACE_SHARD_TOOLS_SKIP in sync. # Re-run the corpus gate on its own so its verdict is legible in the log # and a skip is impossible to miss. TestCorpusGates is the shared # cache-independence case over all four OMG roots, which skips with them. @@ -438,6 +513,7 @@ jobs: # The RDF round-trip ratchet over every example on its own, so its # per-verdict counts are legible in the log and a skip must not pass. + # The round-trip gates stay in the race shards too: their worker pool converts files concurrently. - name: Run RDF corpus round-trip gate run: | set -o pipefail @@ -529,6 +605,41 @@ jobs: } done + wasm-gate: + name: WebAssembly gate + needs: changes + if: needs.changes.outputs.go == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '24' + + # It compiles and vets the whole tree for both wasm targets, links the commands + # and runs them under Node, so a skip has to be visible and has to fail. + - name: Run the WebAssembly gate + run: | + set -o pipefail + node --version + go test -count=1 -v ./tests/wasm | tee wasm-gate.log + if grep -qE '^\s*--- SKIP' wasm-gate.log; then + echo "error: the WebAssembly gate skipped" >&2 + exit 1 + fi + # The client jobs download this binary rather than building one, so it runs # whenever the service or any client does. build: @@ -540,6 +651,8 @@ jobs: needs.changes.outputs.python == 'true' || needs.changes.outputs.java == 'true' || needs.changes.outputs.rust == 'true' || + needs.changes.outputs.julia == 'true' || + needs.changes.outputs.matlab == 'true' || needs.changes.outputs.cameo == 'true' || needs.changes.outputs.syson == 'true' runs-on: ubuntu-latest @@ -593,14 +706,18 @@ jobs: - conformance-pkg - docs - java-test + - julia-client + - matlab-client - syson-plugin - node-test - pdf-toolchain - python-test - race-tests + - race-coverage - rust-test - static-and-integrity - vscode-extension + - wasm-gate runs-on: ubuntu-latest permissions: contents: read @@ -614,14 +731,18 @@ jobs: CONFORMANCE_PKG_RESULT: ${{ needs.conformance-pkg.result }} DOCS_RESULT: ${{ needs.docs.result }} JAVA_TEST_RESULT: ${{ needs.java-test.result }} + JULIA_CLIENT_RESULT: ${{ needs.julia-client.result }} + MATLAB_CLIENT_RESULT: ${{ needs.matlab-client.result }} SYSON_PLUGIN_RESULT: ${{ needs.syson-plugin.result }} NODE_TEST_RESULT: ${{ needs.node-test.result }} PDF_TOOLCHAIN_RESULT: ${{ needs.pdf-toolchain.result }} PYTHON_TEST_RESULT: ${{ needs.python-test.result }} RACE_TESTS_RESULT: ${{ needs.race-tests.result }} + RACE_COVERAGE_RESULT: ${{ needs.race-coverage.result }} RUST_TEST_RESULT: ${{ needs.rust-test.result }} STATIC_AND_INTEGRITY_RESULT: ${{ needs.static-and-integrity.result }} VSCODE_EXTENSION_RESULT: ${{ needs.vscode-extension.result }} + WASM_GATE_RESULT: ${{ needs.wasm-gate.result }} run: | ok() { [ "$1" = success ] || [ "$1" = skipped ]; } if [ "$CHANGES_RESULT" != success ] || @@ -630,28 +751,36 @@ jobs: ! ok "$CONFORMANCE_PKG_RESULT" || ! ok "$DOCS_RESULT" || ! ok "$JAVA_TEST_RESULT" || + ! ok "$JULIA_CLIENT_RESULT" || + ! ok "$MATLAB_CLIENT_RESULT" || ! ok "$SYSON_PLUGIN_RESULT" || ! ok "$NODE_TEST_RESULT" || ! ok "$PDF_TOOLCHAIN_RESULT" || ! ok "$PYTHON_TEST_RESULT" || ! ok "$RACE_TESTS_RESULT" || + ! ok "$RACE_COVERAGE_RESULT" || ! ok "$RUST_TEST_RESULT" || ! ok "$STATIC_AND_INTEGRITY_RESULT" || - ! ok "$VSCODE_EXTENSION_RESULT"; then + ! ok "$VSCODE_EXTENSION_RESULT" || + ! ok "$WASM_GATE_RESULT"; then echo "changes=$CHANGES_RESULT" echo "build=$BUILD_RESULT" echo "cameo-plugin=$CAMEO_PLUGIN_RESULT" echo "conformance-pkg=$CONFORMANCE_PKG_RESULT" echo "docs=$DOCS_RESULT" echo "java-test=$JAVA_TEST_RESULT" + echo "julia-client=$JULIA_CLIENT_RESULT" + echo "matlab-client=$MATLAB_CLIENT_RESULT" echo "syson-plugin=$SYSON_PLUGIN_RESULT" echo "node-test=$NODE_TEST_RESULT" echo "pdf-toolchain=$PDF_TOOLCHAIN_RESULT" echo "python-test=$PYTHON_TEST_RESULT" echo "race-tests=$RACE_TESTS_RESULT" + echo "race-coverage=$RACE_COVERAGE_RESULT" echo "rust-test=$RUST_TEST_RESULT" echo "static-and-integrity=$STATIC_AND_INTEGRITY_RESULT" echo "vscode-extension=$VSCODE_EXTENSION_RESULT" + echo "wasm-gate=$WASM_GATE_RESULT" exit 1 fi @@ -727,7 +856,7 @@ jobs: OPENSYSML_KATEX: ${{ github.workspace }}/build/doc-pdf/katex/node_modules/.bin/katex OPENSYSML_DOT: ${{ github.workspace }}/build/doc-pdf/graphviz/bin/dot OPENSYSML_PLANTUML_JAR: ${{ github.workspace }}/build/doc-pdf/plantuml/plantuml-1.2026.8.jar - run: go test -count=1 -v -run Installed ./internal/doc/docpdf + run: go test -count=1 -v -run Installed ./internal/doc/docpdf ./tests/migrate vscode-extension: name: VS Code extension @@ -787,7 +916,7 @@ jobs: run: chmod +x bin/sysml-grpc - name: Install the Java client the plugin builds against - run: mvn -B -q -f client/java/pom.xml -pl opensysml-client -am install -DskipTests + run: mvn -B -q -f client/java/pom.xml -pl :opensysml -am install -DskipTests # Compiles against the compile-only OpenAPI stubs and runs the unit tests plus # the pipeline test against the freshly built service. @@ -851,6 +980,9 @@ jobs: - name: Check changelog fragments run: python3 scripts/changelog-test.py && python3 scripts/changelog.py check + - name: Check the release provenance writer + run: python3 scripts/release-provenance-test.py + # The compliance census is counted when the site is built, never committed. - name: Check the compliance census hook run: python3 scripts/mkdocs_census-test.py @@ -991,12 +1123,30 @@ jobs: cp bin/sysml-grpc ~/.opensysml/bin/ chmod +x ~/.opensysml/bin/sysml-grpc + # The verification-questions tests ask the service solver-backed questions; + # the solver is an external process the service finds on PATH. + - name: Install Z3 + run: | + sudo apt-get update + sudo apt-get install -y z3 + z3 --version + - name: Install Python client run: | make python-install # Pinned and wheel-only: an unpinned resolve runs whatever was # published today, and a source distribution runs its own build code. - pip install --only-binary :all: pytest==9.0.3 pytest-mock==3.15.1 psutil==7.2.2 + pip install --only-binary :all: pytest==9.0.3 pytest-mock==3.15.1 psutil==7.2.2 fmpy==0.3.32 + + # The FMI runner test's FMUs, same pin as the Go gate's. + - name: Cache the Reference-FMUs + uses: actions/cache@v4 + with: + path: examples/reference-fmus + key: reference-fmus-${{ hashFiles('scripts/download-reference-fmus.sh', 'scripts/reference-fmus-pin.sh') }} + + - name: Download the Reference-FMUs + run: ./scripts/download-reference-fmus.sh # The integration tests connect to a service on the standard port with # auto_start=False, the explicit opt-in to one the client does not manage; @@ -1136,6 +1286,111 @@ jobs: name: conformance-report-rust path: bin/conformance-report-rust.json + # Mirrors the CircleCI julia-client job: Pkg.test on the two supported Julia + # versions plus the conformance suite, against the binary the build job made. + julia-client: + name: Julia client tests + needs: [changes, build] + if: needs.changes.outputs.julia == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + julia-version: ['1.10', 'lts'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: binaries + path: bin + + # The artifact is a zip, which does not carry the executable bit. + - name: Install the sysml-grpc binary + run: chmod +x bin/sysml-grpc + + - name: Set up Julia ${{ matrix.julia-version }} + uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0 + with: + version: ${{ matrix.julia-version }} + + - name: Run Julia tests + working-directory: client/julia/OpenSysML + env: + OPENSYSML_GRPC_BINARY: ${{ github.workspace }}/bin/sysml-grpc + run: julia --project=. -e 'using Pkg; Pkg.test()' + + - name: Run Julia conformance + env: + OPENSYSML_GRPC_BINARY: ${{ github.workspace }}/bin/sysml-grpc + run: | + julia --project=client/julia/OpenSysML client/julia/OpenSysML/conformance/run.jl \ + --binary "$GITHUB_WORKSPACE/bin/sysml-grpc" \ + --report bin/conformance-report-julia.json + + - uses: actions/upload-artifact@v4 + with: + name: conformance-report-julia-${{ matrix.julia-version }} + path: bin/conformance-report-julia.json + + # Mirrors the CircleCI matlab-client job: the tests and the conformance suite + # under GNU Octave — MATLAB itself is not available in CI, and its + # matlab.net.http path is exercised by no gate. The CI build of Octave is + # built without Java, so the private-child path cannot run there: the job + # starts a service itself and hands the runner its address. + matlab-client: + name: MATLAB/Octave client tests + needs: [changes, build] + if: needs.changes.outputs.matlab == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: binaries + path: bin + + # The artifact is a zip, which does not carry the executable bit. + - name: Install the sysml-grpc binary + run: chmod +x bin/sysml-grpc + + - name: Install GNU Octave + run: | + sudo apt-get update + sudo apt-get install -y octave + + - name: Start sysml-grpc + run: | + bin/sysml-grpc -port 0 -health-port 0 -report-address > bin/octave-svc-addr & + svc_pid=$! + echo "svc_pid=$svc_pid" >> "$GITHUB_ENV" + for i in $(seq 1 100); do [ -s bin/octave-svc-addr ] && break; sleep 0.1; done + test -s bin/octave-svc-addr + + - name: Run MATLAB client tests + working-directory: client/matlab/tests + run: | + addr=$(cat "$GITHUB_WORKSPACE/bin/octave-svc-addr") + OPENSYSML_SERVICE="$addr" octave --no-gui --eval "run_tests" + + - name: Run MATLAB conformance + run: | + addr=$(cat "$GITHUB_WORKSPACE/bin/octave-svc-addr") + octave --no-gui --eval "addpath('client/matlab'); addpath('client/matlab/conformance'); addpath('client/matlab/conformance/private'); run_conformance('--address','$addr','--report','bin/conformance-report-matlab.json')" + + - name: Stop sysml-grpc + if: always() + run: kill "$svc_pid" 2>/dev/null || true + + - uses: actions/upload-artifact@v4 + with: + name: conformance-report-matlab + path: bin/conformance-report-matlab.json + # Mirrors the CircleCI java-test job: unit tests plus the conformance suite over # both Connect encodings, on the client's Java 17 baseline. java-test: diff --git a/.gitignore b/.gitignore index 16ab8e708c..8315ccb47b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,12 +12,18 @@ # Rendered documentation site (make docs) /site/ +# Julia package environments resolve locally; the pinned deps are in Project.toml. +client/julia/**/Manifest.toml + # Downloaded training examples (run scripts/download-training-examples.sh) /examples/sysml-v2-training/ # Downloaded OMG corpora (run scripts/download-pilot-corpora.sh) /examples/pilot-corpora/ +# Downloaded Modelica Reference-FMUs (run scripts/download-reference-fmus.sh) +/examples/reference-fmus/ + # Python /src/ # Legacy Python layout shadow directory (superseded by client/python/) __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index b223d8e3a0..14a324aec3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ make build # build bin/sysml and bin/sysml-lsp (with version ldflags) make build-sysml # REPL binary only make build-lsp # LSP binary only make test # full suite: go test -race -coverprofile ... ./... +make test-shard SHARD=runtime # one CI shard of the race suite (runtime|model|export|rest) make test-short # faster, no race detector make clean # remove build artifacts ``` @@ -68,6 +69,12 @@ migrator is gated over: fetch it with `./scripts/download-pssm-suite.sh` and run variable must run the matching download script first; the scripts are idempotent, and none reports success over an empty corpus. +The Modelica Reference-FMUs gate the FMI integration the same way: fetch them with +`./scripts/download-reference-fmus.sh` (into `examples/reference-fmus/`, gitignored), install +the runner's simulator with `pip install fmpy`, and run +`go test -count=1 ./tests/fmi -run TestReferenceFMUs`. CI sets +`OPENSYSML_REQUIRE_REFERENCE_FMUS=1`. + All four roots share one mechanism (`tests/corpus/corpus_gate_test.go`) but two policies, and the difference is deliberate: the training corpus is **asserted** clean, so its expectation file holds no per-file counts and `-update-training` refuses to record one, while diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a3aed684..ce24179aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,521 @@ release is described in [docs/project/releasing.md](docs/project/releasing.md). ## Unreleased +## 0.9.1 — 2026-09-26 + +### Added + +- **Add connection-like usages through `ApplyEdits`.** Python exposes `Editor.add_connection`, `add_allocation` and `add_flow`; Go and Java expose `AddConnection`. The operation requires both the `authoring` and `connection_authoring` capabilities. + +- **Extend source-preserving authoring with member modifiers, satisfy usages and requirement constraints.** ApplyEdits now supports grammar-checked modifiers and requirement statements, with dedicated service capabilities and client preflight. + +- **A `cameo` drawing style draws the DOT form as Cameo Systems Modeler draws a diagram.** `-render-style cameo` beside `-render-palette` on `-render`, `-render-all`, `-render-document` and `-render-documents`, `%render dot [palette] cameo` and `%render-document dot cameo` in the REPL, `"style": "cameo"` on `opensysml/render` (the styles listed under the new `openSysmlRenderStyles` capability) and `cameo` in the VS Code panel's **Style** list (`opensysml.diagram.style`) all draw a diagram frame with the `stm [State Machine] Owner [ Name ]` header tab, 11 pt Arial, Cameo's gradient fills — pale yellow states, green actions, orange blocks — under thin dark borders, a state's `do / Activity` compartment beneath a rule, a block's «stereotype» line and compartments, rounded composite states with dashed region dividers, the initial dot, final bull's-eye, decision diamond and fork bars, `trigger [guard] / effect` transition labels and notes as folded boxes on a dashed anchor, every colour measured from Cameo's own pages and recorded in the rendering-forms design note. `pilot`, the Pilot visualizer's Standard B&W, stays the default; Mermaid and PlantUML note any other style as not represented. +- **`DiagramLayout::Style` and `DiagramLayout::Note` carry a diagram's colours, fonts and notes.** A `Style` on a member (`fill`, `line`, `text` colours, `font`, `fontSize`, `bold`, `italic`) is drawn over whichever style the DOT form draws in, explicit values winning over the look's defaults; a `Note` on a view (`text`, `x`, `y`, `width`, `height`) about a member is drawn beside it with a dashed anchor. The validation pass checks both as it checks a `Layout`, `opensysml/render` reports a node's or edge's `style`, and `opensysml/applyModelEdit` takes `setStyle` as it takes `setLayout`. +- **A migrated diagram is laid out, coloured and annotated from its own MagicDraw stream.** `sysml Project.mdzip -convert sysml` reads each diagram's `mdOwnedViews` symbols for the frame and every symbol's geometry, each path's breakpoints and end symbols, the fill, pen and text colours and font each symbol was drawn with, and the text of every note anchored to a drawn element, and writes them as `DiagramLayout` `Canvas`, `Layout`, `Route`, `Style` and `Note` metadata on the migrated view, so no MTIP export is needed to keep a Cameo diagram's shape; an MTIP `-layout` record still overrides the stream for every element it places or routes, the stream supplying the rest. The migration report counts the symbols positioned, routed, styled and noted, and the free symbols it dropped — pasted images (their class, geometry and attachment name are exposed for a later change) and unanchored text boxes. +- **A migrated document publishes to PDF with Graphviz figures at Cameo fidelity.** `sysml Project.sysml -render-document 'Project::DesignDescription' -doc-form pdf -diagram-form dot -render-style cameo` runs Graphviz (`OPENSYSML_DOT`) under `neato -n2` for a diagram whose every member is placed and routed, and embeds the SVG; the migration guide's new *Publishing a migrated document with Cameo-style diagrams* section gives the command, the toolchain variables and what the path does not carry. + +- Documents gain an `Image` content block (`location` a path relative to the document's file or an http(s)/file URL, optional `caption` and `alt`), rendered as a CommonMark image under its caption in Markdown, a `
` in HTML and the drawn image in PDF — a missing local file is a `missing-image` error naming the block. +- The SysML v1 migrator writes View Editor image paragraphs — comments stereotyped MagicDraw «AttachedFile», or carrying an `` — as `DocumentQueries::Image` blocks, and copies the attached bytes beside the notation under `images/` when `-o` names a file (stdout or a Flexo target reports the requirement instead of dropping them); an attached file no archive entry holds is refused with the file named. `-image-base-url` resolves a comment body's relative `` — a path the View Editor serves — to a remote `Image` location (a documentation `` is documentation text and is not shown); without it the paragraph keeps its text with a note naming the flag; a figure whose diagram draws nothing but whose note holds an `` becomes an `Image` block the same way. + +- Added thin Julia and MATLAB clients for `sysml-grpc` (`client/julia/OpenSysML` and + `client/matlab`), each a JSON-over-HTTP client of the Connect-JSON surface with a conformance + runner driving every scenario — the Julia client from a private child or a named service, the + MATLAB client from MATLAB R2019b+ or GNU Octave 7+ (an Octave built without Java cannot spawn + a private child, so Octave uses a named service). `make conformance-julia` and + `make conformance-matlab` run the suite. + +- **Pictures pasted onto a Cameo diagram survive migration.** `sysml Project.mdzip -convert sysml -o Project.sysml` decodes the bytes MagicDraw serializes into each `ImageShape` symbol's `` tag (space-separated hexadecimal octets), writes them beside the notation under `images/` named after the pasted file with the suffix the bytes' content type calls for (the same bytes written once), and draws each where Cameo drew it as `@DiagramLayout::Picture { location; x; y; width; height; alt; above }` on the migrated view — under the element symbols, or `above = true` for a sticker over them. A diagram of pictures alone is a view drawing them, so a document figure of it shows the pictures under the diagram's name instead of being left out; a symbol naming a file the archive does not hold is reported as before, and octets that do not read are an `unmapped` row for the symbol. The report's layout note says what was written per diagram and its summary counts the pasted images. +- **`DiagramLayout::Picture` places an image on a view.** `metadata def Picture { location; x; y; width; height; alt; above }` in the DiagramLayout library draws the file at `location` (relative to the view's file) at stated bounds on any view; the validation pass checks its bounds, that it annotates a view and that `location` is a file's path — a URL, which no drawing tool reads, is refused rather than handed to Graphviz as a file name — the DOT form pins it as an image node (inlined as a data URI when the SVG is embedded in an HTML or PDF document, so the page stays self-contained — only a file whose bytes are a recognised image, an SVG being one well-formed document with an `svg` root; any other file stays a path), and the Mermaid and PlantUML forms count it in their `not represented` notice. +- **`-doc-number-figures` numbers figures and tables.** Captions read `Figure 1. ` for a drawn diagram or an `Image` block and `Table 1. ` for a query table or a table-kind diagram, in document order, alike in the Markdown, HTML (the number in a `sysml-caption-number` span) and PDF forms; off by default, so existing renderings are unchanged. + +- **Document queries read a feature through a member nested in the row element.** A `Column` expression may be a feature chain — `'Monte Carlo'.runs`, `stat.runs`, `outer.inner.value` — and a `properties`/`property` string may hold the same `.`-joined path; each segment names a member of the element reached so far, own members before inherited ones. A row lacking a segment makes the path absent on that row alone (an empty cell or a `??` default), a member declaring no value is an empty cell, a multi-valued member fills the cell — more than its multiplicity admits fails the column as a direct feature column does — and `OrderBy` sorts by the path; a path no row reaches stays an unknown-property error. This is what lets a migrated individual's nested usage, such as a Monte Carlo analysis's `runs`, surface as a document column. + +- **Queries expose the requirement and satisfying feature of a satisfy usage.** Select `satisfiedRequirement` and `satisfyingFeature` to follow each end of the satisfy relationship. + +- **The satellite-network stress model can be generated as a fleet.** `tools/cmd/stress-model -fleet` + declares each orbital plane as occurrences of one of four spacecraft blocks — `part sats : + BlockA[400] ordered` — with the as-built values as the block's defaults and stated only on the + units that diverge, instead of one `part def` per satellite; the spacecraft, ground segment, + requirements and state machine are unchanged, and the ring, inter-plane and downlink connectors + are declared once over each collection with `[1]` ends rather than once per satellite pair. `-stats` + now reports the spacecraft definitions, the units carrying values of their own and the satisfy + assertions in both forms, so the two can be compared: at 12 800 satellites the fleet declares 12 467 elements against + 2 354 827, and validates in 0.70 s and 184 MB rather than 331 s and 20.3 GB. A guide chapter, + `docs/guide/modeling-fleets.md`, shows the constellation both ways and what the + runtime does with 12 800 occurrences, and the stress-test record and performance notes carry the + measurements. `BenchmarkFleetInstantiate` and `BenchmarkFleetSatisfy` in `tests/stressmodel` + time the runtime over the fleet form. + +- **Tools get a dry run.** `%tool [()] []` at the prompt and `-tool-dry-run ` at the CLI show what the external tool a run's first `ToolExecution` names would be given — manifest, executable, argv, environment, working directory, standard input, input file and reply mapping — with the model's current values, without starting the process, then discards everything the preview performed; a manifest fault, an unregistered tool or an input the call does not send reports the same typed error the real run would fail with. +- **Runs record the tools they reached, and `-engines` spells the protocol.** `-record-run`/`%record` write every external tool call a run made into its `AnalysisRecords::RecordedRun` provenance as `tools`, one element per call in call order (`tool version from manifest: executable argv`); the `protocol` column a `-engines`/`%engines`/`ListEngines` listing shows for a tool spells how its manifest entry composes the process — `object` for the one-JSON-object exchange, or `argv+` with a `/` suffix such as `argv+none/csv` for an `invocation`/`reply` block. +- **A worked example walks the whole loop.** `examples/external-tool-demo/` registers a small Python solver as a tool, previews its invocation with `-tool-dry-run`, records a run — tools included — and renders the recorded run in a document; `docs/manual/running-external-programs.md` tells the same story end to end. + +- **`ToolExecution` on a `calc def` or calc usage.** A `calc def` or calc usage annotated `metadata ToolExecution { toolName = "…"; uri = "…"; }` is computed by the named tool wherever a calc is invoked — `sysml -calc`, `%calc`, `EvaluateCalc`, derived attributes and document formulas — its `in` parameters sent by their `ToolVariable` names, its `out` parameters and result parameter (under its `ToolVariable` name, else its declared name, else `result`) bound from the reply. The body never evaluates: a tool unregistered, refusing or failing fails the calculation with the same typed errors a performance gets, and equal inputs answered differently are noted as a divergence. + +- **A design note on running programs not written for the tool protocol** + (`docs/internals/design/bring-your-own-engines.md`, section *Tools: composed invocations and + structured replies*). Two optional blocks of the `OPENSYSML_TOOLS` entry are specified: + `invocation`, which composes the command line, environment, working directory, standard input + and an input file from the values the model binds, through a placeholder grammar with its + rendering and escaping rules over a minimal base environment; and `reply`, which reads the + outputs from JSON (by pointer), CSV (by column and row), key–value lines, or the exit status, + on standard output or in a file the tool wrote, with the typed error each fault raises and + where it is named. The note also specifies sequence-valued outputs, `ToolExecution` on a + `calc def`, the `-tool-dry-run`/`%tool` surfaces, the recorded provenance, the security + invariants and the delivery order. Nothing is implemented; an entry without the two blocks + keeps today's meaning byte for byte, and the analysis-framework note points to the new section. + +- **Tool manifests compose the external tool's command from the model's values.** An optional `invocation` block on an `OPENSYSML_TOOLS` entry renders `args`, `env`, a `cwd`, the standard input (`json`, `none`, `csv` or a template) and an `inputFile` from templates over the declared variables (`{mass}`, `{mass.value}`, `{mass.unit}`), the annotation's `{toolName}` and `{uri}`, and a per-invocation `{inputFile}` and `{outputDir}`, with `{{`/`}}` for literal braces. Each rendered argument is exactly one `argv` entry — no shell, no splitting — the working directory is confined to the manifest's directory as the executable is, and the process environment is `PATH`, `HOME`, `TMPDIR`, `LANG` plus the names `OPENSYSML_TOOL_ENV_PASSTHROUGH` lists and the block's own; the invocation's directory is removed once the reply is read unless `OPENSYSML_TOOL_KEEP=1`. A placeholder naming an undeclared variable is a manifest fault; a declared variable the performance did not send fails it before the process starts. The reply, timeout, size bounds and divergence report are unchanged, an entry without the block runs exactly as before, and `-engines` lists the protocol as `argv+json`, `argv+none`, `argv+csv` or `argv+template` beside `object`. + +- **Tool manifests read the external tool's reply in four more formats.** An optional `reply` block on an `OPENSYSML_TOOLS` entry says how a tool answers once its process ran: `json` resolves each output by an RFC 6901 pointer into one JSON document, `csv` reads a cell by column and data row (with `header`, `delimiter`, `errorColumn` and a `unitColumn`), `lines` reads a `key = value` or `key: value` line or an RE2 named group, and `exitcode` reads the process's exit status as a Boolean against `success` or as an Integer. `source` is standard output by default or `file: