A release is cut by pushing a v* tag. Everything after that is CircleCI: the
release workflow runs the test suite, cross-compiles sysml, sysml-lsp and
sysml-grpc for five platforms, builds the Python client's wheel and sdist, and
publishes all of them to a GitHub release and the package to PyPI. Nothing is
published from a laptop.
The Python client is released in lockstep with the core: the same v<version> tag
publishes opensysml <version> to PyPI, and the workflow refuses to build anything
unless client/python/opensysml/_version.py declares that version — see
Releasing opensysml to PyPI. A caller who pins one
version therefore gets the package and the sysml-grpc binary that were tested
together.
The same v* tag also publishes the Node client to npm as @openmbee/opensysml
at the same version — see
Releasing @openmbee/opensysml to npm — and
the Java client to Maven Central as org.openmbee:opensysml — see
Releasing the Java client to Maven Central —
and the Rust client to crates.io as opensysml, all at the same version — see
Releasing the Rust client to crates.io.
No client keeps a tag of its own any more.
A second tag, pysysml-v*, publishes the one-off final release of the client's
pre-rename PyPI name — see The final pysysml release.
The public Go API in client/opensysml has no release of its
own: it is part of this module, so the core's v* tag is what a Go program pins.
Between releases, .github/workflows/nightly.yml builds the newest green develop
commit every night with scripts/build-release-artifacts.sh — the same targets,
platforms and layout as build-release below, minus the Python distribution, which
only a release publishes — and publishes it as the moving
prerelease nightly, never marked latest and signed by the workflow's own GitHub
identity rather than the CircleCI one the clients pin. It touches nothing described on
this page: the nightly tag matches neither the v* filter of the release workflow
nor the Windows signing workflow, and releases/latest keeps resolving to the stable
line. Nightly snapshots documents it for a user; when build-release
changes what it produces, change the script so the two stay the same.
Run the full gate on the commit you intend to tag:
gofmt -l . # must print nothing
go build ./...
go vet ./...
make lint # staticcheck + gosec, as CircleCI runs
go test -race -count=1 ./...
go test -run TestStdlibConformance ./internal/workspace/libsRun the Python client the way CircleCI's python-test job does, since a release
gates on the same suite:
make build-grpc && mkdir -p ~/.opensysml/bin && cp bin/sysml-grpc ~/.opensysml/bin/
pip install -e client/python/ && pip install pytest pytest-mock psutil
pytest client/python/tests/ -vThe OMG training-corpus gate skips while the corpus is absent, so fetch it and run it explicitly — the expected result is the pinned baseline, currently 100/100 files clean:
./scripts/download-training-examples.sh
OPENSYSML_REQUIRE_TRAINING_CORPUS=1 go test -count=1 ./tests/corpus -run TestTrainingExamplesA change in that count is a finding to adjudicate file by file, never a baseline to regenerate.
Then check the release-facing text:
- The changelog fragments under
changes/unreleased/are folded into a dated entry for this version:python3 scripts/changelog.py release X.Y.Z(or--date YYYY-MM-DD) appends them to the## Unreleasedsection, renames it, and deletes the fragments. CommitCHANGELOG.mdand the deletions together. The previous version's entry stays unchanged. WhetherX.Y.Zbumps the patch or the minor segment is decided by model compatibility, as CONTRIBUTING.md § Versioning states. README.mdanddocs/guide/transcripts match what the binary prints. Build it (make build-sysml) and paste a few commands through it.python3 scripts/check-doc-links.pyreports no broken link (CI gates on it too).- Test counts match a real run in the two surfaces that type them at a release
(
docs/project/roadmap.md's gate table anddocs/project/training-examples.md; the compliance map's test inventory is counted from the tree when the site is built, and everything else links to it, per CONTRIBUTING.md), and no compliance row claims more than the implementation does. Count first-level subtests: a case that registers sub-subtests, likevariant_connection_per_owner, otherwise counts twice.
The release workflow also runs on a branch — release/X.Y.Z — when the
boolean pipeline parameter release_rehearsal is true. Every job runs with
every pre-upload check live: the suite, the version lockstep, the registry
availability checks, the credential checks, the npm whoami, the GitHub and
Central token probes, the GPG key import and test-sign, the npm packs,
cargo package and the Maven build-and-sign. Only the irreversible commands
are skipped: cosign keyless signing and attestation (which write to the public
Rekor log), ghr (the GitHub release), twine upload, npm publish,
mvn deploy and cargo publish. A rehearsal exports a stand-in CIRCLE_TAG
from _version.py before any step reads it, so the version bumps must already
be on the branch.
Trigger it from the CircleCI UI with Trigger pipeline: choose the release
branch for both the config and the checkout source, then add the boolean
parameter release_rehearsal = true. Or by API:
curl -X POST "https://circleci.com/api/v2/project/<project-slug>/pipeline/run" \
-H "Circle-Token: $CCI_TOKEN" -H 'Content-Type: application/json' \
-d '{"definition_id": "<pipeline definition id>",
"config": {"branch": "release/X.Y.Z"},
"checkout": {"branch": "release/X.Y.Z"},
"parameters": {"release_rehearsal": true}}'The project slug and the pipeline definition id are under Project Settings →
Project Setup. Whoever triggers it must be authorized for all four contexts
(PyPI, npm, Maven Central, crates.io), because the same jobs run with
the same contexts. The built binaries are kept only as the pipeline's CircleCI
artifacts; nothing reaches a registry or a GitHub release.
A green rehearsal proves the tag will not fail on builds, tests, version
lockstep, registry availability, credential presence, npm/Central/GitHub
token auth, the GPG key and passphrase with real Maven signing, npm packing,
or cargo package. It cannot prove the PyPI and crates.io token validity (no
read-only check exists for either), cosign keyless signing (skipped because it
writes to Rekor), the uploads themselves, Central publish permission beyond
token auth, or a version someone publishes between the rehearsal and the tag.
Day-to-day work merges into develop; main carries releases only (see
CONTRIBUTING.md § Branches). A release is a
branch that moves the integration state onto main:
-
Cut
release/x.y.zfromdevelop:git checkout develop && git pull git checkout -b release/0.0.5 -
Fold the changelog fragments on that branch —
python3 scripts/changelog.py release 0.0.5, as Before tagging describes — and commitCHANGELOG.mdtogether with the deleted fragments. SetVERSIONinclient/python/opensysml/_version.pytox.y.zas well: the tag publishesopensysmlat the core version, and the release workflow fails before building anything when the two disagree (see Releasing opensysml to PyPI). Also set"version"inclient/node/package.json— and the five platform packages inoptionalDependencies— to the SemVer spelling of the same version (0.9.1;0.9.0-rc.1for0.9.0rc1), and runnpm install --package-lock-onlyinclient/nodeso the lockfile agrees; the release workflow fails before building anything when package.json disagrees.client/java/pom.xmlfollows the same version too: set the parent pom's<version>, both modules'<parent><version>, and the client version ineditors/mdk/pom.xml(opensysml.client.version) andeditors/syson/backend/pom.xmlto the same spelling as package.json.client/rust/opensysml/Cargo.tomlfollows too: set[package] versionto the same spelling and runcargo update -p opensysmlinclient/rustso the lockfile agrees. The editors carry the same spelling too, though nothing publishes them:"version"ineditors/vscode/package.jsonandeditors/syson/frontend/package.json, each lock regenerated withnpm install --package-lock-onlyin that directory;<version>ineditors/mdk/pom.xmlandeditors/syson/pom.xml; and<parent><version>in their child poms (editors/mdk/plugin,editors/mdk/tools,editors/mdk/openapi-stubs,editors/mdk/dist,editors/syson/backendandeditors/syson/syson-api-stubs).check_version.py --editorsinbuild-python-packagefails the release early when any of them disagrees. Anything else the release needs (a doc that names the version) lands here too; a feature does not. Check the wire compatibility against the released schema, not the branch's own source:make proto-breaking BUF_BREAKING_REF=origin/main(the default baseline isorigin/develop; the pull-request workflow uses the base branch, so the PR tomainmakes the same comparison). -
Open a pull request from
release/x.y.ztomainand merge it once the pull-request workflow is green. Merging intomainruns CircleCI'sbuild-testworkflow over the merged tree. -
Tag
mainas Tagging describes. -
Merge
mainback intodevelop— a plain merge, no rebase — so the folded changelog, and any hotfix that landed onmainin the meantime, flow down:git checkout develop && git pull git merge main git push origin develop
A hotfix/ branch follows the same path from main: cut from main, pull
request to main (make proto-breaking BUF_BREAKING_REF=origin/main locally, as above),
tag, merge back into develop.
The tag is the version: CircleCI passes CIRCLE_TAG to the build as
VERSION, so sysml --version reports it. Rehearse first — see
Rehearsing the release.
git checkout main && git pull
git tag -a v0.0.5 -m "v0.0.5"
git push origin v0.0.5The tag belongs on Open-MBEE/OpenSysML, the repository the releases live on
and where development happens: every release from v0.0.1 on is tagged on its
main. The clients resolve releases from that repository
(DEFAULT_GITHUB_REPO in client/python/opensysml/binary.py), so a tag pushed
to a fork builds a release nobody consumes.
Tags are matched by /^v.*/ in .circleci/config.yml. A tag on a commit that
fails the suite fails the release workflow before anything is published, and so
does a tag whose version client/python/opensysml/_version.py,
client/node/package.json, client/java/pom.xml or
client/rust/opensysml/Cargo.toml or an editor manifest does not declare.
build-release produces, in dist/:
- per-binary archives —
sysml-<os>-<arch>.tar.gz,sysml-lsp-<os>-<arch>.tar.gz(.zipon Windows); - bundle archives —
opensysml-<os>-<arch>.tar.gzholding both binaries under their plain names, which is the layout Homebrew and a PATH install expect, plus their section 1 manual pages undershare/man/man1(the Unix archives only; the Windows bundle has no use for them, andsysml-grpc.1stays out of a bundle that does not carrysysml-grpc); sysml-grpc-<os>-<arch>, published raw with a.sha256sidecar rather than archived, because that is whatopensysmldownloads and verifies (client/python/opensysml/binary.py) when it starts the service for a Python caller;- the Python client's distribution,
opensysml-<x.y.z>-py3-none-any.whlandopensysml-<x.y.z>.tar.gz, built bybuild-python-packageand the same filespublish-pypiuploads (see Releasing opensysml to PyPI); SHA256SUMS.txtover every archive, the wheel and everysysml-grpcbinary, with its cosign signatureSHA256SUMS.txt.bundle(see The signed checksum manifest);provenance.intoto.json, the SLSA provenance statement naming every artifact the manifest lists, andprovenance.intoto.json.bundle, its cosign attestation (see The release provenance).
Platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64.
Before any of it is stored or published, build-release runs each host-platform
binary and fails the release unless --version reports CIRCLE_TAG. The ldflags
are the only thing stamping the tag into a binary, and a binary reporting dev
or a stale tag looks the same on the release page as a correct one — that is how
an artifact whose version disagreed with its tag reached a release once already.
The check runs the linux/amd64 builds; the cross-compiled ones cannot run on the
executor, so each is checked for the tag string the ldflags write into it. The
wheel and sdist are checked by name: both carry the version in their file name,
and build-python-package has already imported the wheel and compared
opensysml.__version__ to the tag.
publish-github-release uploads them with ghr, using a token from
GITHUB_TOKEN, GH_TOKEN or CIRCLE_TOKEN in the CircleCI project settings.
It runs with -replace, so re-running the workflow for the same tag replaces
that release's assets rather than appending duplicates, and leaves everything
else on the release alone: notes, title and the prerelease/latest flags survive.
A tag that has no release yet still gets one created.
publish-pypi runs after it, off the same built artifacts, and is the one step
of a release that cannot be repeated: PyPI never accepts a version twice, so on a
re-run of a published tag it fails by design while the GitHub assets are replaced
(see What the jobs do, in order). Running it after
the GitHub release means the package version never exists without the release
it names; if the GitHub upload fails, nothing irreversible has happened yet.
publish-npm runs beside it, also after the GitHub release and also not
repeatable: npm never accepts a version twice. It publishes the five
@openmbee/opensysml-sysml-grpc-<os>-<cpu> platform packages built from
build-release's dist/grpc binaries — the same bytes the release ships — and
then the @openmbee/opensysml client (see
Releasing @openmbee/opensysml to npm).
publish-maven runs beside them, in the same position and with the same
one-way property: a Central version can never be replaced. It signs, uploads
and publishes org.openmbee:opensysml and its opensysml-parent pom,
waiting until Central reports the deployment published (see
Releasing the Java client to Maven Central).
publish-crates runs beside them, in the same position and with the same
one-way property: a crates.io version cannot be replaced, only yanked. It
packages and publishes opensysml (see
Releasing the Rust client to crates.io).
Do not go back to -delete. It is an alias of -recreate: it deletes the
existing release and its tag and creates an empty one, which wipes
hand-written release notes (the notes must therefore be on a published release —
ghr does not see a draft release for the tag and would publish a second, empty
one alongside it).
-
Verify a download on at least one platform:
curl -fLO https://github.com/Open-MBEE/OpenSysML/releases/download/v0.0.5/opensysml-linux-amd64.tar.gz curl -fLO https://github.com/Open-MBEE/OpenSysML/releases/download/v0.0.5/SHA256SUMS.txt sha256sum -c SHA256SUMS.txt --ignore-missing tar xzf opensysml-linux-amd64.tar.gz && ./sysml --version--versionmust report the tag, notdev.Then check the path
opensysmltakes, since it reads the sidecar rather thanSHA256SUMS.txt:OPENSYSML_GITHUB_REPO=Open-MBEE/OpenSysML python -c \ "from opensysml.binary import download_binary; print(download_binary('latest'))" ~/.opensysml/bin/sysml-grpc -version
A checksum mismatch there means the sidecar and the binary came from different builds.
For a release no published
opensysmlpins, that path depends on the signature, so check it the way the client does:curl -fLO https://github.com/Open-MBEE/OpenSysML/releases/download/v0.0.5/SHA256SUMS.txt.bundle cosign verify-blob SHA256SUMS.txt --bundle SHA256SUMS.txt.bundle \ --certificate-oidc-issuer https://oidc.circleci.com/org/1169df8b-0b59-400f-82d2-c9d8e98bdb62 \ --certificate-identity-regexp '^https://circleci\.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d/pipeline-definitions/[0-9a-f-]+$'A missing bundle means
build-releasedid not sign — re-run the tag's workflow rather than pinning around it.The provenance is checked the same way, against the downloaded archive rather than the manifest:
curl -fLO https://github.com/Open-MBEE/OpenSysML/releases/download/v0.0.5/provenance.intoto.json.bundle cosign verify-blob-attestation opensysml-linux-amd64.tar.gz \ --bundle provenance.intoto.json.bundle --type slsaprovenance1 \ --certificate-oidc-issuer https://oidc.circleci.com/org/1169df8b-0b59-400f-82d2-c9d8e98bdb62 \ --certificate-identity-regexp '^https://circleci\.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d/pipeline-definitions/[0-9a-f-]+$'Then install the Python client the release published, from the index rather than the source tree, and run it against the release's own
sysml-grpc— the pairing a user who pins one version gets (see Verifying an upload):python -m venv /tmp/opensysml-verify && . /tmp/opensysml-verify/bin/activate pip install opensysml==0.0.5 OPENSYSML_GRPC_VERSION=v0.0.5 python -c \ "import opensysml; print(opensysml.__version__, opensysml.load('examples/state-machine-demo.sysml').diagnostics)"
-
Verify the npm upload. Check the registry sees all six packages at the version and the right dist-tag:
npm view @openmbee/opensysml@0.0.5 version dist-tags
Then install it in a temp dir and load a model with
OPENSYSML_BINARYunset, so the per-platform package is what supplies the binary:mkdir /tmp/npm-verify && cd /tmp/npm-verify && npm init -y npm install @openmbee/opensysml@0.0.5 node --input-type=module -e " import { loads } from '@openmbee/opensysml'; const model = await loads('package V { part def P; }'); console.log(model.diagnostics); await model.close();"
-
Verify the Maven Central upload. Central can take up to ~30 minutes to answer (search indexing later), so check the pom's URL:
curl -sI https://repo1.maven.org/maven2/org/openmbee/opensysml/0.0.5/opensysml-0.0.5.pom
Then a consumption check resolves it the way a consumer does:
mvn dependency:get -Dartifact=org.openmbee:opensysml:0.0.5
-
Verify the crates.io upload. Check the API sees the version:
curl -s -H 'User-Agent: OpenSysML release (https://github.com/Open-MBEE/OpenSysML)' \ https://crates.io/api/v1/crates/opensysml/0.0.5Then a consumption check resolves it the way a consumer does, in a throwaway crate:
cargo new /tmp/crates-verify && cd /tmp/crates-verify cargo add opensysml@=0.0.5 && cargo fetch
-
Let the Homebrew tap pick the release up. The tap repository
Open-MBEE/homebrew-tapupdates itself: a scheduled workflow there resolves the latestOpen-MBEE/OpenSysMLrelease, rendersFormula/opensysml.rbfrom this repository'sscripts/render-homebrew-formula.shand formula template at that tag, and commits only when the file changed. Nothing here triggers it, so the formula follows the release within the workflow's schedule interval.If it does not, check the workflow run in the tap repository. The render reads the release's
SHA256SUMS.txt, so a release missing that asset (or missing aopensysml-<os>-<arch>.tar.gzline in it) fails the run loudly instead of committing a broken formula — re-runpublish-github-releasefor the tag and then the tap workflow (workflow_dispatch). Rendering by hand still works:scripts/render-homebrew-formula.sh v0.0.5 > Formula/opensysml.rb -
Say what is not signed. macOS binaries are not Developer ID signed or notarized, so a browser download trips Gatekeeper. Point release notes at MACOS_DISTRIBUTION.md, which gives the workarounds and what signing would take. Windows binaries are Authenticode signed through SignPath Foundation once the application below is approved; until then, and for a release whose signing request nobody approved, only the unsigned Windows assets exist and SmartScreen warns — say so in the notes.
-
Approve the Windows signing request. When SignPath is configured, the tag also runs
release-windows.yml, which parks a signing request in SignPath until an Approver approves it (see Windows Authenticode signing). No approval, no*-signed*assets on the release. -
Check the Windows installer landed. The same workflow builds the MSI (see The Windows installer) once CircleCI has published the release:
opensysml-<x.y.z>-windows-amd64.msiwithSHA256SUMS-windows-msi.txtwhen SignPath is not configured, oropensysml-<x.y.z>-windows-amd64-signed.msilisted inSHA256SUMS-windows-signed.txtwhen it is. A release with neither means the workflow failed (WiX, the Z3 download, ICE validation or the signing request); re-run it after fixing the cause — see the recovery path in The Windows installer. -
Render the Windows package-manager manifests when a maintainer wants to (re)submit them externally. Nothing here submits anything:
scripts/render-scoop-manifest.sh v0.0.5 > opensysml.json scripts/render-winget-manifests.sh v0.0.5 out/ scripts/render-msys2-pkgbuild.sh v0.0.5 > PKGBUILD
The procedure for each external repository is in packaging/scoop, packaging/winget and packaging/msys2.
build-release signs dist/SHA256SUMS.txt — the manifest covering every
published artifact, including each sysml-grpc-<os>-<arch> — with cosign
keyless, and publish-github-release uploads the sigstore bundle beside it as
SHA256SUMS.txt.bundle. The certificate identity comes from the job's CircleCI
OIDC token exchanged with Fulcio, so no signing key exists anywhere: nothing
to provision, rotate or leak. The job then verifies its own bundle and fails the
release rather than publish a signature clients would reject.
That signature is what lets the Python and Node clients install a core release published after them. For a release a client pins no digest for, it downloads the manifest and the bundle, verifies the bundle, and takes the asset's digest from the verified manifest. The only signature accepted is this pipeline's:
| OIDC issuer | https://oidc.circleci.com/org/1169df8b-0b59-400f-82d2-c9d8e98bdb62 |
| Certificate subject | https://circleci.com/api/v2/projects/eeb0dddd-237f-4f02-9e51-8e24caef589d/pipeline-definitions/<pipeline definition> |
The issuer is CircleCI's OIDC issuer for the organization that owns this
project, and the subject is the pipeline definition that ran the job, which is
what CircleCI puts in the certificate. The client currently accepts any pipeline
definition of that project, because no signature of the real one exists to read
the identifier off yet — the verify step in build-release prints it, so after
the first signed release set it as definition= on the signer in
client/python/opensysml/signing.py and in client/node/src/node/signing.ts
to narrow the pin to the one pipeline.
Anything short of a verified manifest is refused exactly as an unpinned release
is today: no bundle asset, a bundle that does not verify, another signer, a
manifest changed after signing, an expired certificate, or sigstore not
installed. The .sha256 served beside a binary is still never a reason to trust
it — same origin as the binary — and remains behind
$OPENSYSML_ALLOW_UNPINNED_DOWNLOAD.
build-release also writes a SLSA provenance
statement over the same artifacts and signs it under the same identity.
scripts/release-provenance.py reads dist/SHA256SUMS.txt and writes
dist/provenance.intoto.json: an in-toto Statement v1 whose subjects are every
artifact the manifest lists, with the manifest's digest, and whose predicate
(https://slsa.dev/provenance/v1) records what built them — the repository
and tag (externalParameters), the commit the tag resolved to
(resolvedDependencies), the CircleCI organization, project and workflow
(internalParameters), the project as the builder (runDetails.builder.id)
and the job's URL as the invocation. The build type,
https://github.com/Open-MBEE/OpenSysML/.circleci/build-release/v1, names this
repository's own job; its version moves when what the job does changes. The
script refuses to write a statement from an empty or malformed manifest, or
without every one of the CircleCI variables it describes the build from, so a
vaguer statement is never published in place of the intended one.
cosign attest-blob --statement then signs that statement as it stands — every
subject kept, nothing re-derived — into a DSSE envelope in a sigstore bundle,
provenance.intoto.json.bundle, keylessly under the job's CircleCI OIDC
identity, exactly as the manifest is signed. The job verifies its own
attestation against three published artifacts (a bundle archive, a sysml-grpc
binary and the wheel) under the identity the clients pin, and checks that the
subjects are the manifest's lines, no more and no fewer, before anything is
stored; publish-github-release uploads the statement and the bundle beside
SHA256SUMS.txt.
What this is, and is not. The statement is produced by the build that produced
the artifacts, on CircleCI's hosted runners, and signed with an identity only
that pipeline can hold, so a verifier learns which repository, tag and commit a
downloaded file was built from and which job built it — SLSA Build L2. It is not
Build L3: CircleCI does not itself issue provenance, so the statement is
generated by the job it describes rather than by the platform outside it, and
nothing stops a change to .circleci/config.yml from changing what is written.
That is why the buildType is versioned and why the trust anchor stays the
certificate identity: a statement signed by anything but this project's
pipeline verifies as nothing. A provenance workflow that hashes downloaded
assets on another platform would attest that platform's download, not this
build, and is not what this is.
The unsigned provenance.intoto.json is a convenience for reading; the
authoritative statement is the bundle's payload:
jq -r '.dsseEnvelope.payload' provenance.intoto.json.bundle | base64 -d | jq .The Python and Node clients keep reading the signed manifest, not the provenance; nothing in them changes.
The policy users see is the Code signing policy in the README; this is the maintainer side of it. SignPath Foundation signs open-source Windows binaries for free, on two conditions this section keeps satisfied: the binaries must be built by a build system SignPath can verify the origin of, and each signing request must be approved by hand.
Why GitHub Actions, and why CircleCI stays. SignPath verifies the origin of
an artifact through a trusted build system connector, and its supported
systems are GitHub Actions, GitLab, Jenkins, Azure DevOps, TeamCity and
AppVeyor — not CircleCI. So the Windows binaries a release signs are rebuilt by
.github/workflows/release-windows.yml
on the same v* tag, with the Makefile targets and the exact VERSION,
COMMIT, BUILD_TIME and GO_VERSION derivation build-release uses, so
both builds stamp the same version, commit and Windows VERSIONINFO (only the
build timestamp differs).
CircleCI keeps publishing everything it publishes today, unsigned Windows zips
included, together with SHA256SUMS.txt and its cosign bundle.
The signed files are additional assets — sysml-windows-amd64-signed.zip,
sysml-lsp-windows-amd64-signed.zip, sysml-grpc-windows-amd64-signed.exe
(with a .sha256 sidecar, as the unsigned one has) and
opensysml-windows-amd64-signed.zip, listed in SHA256SUMS-windows-signed.txt.
They do not replace the unsigned ones, on purpose: the cosign-signed manifest is
what the Python and Node clients trust, and its certificate identity is this
project's CircleCI pipeline. A GitHub Actions job cannot re-sign that manifest
under the CircleCI identity, and overwriting sysml-windows-amd64.zip with a
signed zip would leave SHA256SUMS.txt describing bytes that are no longer on
the release. The -signed names keep every line of the manifest true and every
existing download link and client pin working. SHA256SUMS-windows-signed.txt
is a convenience for humans; the verifiable statement about a signed file is its
Authenticode signature (Get-AuthenticodeSignature in PowerShell, or
osslsigncode verify), and opensysml keeps downloading the unsigned
sysml-grpc-windows-amd64.exe it can verify against the manifest.
Applying. A maintainer applies once at https://signpath.org/apply
with the repository URL https://github.com/Open-MBEE/OpenSysML. The
conditions at https://signpath.org/terms ask for what the README's policy
section provides: an OSI-approved license (Apache-2.0), the sentence naming
SignPath.io and SignPath Foundation, the Authors / Reviewers / Approvers roles
with links to the GitHub teams that hold them, the privacy statement, and MFA
for everyone in those roles. Before applying, make sure the three teams the
README links to actually exist in the Open-MBEE organization (or edit the
README to the teams that do) and that every member has MFA enabled on GitHub.
Configuring, once approved. SignPath creates an organization for the
project; in it, create a project for this repository with an artifact
configuration describing a zip of .exe files to be Authenticode-signed (the
workflow uploads the three executables as one artifact, and the metadata
restriction should require ProductName OpenSysML), a release signing
policy with manual approval, and a trusted build system link to GitHub Actions
for Open-MBEE/OpenSysML following
https://docs.signpath.io/trusted-build-systems/github. Then, in the GitHub
repository settings:
| Where | Name | Value |
|---|---|---|
| Secret | SIGNPATH_API_TOKEN |
the API token of the SignPath CI user for the project |
| Variable | SIGNPATH_ORGANIZATION_ID |
the SignPath organization ID (a GUID) |
| Variable | SIGNPATH_PROJECT_SLUG |
the project slug, e.g. OpenSysML |
| Variable | SIGNPATH_SIGNING_POLICY_SLUG |
the release policy slug, e.g. release-signing |
| Variable | SIGNPATH_MSI_ARTIFACT_CONFIGURATION_SLUG |
the artifact configuration for the MSI (see The Windows installer) |
With any of the first four missing the workflow builds the binaries, checks
their VERSIONINFO against the tag, keeps them as a workflow artifact, builds
and publishes the unsigned MSI, and stops: nothing is submitted to SignPath.
With the four present but the fifth missing, the msi-signed job fails on
purpose rather than publish an MSI of signed executables under a name that
claims the MSI itself is signed. Try it before the
first real tag with Run workflow (workflow_dispatch), which stamps the
version input instead of a tag and never publishes; tick submit to also
exercise the SignPath round trip, which creates a real signing request an
Approver has to approve or deny.
Every release needs an approval. On a v* tag the workflow first waits
(up to 90 minutes) for publish-github-release to put SHA256SUMS.txt.bundle
on the release and checks that the tag still resolves to the commit it built —
CircleCI publishes only after the suite and build-release passed on the tag,
so a tag CircleCI rejected is never signed. It then submits the artifact and
waits (up to about four hours) for the request to complete.
An Approver — a member of the Approvers team listed in the README, with MFA on
their SignPath account — opens the request in SignPath, checks that it points at
the expected commit and workflow run, and approves it. The job then downloads
the signed executables, checks their VERSIONINFO still carries the tag,
packages the -signed assets and uploads them to the release with
softprops/action-gh-release, overwriting only assets of those names. If the
request is denied or the wait times out, the job fails and the release simply
has no signed Windows assets; re-run the job after the request is approved, or
leave it unsigned and say so in the notes. SignPath also revokes signing for
projects whose Authors, Reviewers or Approvers do not keep MFA enabled, so keep
the team membership current.
VERSIONINFO. SignPath enforces the metadata a signed file carries.
packaging/windows/<cmd>.winres.json holds the static fields (ProductName
OpenSysML, CompanyName, FileDescription, LegalCopyright,
OriginalFilename), and the Makefile's build-sysml, build-lsp and
build-grpc targets run go-winres (pinned by GO_WINRES_VERSION, a build
tool that ends up nowhere in the product) for GOOS=windows only, writing
cmd/<cmd>/rsrc_windows_<arch>.syso with ProductVersion and FileVersion
set to the same VERSION the -ldflags carry. The .syso files are ignored
by Git and by every non-Windows build. make windows-versioninfo-check EXE=dist/sysml-windows-amd64.exe VERSION=v0.5.0 extracts the resource from a
built binary and fails unless all of that is true; the workflow runs it on the
unsigned and again on the signed executables.
packaging/msi/opensysml.wxs (WiX Toolset v5, plain MSI, no bootstrapper) and
scripts/build-msi.sh produce opensysml-<x.y.z>-windows-amd64.msi: a
per-machine x64 installer of sysml.exe, sysml-lsp.exe, LICENSE.txt and,
as separately deselectable features, sysml-grpc.exe and the Z3 solver
(z3\z3.exe with its runtime DLLs and LICENSE-z3.txt), both directories on
the system PATH. MajorUpgrade makes a newer MSI replace an older install;
the ProductVersion is the tag without v and without any pre-release suffix
(v0.4.0-rc1 and v0.4.0 are both 0.4.0, and the later one wins). Details,
feature ids and the msiexec incantations are in
packaging/msi/README.md.
Where it is built, and why not in CircleCI. WiX v5 runs only on Windows (its
cabinet builder is a Win32 executable and the toolset rejects Linux paths), so
the MSI cannot come out of the cimg/go release job. It is built by
release-windows.yml on a windows-latest runner, in two shapes:
- SignPath not configured: the
msijob builds the MSI from the unsigned executables the workflow built, runswix msi validate(ICE), and thepublish-msijob uploads it withSHA256SUMS-windows-msi.txt— after the same CircleCI gate the signing job uses, so the MSI never lands on a release CircleCI did not publish. Both jobs run on every tag and onworkflow_dispatch(which publishes only when itstaginput names one). - SignPath configured: the
msi-signedjob rebuilds the MSI from the SignPath-signed executables, validates it, submits the MSI itself to SignPath underSIGNPATH_MSI_ARTIFACT_CONFIGURATION_SLUG, andpublish-signeduploads it asopensysml-<x.y.z>-windows-amd64-signed.msi, listed inSHA256SUMS-windows-signed.txtwith the other-signedassets. The unsigned MSI is then kept only as a workflow artifact, so a release never carries two installers whose contents differ only by signature.
Re-running it for an existing tag. When the workflow failed on a tag —
including at git checkout, since the Windows runners cannot check out a
tracked path Windows forbids — fix the cause on a hotfix/ branch to main,
then re-run it against the tag with gh workflow run release-windows.yml --ref main -f tag=v0.9.1. The dispatch builds the tagged commit on the Linux
job, and the Windows MSI jobs check nothing out — they consume packaging/msi,
scripts/build-msi.sh and LICENSE, uploaded by that job as a workflow
artifact — so even a tag whose tree Windows cannot check out gets its
installer. The release gate still requires the tag to resolve to the built
commit, and overwrite_files touches only the MSI assets, so the
CircleCI-published release and its assets are never rewritten.
The trade-off is the one the -signed assets already make: the MSI is not in
SHA256SUMS.txt or the cosign bundle, because those are produced by CircleCI
from the bytes CircleCI built, and an MSI built elsewhere (and, when signed,
from different executable bytes) must not be described by a manifest that did
not hash it. The unsigned MSI has its own SHA256SUMS-windows-msi.txt; for the
signed one the verifiable statement is its Authenticode signature. Nothing the
clients download or pin changes.
SignPath and the MSI. Add a second artifact configuration to the SignPath
project: a single .msi file, Authenticode-signed, and put its slug in the
SIGNPATH_MSI_ARTIFACT_CONFIGURATION_SLUG variable. The MSI's executables are
already signed by the first request, so this second request signs only the
installer. z3.exe and its DLLs are never signed: SignPath Foundation's
terms allow unsigned upstream open-source binaries inside a signed installer but
not signing them with the Foundation certificate, so the artifact configuration
for the MSI must not descend into its contents. Each release therefore parks
two signing requests for an Approver (executables, then the MSI).
Updating the bundled Z3. packaging/msi/z3.pin pins the Z3 release
(Z3_VERSION, the z3-<ver>-x64-win.zip asset name and its SHA256) in one
place; the build script refuses a zip whose hash differs. The update procedure —
fetch the new zip, hash it yourself, update the pin, check the zip's bin/ still
has the files the .wxs lists — is in
packaging/msi/README.md. Bump
it deliberately, in its own PR with a changelog fragment naming the new Z3
version: it changes what every installer ships.
The table in client/release-digests.json, which every client ships a synced
copy of, still covers the releases published before signing existed, and it
stays the override: where a pin exists
it wins, and a verified manifest that disagrees with a pin is an error rather
than a downgrade. Per release there is now nothing to do — pinning a release
signed by the pipeline is optional. Pinning still works, and is worth doing for
a release clients on an older opensysml should be able to install:
export GITHUB_TOKEN=... # must be able to read this repository's releases
python client/python/scripts/pin_release_checksums.py --version v0.0.8 --writeThe token is required, not an optimization: the script reads the release's assets
through the GitHub releases API, and unauthenticated calls are rate-limited per
address and fail as an opaque HTTP 403. GH_TOKEN is read as well. The scope
needed is read access to this repository's releases — public_repo for a classic
token, Contents: read for a fine-grained one; nothing is written through the
API. Without either variable the script fails immediately with
MissingTokenError naming the variable, rather than at the first request.
Not a release step — the scan job runs in the build-test workflow on every
commit, after go-static, go-coverage, go-gates, python-test,
java-test and node-test. It does not wait on go-race-test: a race-run
failure used to hide the scan entirely, and nothing the race run produces
reaches the analysis — but it is documented here with the other CircleCI
credential plumbing.
It waits on the three client jobs because each writes a coverage report the scan reads: a language whose report is absent has every one of its lines counted as uncovered, which is what dropped new-code coverage to 54.8% on the 0.4.0 analysis while the suites themselves were passing.
The job references the organization context named exactly SonarCloud, which
supplies SONAR_TOKEN (the same context Open-MBEE/flexo-mms-layer1-service
uses, so no new credential is provisioned). It reads
sonar-project.properties at the repository root and four coverage reports
persisted to the workspace — coverage.txt from go-coverage,
coverage-python.xml from python-test, coverage-node.lcov from
node-test, and JaCoCo's jacoco.xml per Java module — and it un-shallows the
clone because SonarCloud needs full history for blame and new-code detection.
The Go profile is written with -coverpkg=./... so a package is credited for
the code it exercises elsewhere; without it internal/syntax/ast/dump.go
measures 21% though the parser's golden tests run 90% of it.
java-test also persists each module's target/classes, target/test-classes
and a target/dependency directory it fills with dependency:copy-dependencies
(the Maven repository itself is that job's cache, not the workspace). The Java
sensor resolves types from those, and without them it warns about missing
sonar.java.binaries/sonar.java.libraries and degrades to a syntactic
analysis, so the scan job fails if any of the six directories is empty.
sonar.python.version names the range client/python/pyproject.toml declares,
because unset the Python sensor assumes every Python 3 version and drops the
rules that depend on one.
On a forked PR the context is withheld, so SONAR_TOKEN is empty; the job
halts successfully rather than failing every outside contribution. When the
token is present, a failing scan fails the job.
The job checks out with method: full, and that is load-bearing: CircleCI's
default checkout is a blobless partial clone, and Sonar blames every file with
JGit, which cannot fetch a blob on demand — against a blobless clone the scan
dies with MissingObjectException: Missing blob ... in the SCM publisher. A
step after the checkout fails the job if the clone is partial or missing an
object reachable from HEAD, so a blame that would silently date every issue to
the import is reported as the configuration error it is. Other jobs read only
the current tree and keep the faster default.
The job runs on a large container — 8 GB, the largest class in the plan —
whose memory is split between three processes. SONAR_SCANNER_JAVA_OPTS: -Xmx5500m sizes the forked analysis JVM the sonar-scanner-cli 8 launcher
starts: Sonar's Go sensor parses one directory at a time and holds that
directory's parser output in memory, so a large package
(internal/exec/runtime) exhausted the scanner's default heap with
java.lang.OutOfMemoryError before it was raised. SONAR_SCANNER_OPTS: -Xmx256m sizes the launcher itself and carries -D properties such as the
project version, and sonar.javascript.node.maxspace=1024 caps the Node
process the JS/TS sensor spawns, whose default 2.2 GB the ~60 TypeScript
files do not need.
A scan that dies with EXECUTION FAILURE and exit 3, with no Java exception
in its log, is the container's OOM-killer, not the analysis — an analysis
heap near the container size plus an uncapped Node heap has done this at the
moment the JS/TS sensor starts its Node process. A when: always step right
after the scan prints the cgroup memory counters, where an OOM kill shows as
oom_kill 1 rather than being guessed at.
The scan step itself reproduces what the sonarsource/sonarcloud orb did —
download the pinned sonar-scanner-cli 8.0.1.6346 into a cache keyed on the
version, chmod the launcher and its JRE — but inline, so the scanner runs
through a one-retry wrapper: a log line matching a transient SonarCloud API
or network failure (HTTP 5xx, JRE-metadata query failure, timeouts, resets)
sleeps 30 seconds and tries once more, while an analysis failure exits with
the scanner's status on the first attempt. Each attempt's log is stored as an
artifact. The orb is no longer used.
The quality gate is mostly conditions on new code — coverage, the security and
reliability ratings, duplication — so which lines are new decides whether it is
red, and the gate says nothing useful if that set is wrong. The project uses
SonarCloud's default period, previous version, whose baseline is the last
analysis carrying a version other than the current one. An analysis that names
no version carries the placeholder not provided: every analysis then looks
like the same version, no earlier one differs, and the period silently falls
back to the first analysis ever run. That happened here — the analysis of
2026-08-27 counted 105,050 of 110,609 lines as new, so "coverage on new code"
was whole-project coverage measured against a 70% threshold that project-wide
coverage is not held to, and the gate was red for it.
The scan job therefore derives the version from the nearest release tag that
is an ancestor of HEAD (git describe --tags --abbrev=0 --match 'v[0-9]*',
without the v) and passes it as -Dsonar.projectVersion in
SONAR_SCANNER_OPTS. New code is then what has been committed since that
release was first analyzed, and cutting a release moves the baseline forward on
its own: the first analysis after a v* tag reports a version the previous
analyses did not, which is exactly the boundary the period wants. The job fetches
tags explicitly, because CircleCI's checkout fetches only the ref being built.
Two failure modes are worth recognizing, since neither fails the job. If no v*
tag is an ancestor of HEAD the step says so and leaves the version unset,
which is the fallback above. And if the version stops reaching the server, the
period collapses again: check it with
curl -s "https://sonarcloud.io/api/project_analyses/search?project=Open-MBEE_OpenSysML&ps=1"whose projectVersion must be the release number, not not provided.
One-time maintainer step (already done for Open-MBEE_OpenSysML, but true of
any future project): SonarCloud does not create a project from a CI-run scan
(the scanner sends branch parameters, and Cloud cannot provision from those —
the first run fails with Could not find a default branch for project with key '...'). Create the project under the organization first, either from the
SonarCloud UI or with POST api/projects/create followed by
POST api/project_branches/rename, using a token that has Create Projects in
that organization.
The Python client in client/python/ is published to PyPI as
opensysml by the release workflow — the
same v<version> tag that publishes the binaries, and at the same version: v0.9.0
publishes opensysml 0.9.0. Nothing is uploaded from a laptop, and no other tag
publishes the package. Releases up to 0.5.0 were cut on a tag of their own,
opensysml-v<version>, which the workflow no longer matches; the version line
before that carries on from pysysml 0.2.0, which was the same client, so no version
number is reused.
opensysml does not ship the service: it downloads a sysml-grpc binary at
runtime for whatever release the caller names (version=,
$OPENSYSML_GRPC_VERSION, or latest), verifying it against the digest it pins
for that release (its copy of client/release-digests.json) or, for a
release it pins nothing for, against the digest in the release's signed
SHA256SUMS.txt (see the signed checksum manifest).
That flexibility is what makes an uncoordinated pair hard to test: a package at one
version against a service at another is a combination nobody ran the suite on.
Releasing the two together from one tag means every opensysml version has a core
release of the same version, tested with it in the same pipeline, and a caller who
wants exactly that pairing pins one number:
pip install opensysml==0.9.0
export OPENSYSML_GRPC_VERSION=v0.9.0The wheel and sdist go on the GitHub release too, listed in the signed
SHA256SUMS.txt, so the release page holds every deliverable of that version.
The cost is that a client-only fix is a core release (a patch tag, with the
binaries rebuilt from the same source), and that one step of a release is
irreversible: publish-github-release runs ghr -replace, so re-running a tag's
workflow replaces the GitHub assets, while a PyPI version can be yanked but never
re-uploaded. publish-pypi therefore refuses a version the index already has, and
on a re-run of a published tag that job fails by design while the rest of the
workflow succeeds — the package was already published from the same revision, so
nothing is missing. The upload runs last, only after the whole suite has passed on
the tagged revision and the GitHub release is published, so a failure anywhere
else — a rebuild, an expired GitHub token — never leaves a package on PyPI whose
release does not exist.
client/python/opensysml/_version.py is the only declaration:
client/python/pyproject.tomlhasdynamic = ["version"]and readsopensysml._version.VERSION(there is nosetup.pyany more —pyproject.tomldeclares the build);opensysml.__version__reports that declaration, which ships beside the module and is therefore the version of the code being imported. A wheel's metadata is generated from it, so the two agree there; an editable install's dist-info is written once, at install time, and a checkout that bumpsVERSIONafterwards would otherwise report the version it had whenpip install -eran.
client/python/tests/test_version.py fails if a second version literal reappears
anywhere under client/python/, or if the declaration, the installed metadata and
__version__ stop agreeing. Where the install is editable, the tests locate the
package through the install's own PEP 610 record (opensysml/_dist.py) rather than
the dist-info's directory, which for an editable install is a site-packages path
holding no opensysml/ at all.
The tag must name the declared version. client/python/scripts/check_version.py is run
by build-python-package before anything is built, and fails loudly otherwise:
python client/python/scripts/check_version.py --tag v0.9.0 # prints 0.9.0So setting VERSION in client/python/opensysml/_version.py to the version being
released is a step of the release branch, beside folding the
changelog; a v* tag pushed while the two disagree fails the release before a binary
is built. The tag is SemVer and the declaration is PEP 440 in canonical form, so the
check translates the tag before comparing: v0.9.0 names 0.9.0, and a pre-release
tag v0.9.0-rc1 (or v0.9.0-rc.1) names 0.9.0rc1, which is what VERSION must say
(0.9.0-rc1 is refused, since the build tools would name the files 0.9.0rc1 anyway).
Only -alpha.N, -beta.N and -rc.N are accepted as pre-release suffixes, the ones
with a single PEP 440 meaning; a tag like v0.9.0-1 is refused rather than read as
the post-release 0.9.0.post1 and sent to PyPI proper.
python client/python/scripts/check_version.py --tag v0.9.0-rc1 # prints 0.9.0rc1The token lives in a restricted context, not in project environment variables, so only the release path can read it:
- In CircleCI, Organization Settings → Contexts, in the context named
PyPI(create it if the organization does not have it yet). A context reference in the config is matched exactly, so the name must be spelled with the same case in both places. - Restrict it to a security group (Contexts →
PyPI→ Add security group) so only that group's members can run a job that uses it. A context with no group restriction is readable by every project job. - Add the token as
PYPI_API_TOKEN(an environment variable in that context).TWINE_USERNAMEis__token__, set by the job; only the token value belongs in the context. - Optionally add
TEST_PYPI_API_TOKEN, a TestPyPI token, which is what a pre-release tag uses (see the dry run below).
.circleci/config.yml references the context from the job in the workflow:
- publish-pypi:
context:
- PyPIAny other variables that context happens to carry are ignored. In particular a
PYPI_USERNAME/PYPI_PASSWORD pair cannot publish to PyPI at all: uploads from
an account with 2FA have required an API token or a trusted publisher since
2023-06-01, and 2FA has been mandatory for every account since 2024-01-01, so a
password is answered with a 403.
The job refuses to run twine when the variable it needs is absent, naming the
variable and the context, rather than letting PyPI answer with a 403 that reads
like a permissions problem. It never echoes the token and never prints the
environment.
opensysml exists on PyPI (0.3.0 and 0.3.1 are published), so the token in the
PyPI context as PYPI_API_TOKEN must be scoped to the opensysml project,
never account-scoped: an account-scoped token in CI can publish anything the
account owns. Keep a second owner/maintainer on the PyPI project as well, so it is
not tied to one account.
PyPI trusted publishing (OIDC) is not an option: the supported providers are GitHub Actions, Google Cloud, ActiveState and GitLab CI/CD, and CircleCI support is still open upstream (pypi/warehouse#13888). An API token is the authentication CircleCI has.
Two jobs of the release workflow, so the distribution is built once and the same
bytes go to the GitHub release and to PyPI.
build-python-package, which runs beside the Go suite and gates build-release:
check_version.py— the tag must name the declared version.python -m build— wheel and sdist, intoclient/python/dist/.twine check --strict— the metadata a broken listing comes from.- Installs the built wheel into a clean virtualenv, imports it, and checks
opensysml.__version__is the version being published. - Persists
client/python/dist/to the workspace.build-releasecopies both files intodist/, lists them inSHA256SUMS.txtbefore signing it, and checks their names carry the tag's version.
publish-pypi, which runs last, after the Go suite, the Python client tests,
build-release and publish-github-release have all passed on the tagged revision:
- Resolves the version from the tag again and checks the workspace holds the wheel and sdist of that version.
- Requires the token for the index it will use.
- Refuses to continue if that index already has this version (a re-run of an
already-published version fails here, deliberately: it cannot be replaced,
and
--skip-existingwould let a half-intended re-run look successful). twine check --strictagain, thentwine uploadwithTWINE_USERNAME=__token__and the token from the context.
A pre-release version publishes to TestPyPI instead of PyPI — that is the whole rule, so the happy path has no extra switch to forget. Since the tag is the core's, a rehearsal is a core pre-release: it builds and publishes the binaries to a GitHub release like any other tag, and only the package's destination changes.
# 1. Declare a pre-release version, e.g. VERSION = "0.9.0rc1"
$EDITOR client/python/opensysml/_version.py
# 2. Land it, then tag it (the SemVer spelling of the same version)
git tag -a v0.9.0-rc1 -m "v0.9.0-rc1" && git push origin v0.9.0-rc1The job resolves the version, sees a PEP 440 pre-release, requires
TEST_PYPI_API_TOKEN, and uploads to https://test.pypi.org/legacy/. Verify it
the same way as a real release, pointing pip at TestPyPI but taking the
dependencies from PyPI:
python -m venv /tmp/opensysml-rc && . /tmp/opensysml-rc/bin/activate
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ opensysml==0.9.0rc1
OPENSYSML_GRPC_VERSION=v0.9.0-rc1 python -c "import opensysml; print(opensysml.__version__)"Then set VERSION to the final version and tag v0.9.0.
Nothing about the pre-release path is required for a normal release; if you skip it, no TestPyPI token is needed at all.
In a clean virtualenv, from the index — not from the source tree:
python -m venv /tmp/opensysml-verify && . /tmp/opensysml-verify/bin/activate
pip install opensysml==0.9.0
python -c "import opensysml; print(opensysml.__version__)" # must print 0.9.0Then check the client end to end against the core release of the same version, since that is the pairing the release tested:
export OPENSYSML_GRPC_VERSION=v0.9.0 # the same tag
python -c "import opensysml; print(opensysml.load('examples/state-machine-demo.sysml').diagnostics)"Finally, read the project page: the description, the license, the project URLs
and the Python versions are the metadata twine check --strict accepted, not
metadata anyone reviewed.
A PyPI version cannot be replaced. Yank it
(PyPI → project → Manage → Releases → Yank, which hides it from resolvers
without breaking a pin that already names it), and cut the next core release —
the package's version is the core's, so the fix is a patch tag, not a new
VERSION alone. Deleting a release frees nothing: the version number stays used.
The Node client in client/node/ is published to npm as @openmbee/opensysml
by the release workflow's publish-npm job, from the same core v<version>
tag that publishes the binaries and opensysml — at that version. No other tag
publishes it; the client-node-v* path never ran and is no longer matched.
Nothing has been published yet — the next core release is the first
publish, and the npm context it needs is already in place (see
What the job needs).
@openmbee/opensysml carries no binary. The service binary comes from one of five
per-platform packages it names in optionalDependencies, which npm installs by
matching their os/cpu metadata:
| package | os | cpu |
|---|---|---|
@openmbee/opensysml-sysml-grpc-linux-x64 |
linux | x64 |
@openmbee/opensysml-sysml-grpc-linux-arm64 |
linux | arm64 |
@openmbee/opensysml-sysml-grpc-darwin-x64 |
darwin | x64 |
@openmbee/opensysml-sysml-grpc-darwin-arm64 |
darwin | arm64 |
@openmbee/opensysml-sysml-grpc-win32-x64 |
win32 | x64 |
All six share the version in client/node/package.json, because the
optionalDependencies name that exact version. The platform packages are
published first, so @openmbee/opensysml is never on the registry naming a
version of them that is not. Where no package matches — a platform with no
release build — the client falls back to $OPENSYSML_BINARY, a binary in
~/.opensysml/bin/, a release download into that cache, sysml-grpc on
$PATH, or an explicit external service. That download is the Python client's:
the same shared cache and metadata, the same pinned digests, and the same
signed-manifest verification, refusing a release it can neither pin nor verify.
See client/node/README.md.
The five binaries are build-release's dist/grpc output — the same bytes as
the GitHub release and the signed SHA256SUMS.txt, persisted to the workspace
the npm job attaches. npm run platform-packages refuses to package a binary
whose bytes disagree with its .sha256 sidecar, or that has none, so the
packages can only carry what the release built. npm's --provenance is not
used: the CLI mints attestations only on GitHub Actions and GitLab CI/CD.
The client follows the Python client's choice (see
Why the same tag): every npm version then has a core
release of the same version tested with it in the same pipeline, and a caller
pins one number — npm install @openmbee/opensysml@0.9.1 gets the release's own
binary via the platform package. The cost: a client-only fix is a core patch
release. And since an npm publish is irreversible, the job runs last and refuses
a version already on the registry, just like publish-pypi.
client/node/package.json follows client/python/opensysml/_version.py — the
same version, spelled the SemVer way (0.9.0-rc.1 for 0.9.0rc1).
check_version.py --node in build-python-package fails the release before
anything is built when they disagree, and the pytest gate in
test_check_version.py runs on every PR that touches either file. The tag must
spell the SemVer version exactly, v aside.
A pre-release tag — the same one that sends opensysml to TestPyPI — publishes
all six packages to the next dist-tag; latest is untouched. Install a
pre-release with @next or the exact version.
Everything below is already in place; it is recorded so it can be re-created.
- The
@openmbeescope on npm, with the publishing account a member of the org with publish rights on its packages. A new package under the scope is created by its firstnpm publish --access public. - A granular access token stored as
NPM_TOKENin the CircleCI restricted contextnpm(lower-case, matched exactly, restricted to a security group). The token was created on npmjs.com → Access Tokens → Generate New Token → Granular Access Token, Packages and scopes: Read and write, restricted to the@openmbeescope, Bypass 2FA enabled for non-interactive publishing, no IP allowlist. (npm classic/automation tokens were revoked in December 2025, and npm has no trusted publishing for CircleCI.) - Rotation is a standing task: granular write tokens expire after at most
90 days, so before each release check its expiry and, if it has lapsed or
will soon, create a replacement the same way and update
NPM_TOKENin thenpmcontext.publish-npm'snpm whoamistep fails before anything is published when the token has expired.
publish-npm runs after publish-github-release, beside publish-pypi:
- Resolves the version: fails if the tag is not
v<version>matchingclient/node/package.json, picks thelatest/nextdist-tag from the version, and lists the workspace binaries it will package. - Requires
NPM_TOKENfrom thenpmcontext. - Refuses to run if any of the six packages is already on the registry at this version (a publish cannot be repeated).
- Builds and tests the client against the release's linux binary (
npm ci, build, typecheck, lint, tests). - Builds the five platform packages from
dist/grpc, checking each binary against its.sha256sidecar. - Authenticates to npm and runs
npm whoami, so an expired token fails before the first publish. - Publishes the five platform packages, then the client, on the resolved dist-tag.
publish-npm runs after publish-github-release and beside publish-pypi, so
a failure there leaves the GitHub release and PyPI in place. Before the first
npm publish — a version/tag mismatch, a missing or expired token, an npm whoami, build, test or digest failure — nothing is on npm: fix the cause (for
example rotate the token in the context) and re-run only that job — Rerun
workflow from failed — without repeating the rest of the workflow.
After the first publish the version is used: the registry refusal makes a
re-run fail by design, and a half-published set — some platform packages up,
the client not — is not repaired by re-running. npm deprecate what went up
(and npm unpublish within 72 hours only if nothing depends on it) and cut the
next core patch release. Never remove the refusal to force a re-run through.
The client was published as pysysml up to
0.2.0, before the project was renamed. That name cannot be deleted and its last
version still installs and works, so pip install pysysml would otherwise go on
silently handing out a pre-rename client indefinitely.
packaging/pypi-pysysml/ is the answer: pysysml 0.2.1, a distribution of the
same name whose only module raises ImportError naming opensysml. Being above
0.2.0 is what makes resolvers prefer it. It is not a compatibility shim — it does
not re-export opensysml, and it declares no dependency on it, since installing
the new client as a side effect would keep the old import working.
pip install pysysml==0.2.0 is the escape hatch while migrating. An exact pin is
the only one that avoids the placeholder whatever version it carries: any range
that does not exclude it (>=0.2, ~=0.2.0, <1.0) resolves to it, which is
the whole point.
It is released by its own tag, which runs the release-pysysml-placeholder
workflow:
git tag pysysml-v0.2.1 # must match the version in packaging/pypi-pysysml/pyproject.toml
git push origin pysysml-v0.2.1The job resolves the version from the tag, refuses a version PyPI already has,
builds the wheel and sdist, and — the check that matters — installs the wheel
into a clean virtualenv and fails if importing pysysml succeeds. A
placeholder that imports cleanly is the alias this release exists not to be.
client/python/tests/test_legacy_pysysml_placeholder.py asserts the same contract from
source on every run.
This is expected to happen exactly once. Nothing further should be published
under the old name; a client fix goes to opensysml.
The Java client in client/java/ is published to Maven Central as
org.openmbee:opensysml — with its parent, org.openmbee:opensysml-parent
— by the release workflow's publish-maven job, from the same core
v<version> tag that publishes the binaries, opensysml and
@openmbee/opensysml, at that version. The opensysml-java-v* path was never
tagged and is no longer used. Nothing has been published yet.
None of these can be provisioned from a checkout. The key and the token live
in the restricted context Maven Central (Organization Settings →
Contexts — a context reference is matched exactly, so the case has to match),
which holds CENTRAL_TOKEN_USERNAME, CENTRAL_TOKEN_PASSWORD,
GPG_PRIVATE_KEY and GPG_PASSPHRASE, set up like the PyPI and npm contexts
(see what the job needs). Contexts restricted to a
security group admit only their members, so whoever pushes the tag must be
allowed to use all of them — PyPI, npm, Maven Central and crates.io —
or the job fails as unauthorized before anything runs. The signing key in
place is a freshly generated one with a two-year expiry, so the rotation note
below applies within two years.
-
A verified namespace. Register
org.openmbeeat central.sonatype.com → Namespaces → Add Namespace. A DNS-verified namespace is proved by a TXT record onopenmbee.orgthat the portal names. It is thegroupIdthe client and its Java package (org.openmbee.opensysml) already declare, and it is in every consumer's build file, so every future Java artifact belongs under it. -
A published GPG key. Central requires a detached signature per artifact, verified against a public keyserver:
gpg --quick-generate-key 'Open-MBEE Release Signing <release@openmbee.org>' rsa4096 sign 2y gpg --keyserver keys.openpgp.org --send-keys <KEY_ID>
The private key and its passphrase are the context's
GPG_PRIVATE_KEYandGPG_PASSPHRASE. Store the key base64-encoded on one line —gpg --armor --export-secret-keys <KEY_ID> | base64 | tr -d '\n'— since the CircleCI UI drops newlines; the job also accepts the raw armored block. The job test-signs before anything uploads, so an expired key or a wrong passphrase fails before anything reaches Central. A key approaching its expiry needs extending (gpg --quick-set-expire) and the public key re-published, or replacing outright — updateGPG_PRIVATE_KEYandGPG_PASSPHRASEto match. Signatures already published stay verifiable. -
Portal tokens. Central portal → View Account → Generate User Token gives a username/password pair for a
<server>with<id>central, the context'sCENTRAL_TOKEN_USERNAME/CENTRAL_TOKEN_PASSWORD, written into~/.m2/settings.xmlby the job. A token can be revoked and regenerated in the portal and then replaced in the context.
client/java/pom.xml follows client/python/opensysml/_version.py — the same
version, spelled the Maven way, which is the SemVer spelling (0.9.0-rc1 for
0.9.0rc1): the parent pom's <version>, both modules' <parent><version>,
and the client version the editors name (opensysml.client.version in
editors/mdk/pom.xml, the opensysml dependency in
editors/syson/backend/pom.xml). check_version.py --java in
build-python-package fails the release before anything is built when they
disagree, and the pytest gate in test_check_version.py — including the test
that every in-repo reference names the pom's version — runs on every PR that
touches either file. The tag must spell the version exactly, v aside.
The editors' own versions are in the same lockstep: every manifest
check_version.py --editors reads — the VS Code and SysON frontend
package.json files and their locks, the Cameo and SysON parent poms and their
children's <parent><version> — carries the SemVer spelling, and the release
fails early when one disagrees.
The client follows the Python and Node clients' choice (see
Why the same tag): every published version has a core
release of the same version tested with it in the same pipeline, and a consumer
pins one number. The old reason for a separate tag — Central is immutable and
ghr -replace re-runs the v* tag — is answered by the job running last and
refusing a version already on Central, like publish-pypi and publish-npm.
The cost stays the same too: a client-only fix is a core patch release.
mvn -f client/java/pom.xml install attaches everything Central validates:
opensysml-<version>.jar,-sources.jarand-javadoc.jar(themaven-source-pluginandmaven-javadoc-pluginexecutions are in the default build, not the release profile, so a missing one fails long before a release);- POM metadata Central requires:
name,description,url,licenses,developers,scm; opensysml-conformance, which setsmaven.deploy.skip— it is a test harness, not a published artifact.
The release profile adds what only a release needs: maven-gpg-plugin signing
at verify (the passphrase comes from MAVEN_GPG_PASSPHRASE, never from a
pom property or the settings), and central-publishing-maven-plugin with
autoPublish=true and waitUntil=published — the job publishes the validated
deployment itself and waits until it is published, so a green job means the
version is on Central. The irreversible step is guarded by running last, on a
revision proven green, and by the refusal of a version already published.
The same checks run locally before a release, without uploading:
make build # the service the tests start
mvn -f client/java/pom.xml clean verify # tests, javadoc, sources
mvn -f client/java/pom.xml -Prelease verify # + signatures, no upload
gpg --verify client/java/opensysml-client/target/*.jar.asc # check one by handCentral has no test registry. A pre-release tag — the same one that sends
opensysml to TestPyPI and the npm client to next — publishes an ordinary,
permanent version that Maven orders before the release: 0.9.0-rc1 resolves
before 0.9.0. Consumers get it only by naming it.
publish-maven runs after publish-github-release, beside publish-pypi and
publish-npm:
- Resolves the version: fails if the tag is not
v<version>matchingclient/java/pom.xml, or the version is a-SNAPSHOT. - Requires all four credential environment variables, naming only the missing one.
- Refuses to run if
org.openmbee:opensysml-parentoropensysmlis already on Central at this version (a publish cannot be repeated). - Imports
GPG_PRIVATE_KEYand test-signs withGPG_PASSPHRASE, so an expired key or wrong passphrase fails before the upload. - Writes
~/.m2/settings.xmlnaming thecentralserver, reading the portal token from the environment so it never lands on disk. - Runs
mvn -Prelease deploy -pl :opensysml -am -DskipTests—java-testran the suite on this revision;-amcarries the parent pom the client's pom names. The plugin uploads, Central validates,autoPublishreleases the deployment, and the build waits until it is published.
publish-maven runs after publish-github-release and beside publish-pypi
and publish-npm, so its failure leaves the GitHub release, PyPI and npm in
place. Before the upload — a tag/version mismatch, a missing variable, a
Central availability-check error, an expired key or wrong passphrase, a build
or javadoc failure — nothing is on Central: fix the cause and Rerun workflow
from failed, without repeating the rest of the workflow.
A deployment that fails Central validation is not published — autoPublish
only publishes a valid one: drop it in the portal if it remains, fix the cause,
and re-run. If the job timed out waiting while Central was still publishing,
check the portal before anything else; the next run's availability check
answers whether the version landed.
Once published the version is immutable — it cannot be replaced or deleted, and the refusal makes a re-run fail by design. A published mistake needs the next core patch release.
The opensysml crate is published by the release workflow's publish-crates
job from the core v<version> tag, at the version Cargo.toml declares, after
the suite and the GitHub release. opensysml-rust-v* was never tagged and is no
longer used; nothing has been published yet — the name opensysml is free on
crates.io. The maintainer-run cargo publish and the bump-then-tag procedure
it followed are gone.
A crates.io API token with the publish-new/publish-update scopes — scoped to
the opensysml crate alone once it exists — stored as CARGO_REGISTRY_TOKEN
in the restricted context crates.io (Organization Settings → Contexts —
the name is matched exactly, lower-case included), set up like the PyPI, npm
and Maven Central contexts (see what the job needs).
Whoever pushes the tag must be allowed to use all of them, as the Java section
above notes. A token can be given an expiry at creation; rotate it before one
lapses — crates.io refuses an expired token at the publish step, and nothing
is published.
client/rust/opensysml/Cargo.toml's [package] version follows
client/python/opensysml/_version.py, at the SemVer spelling of it — the same
spelling package.json and the pom use (0.9.1; 0.9.0-rc.1 for 0.9.0rc1).
check_version.py --rust in build-python-package enforces the lockstep, and
a pytest gate holds it on every commit — including client/rust/Cargo.lock,
whose opensysml entry must name the same version (the checklist's
cargo update -p opensysml keeps it in step). crates.io publishes
the version Cargo.toml declares, so the tag must spell it exactly.
The same reasons as npm and Maven — see Why the same tag: the crate is the same client's surface in another language, so the tag that proves the suite is the tag that publishes it. crates.io versions are immutable, which is why the job runs last and refuses a version the registry already holds, like PyPI, npm and Central.
cargo package -p opensysml must succeed cleanly before any publish — it is
what proves the manifest carries the metadata crates.io requires and that the
packaged file list builds on its own, outside this workspace. The manifest
declares license, description, repository, homepage, documentation,
keywords, categories and rust-version = "1.83", so a published crate
documents its own minimum supported Rust version. opensysml-conformance is a
workspace member and a runner, not a library, and is not published: it
reads conformance/scenarios from this repository.
One limitation stands, and this publish does not change it: a download of the
sysml-grpc release binary — which $OPENSYSML_GRPC_VERSION asks for —
verifies only against the digests pinned in the crate's embedded
release-digests.json, which currently runs through v0.3.0. A published crate
therefore cannot download the binary of its own release; it is used against a
running service or a binary it is pointed at ($OPENSYSML_GRPC_BINARY, then
sysml-grpc on $PATH). See client/rust/README.md for the resolution order.
crates.io has no test registry, so a pre-release tag (v0.9.1-rc.1…) publishes
an ordinary version. Cargo never selects a pre-release for a 0.9 requirement,
so consumers get it only by naming it exactly.
- Fails on an empty
CIRCLE_TAG; resolves the crate version withcargo pkgidand requires the tag to bev<version>— nothing was published when they disagree. - Requires
CARGO_REGISTRY_TOKEN, naming it only when missing. - Refuses the version when crates.io already holds it (a published version cannot be replaced, only yanked), and refuses rather than guesses when the API cannot be asked.
cargo package -p opensysml --locked— the dry run that builds and verifies the packaged file list.cargo publish -p opensysml --locked --no-verify; cargo reads the token from the environment, so nothing is written to disk.
publish-crates runs after publish-github-release and beside
publish-pypi, publish-npm and publish-maven, so its failure leaves those
in place. Before the upload — a tag/version mismatch, a missing token, a
crates.io availability-check error, a package dry-run failure — nothing is on
crates.io: fix the cause and Rerun workflow from failed.
Once published the version is immutable — it cannot be replaced or deleted,
and the availability check makes a re-run fail by design. A mistake is yanked
(cargo yank --version <version>, which stops new resolutions while existing
lockfiles keep working) and fixed in the next core patch release.