A platform follows the same model as a package: the same environment, layout, examples archive and three workflows, and no compiler pin in the platform or its examples. This chapter covers what a platform adds, which is a native host. A host brings generated bindings, a target matrix, and build inputs that are too expensive or too sensitive to rebuild on every pull request.
Risks and controls for native targets§
A cross-compiled host can link but fail on its advertised OS, and stale glue can disagree with the compiler’s ABI. Treat native execution and interface identity as separate evidence. Check generated bindings against the selected nightly, bind native inputs to their source and target, and run the exact bundles on every supported runner. Include a consumer link/run test rather than relying on archive presence. The target matrix establishes those exercised combinations; it does not establish that native host code or FFI behavior is memory-safe or that every downstream application is compatible.
Host bindings§
A host is compiled against bindings that roc glue generates from
platform/main.roc: a Zig, Rust or C description of the types and entry points
the Roc side expects. The compiler generates them, so a new nightly can change
them when nothing in the repository changed.
The bindings are committed. The host build reads the committed file and never
runs roc glue itself, so a host builds the same way on every runner and a
reviewer sees every change to the interface as a diff.
Check the bindings in pull-request CI§
ci.yml has one job that regenerates the bindings with the latest nightly and
requires the committed file to match:
Obtain the glue specification that belongs to the resolved nightly, from that nightly’s source archive, and verify the archive’s digest.
Run
roc glue <spec> <output directory> platform/main.rocwith the latest nightly.Run
git diff --exit-codeon the committed bindings.
This check runs on pull requests and on pushes to the default branch. It does
not run in nightly.yml: a released host is fixed, and what
nightly compatibility asks is whether
applications still build and run against it.
The job is part of the required aggregate check. When a nightly changes the generated bindings, every open pull request fails this job until the bindings are upgraded. That is intended: the default branch should not accept changes on top of an interface that no longer matches the compiler.
Upgrade the bindings§
Regenerating the bindings is a manual task, because a changed interface usually needs host code to change with it.
Run the bindings script with its update flag, using the latest nightly.
Review the diff. A changed layout or signature needs matching host changes.
Build the host for every target and run the examples.
Open a pull request containing the bindings and any host changes. Merge after
ci.ymlpasses.If the released platform no longer works with the new nightly,
nightly.ymlhas already reported it. Release a new platform version.
Cross-compile once, run on every target§
A platform advertises a set of targets, and each workflow validates all of them. The usual shape is one builder job that cross-compiles for every target, followed by a matrix of runner jobs that execute the result on each one.
The builder job§
One job, on one runner, cross-compiles the host for every supported target. A single toolchain version then produces every host, and the expensive work happens in one place where its cache is easy to manage.
It assembles the hosts with the locked linker inputs and bundles once, with the repository’s own bundle command. The shared
release-packageutility then runs the examples against the working tree, and itsbuild VERSION --platform-bundle DIRchecks that bundle, names it, and writes the examples archive, documentation and notes beside it.dist/is uploaded as a workflow artifact.Where a target cannot be cross-compiled, because it needs an SDK or a toolchain that exists on one operating system only, that host is built by a job on that system and handed to the builder as an artifact. This is the exception to plan around, not the default.
The runner jobs§
One job per supported target downloads the bundle and runs applications against it. A runner job never rebuilds the host or the bundle, so every target tests the same bytes, and those are the bytes a release publishes.
blueprint run release-test -- test-archiveruns the archived examples, unchanged, against the bundle served from a loopback address with an empty package cache, as a first user would fetch it. Blueprint and the utility run on Windows too, so the Windows runners use the same task.One aggregate job requires every runner job to have passed. Its name is the required status check.
Every target the platform’s
targetssection lists has a runner that executes it:macos-15-intelforx64mac,macos-15forarm64mac,ubuntu-24.04andubuntu-24.04-armfor the Linux targets,windows-2025forx64win. A target without one is cross-compiled only, and the platform’s README says so beside its list of supported targets, so that a user can tell a target that was linked and run from one that only compiled.
The purpose of the matrix is narrow: to show that the host and the linker inputs for each target link and run correctly there. Cross-compilation proves a host compiles for a target; only running on that target proves that it links, starts, and talks to the operating system as expected.
What the runners run§
Run the examples. They exercise the platform through its public API the way an application does, and between them they usually reach enough of the host to show that a target works. They are already written, already have recorded expected output, and are what a user will try first.
Add dedicated tests where the examples leave a gap: a host service no example uses, a code path that differs per operating system, a failure that should be reported in a particular way. Keep them few and aimed at the host, because everything in the matrix runs once per target.
What does not belong in the matrix is anything that does not depend on the target. Unit tests of pure Roc code, formatting and the static checks run once, on one runner.
Runner environments§
State each runner’s runtime needs in the workflow: a display server, a software GPU, an audio sink, a browser. A simulated environment is reported as simulated; it is not evidence about the real one.
An example that can be built but not run on a hosted runner is built and checked, and its expected-output specification records that it is not run.
The native toolchains come from Blueprint.roc, as described in
Declare the environment, so a
contributor and every CI job compile the host with the same versions.
Several platforms in one repository§
A repository that publishes more than one platform, such as a web platform and a native platform that share an engine, gives each its own root, its own bundle and its own examples archive, as in Several bundles in one release. Sharing an engine does not make two platform APIs or their application roots interchangeable. Give examples separate roots and package URLs even when they share application modules.
Template repositories§
A template’s examples are the product: they are what a user copies. A template follows the model without exception. Its examples depend on the working tree by relative path, and each release’s examples archive is the starter a user downloads.
Native build inputs§
Platforms need native builds before Roc can link an application, and native builds are where CI time goes. A maintainer has a limited budget of runner minutes and of patience, and spends it well only by planning what gets built when, and by watching how that changes as the project grows. This section is about building each thing once, reusing it until it changes, and testing and publishing the same bytes.
No binary blobs in the repository§
As a general rule, a platform repository does not commit binaries. A committed library or object file has no visible origin: a reviewer cannot tell what source it came from, what built it, or whether it changed for a reason.
Binaries such as linker inputs are built by their own workflow, independent of ordinary CI, and published as an immutable release that carries:
the archives for every supported target;
a build-provenance attestation for each of them;
the licenses of everything inside; and
an SBOM.
The software supply chain can then be followed and verified end to end: from a source commit, through the workflow run that built it, to the exact bytes a platform links.
Routine use goes through content hashes. The repository commits a lock that names each archive, its release and its SHA-256, and every build verifies the bytes against that lock. A hash check is local and fast, it makes the archives safe to cache, and it needs no network service. The attestation API is reserved for the moments that need it: admitting a new release into the lock, auditing one, or an end user who wants to verify what they downloaded. It is not called on every build. Separate admission from routine use sets out that division.
There are exceptions. A platform may depend on libraries that another project already publishes, for example the prebuilt libraries of a graphics library it wraps. Those may be vendored in the repository, so that builds do not make repeated network calls on someone else’s project. A vendored binary records where it came from: the upstream project, the release, the download URL, its hash and its license. It is the upstream project’s published artifact, unmodified, and never something the repository built and then committed.
Two releases, three build steps§
A platform’s host and its Roc source usually change together and are released together: one platform release holds both. Linker inputs are different. They change rarely, cost the most to build, and are published as their own release.
So there are two kinds of release, and three things that are built:
| What is built | How it is released | In an ordinary pull request |
|---|---|---|
| Linker inputs | Their own immutable release, selected by a content lock | Download the locked archives and verify their hashes. Never rebuild them. |
The host, such as libhost.a | As part of the platform release | Build it in its own job, and skip that job when the host’s fingerprint has not changed. |
| The platform bundle | The platform release: the Roc source with the host and linker inputs | Assemble from the outputs above and test. Bundling compiles nothing native. |
The host is not given a release lifecycle of its own. It is given a job of its own. The point of the separation is that each step can be skipped when its inputs have not changed.
Skip what has not changed§
Compute a content fingerprint of everything that can affect the host: its source, generated bindings, dependency versions, toolchain, target and build options. Use the fingerprint as a cache key.
When the fingerprint matches a cached build, the host job restores it and finishes in seconds. A pull request that touches only Roc source, examples or documentation compiles no native code.
When it does not match, the job builds the host and saves it under the new fingerprint.
Later jobs take the host as an artifact from that job. They do not know or care whether it was built or restored.
Keep rebuild selection narrow and complete says what a fingerprint must include. A cache is storage, not a source of trust: restored bytes come from the same repository’s own earlier job, and anything that crosses a release boundary is verified against a locked hash.
Separate steps and caches pay off beyond runner minutes. A failure points at the step that failed. A contributor without the native toolchain can still work on the Roc side. Review is easier when a pull request shows which step it invalidated.
Watch what CI costs§
Record stage durations and investigate slow routine pull requests. Check for fingerprints that include unrelated files, changing cache keys, duplicated builds and unnecessary matrix jobs. Run the expensive native matrix when its inputs change and before a release; use the cached path for other changes.
libhost.a and host.lib are host outputs, not linker-input release assets.
Operating-system import libraries, runtime libraries, interface stubs and
resource objects required by platform/main.roc are linker inputs, even when
the platform repository holds their generation recipes. Classify by ownership
and invalidation, not by the fact that the final link receives the file.
Choose the smallest lifecycle that fits the project:
| Project shape | Lifecycle |
|---|---|
| Roc package | Bundle source. No native producer. |
| Package with a test-only host | Build the host in a read-only job of ci.yml. It is never published. |
| Platform with a small host and no external inputs | Build the host in its own fingerprinted job, bundle once, test the bundle, then publish those bytes. |
| Platform with expensive external inputs | The same, with the linker inputs released independently and consumed through a reviewed lock. |
An existing binary is not a verified artifact until its build recipe, digest and target coverage are established. An explicit source build remains valid.
Separate the artifacts by what can invalidate them§
All of these files can eventually reach a linker, but they have different owners and rebuild triggers. Name the categories explicitly in scripts, locks and docs.
| Artifact | What it contains | What requires a new build |
|---|---|---|
Project host and engine outputs, such as libhost.a, libengine.a or a Wasm host library | Compiled project implementation for a target | Relevant source, generated inputs, dependency versions, toolchain, target ABI or build options change |
External linker inputs, such as import libraries, runtime link files and macOS .tbd files | The external libraries or interfaces used by the final application link | Their upstream inputs, target contract, generation recipe or required interface catalog changes |
| Roc platform bundle | Platform API source plus the selected target outputs and linker inputs | Package source or selected input bytes change |
| Application root and companion modules | An application targeting a particular platform API and URL | Application source or selected package URL changes |
A host edit can require new host archives while reusing the existing external linker inputs. Rebuild an engine archive only when its own inputs change; if a producer groups multiple outputs, document that granularity. A new nightly invalidates host outputs only when the compiler contributes to their build identity; a change to the generated bindings is handled as described in Host bindings.
For platforms that package macOS interfaces, review the required catalog before
generating and validating .tbd files. That generation belongs inside the external linker-input
cycle. When released separately, publish the generated files as immutable inputs.
For a static host archive, these files normally serve Roc’s final link rather
than host compilation. A producer that itself links executables or shared libraries
may also consume linker interfaces. Record its actual SDK, headers, interfaces and
native build tools in its identity; do not assume the host build and the linker inputs have no dependencies.
An unresolved symbol during the application link is evidence to investigate. Confirm the library, symbol and supported target before changing the catalog; missing project symbols, mismatched archives and ABI errors can produce similar failures. Do not invent an export or expand the catalog automatically to make a test pass. Producer validation can catch mistakes earlier, but a successful host build alone does not establish that Roc can link the final application.
Make content identity the ordinary consumer contract§
Commit reviewed lock data with each dependency selection. Include the artifact name, target, exact release location, cryptographic digest such as SHA-256, byte size, and expected archive contents or extraction boundaries. Record available source revision and producer build-input fingerprint separately from the archive digest. The fingerprint answers whether a build can be reused; the digest identifies the exact bytes a consumer accepts. Neither a version label nor a fingerprint alone verifies a downloaded archive.
Recompute freshness fingerprints where the repository owns or vendors the producer inputs, such as its own host source. For external binaries, consumers verify the reviewed selection, digest, target and inventory; they need not fetch upstream source or install its toolchain to recompute the producer’s fingerprint. Recorded producer metadata remains a claim subject to the chosen provenance policy.
Final Roc bundles have a compiler-defined content identity: preserve the hash
that roc bundle puts in the filename, and use one filename in both localhost
and published URLs. Roc uses its
base58-encoded BLAKE3 hash to verify the downloaded bundle. An additional SHA-256
in release metadata is useful but cannot replace that URL hash. See the
Roc bundle implementation
and download contract.
Application users consuming that bundle need no separate native-input fetcher.
Resolve locked artifacts from cache, mirror or release URL and verify size and digest before extraction. Stage the complete declared inventory with bounded sizes and portable regular-file paths; reject aliases, special files and missing runtime or license files. Do not trust an extracted tree merely because its archive verified. Required unavailable or stale inputs fail rather than silently rebuilding or selecting another release.
The reviewed lock authorizes bytes; a checksum from the same mutable download location does not. Follow artifact admission when selecting new inputs. Routine hash checks are local, including cache hits, and need no attestation service. An uncached download still needs a source of bytes.
Publish provenance without confusing it with integrity§
Publish provenance binding the artifact digest to its producer, workflow and source revision. Verify those expected identities when adopting an input; follow the attestation policy for audit and offline verification. A packaging workflow attests to assembly, not to how a downloaded dependency was compiled. Preserve original producer evidence; neither provenance nor hashes prove reproducible builds or correctness.
Publish dependency releases with unique immutable identities, validated archives, lock data, licenses and available provenance. Enable and verify hosting-service immutability. Stage all assets in a draft, read back the complete inventory and verify the tag before publication. An interrupted draft can resume with identical bytes; changed candidates or incomplete published immutable releases require a new identity. Revalidate starters when their final URLs change.
Publish a pull-request candidate without granting it release authority§
When a linker-input release must precede merge, an unprivileged same-repository PR workflow builds, tests and attests it. A reviewed default-branch publisher verifies the producer run, source, manifest, attestations and final bytes without executing candidate code. It can add only the generated lock in a lease-guarded, GitHub-signed commit. Merging that reviewed lock adopts the branch-built release; do not rebuild or relabel it after merge.
actions/publish-build-inputs implements this boundary. Its wrapper exposes only
a PR number and pins the controller to a full SHA. Component build identities and
input snapshots are inert producer claims preserved in the lock; the publisher
validates their structure, while read-only producer/admission jobs own build
equivalence checks. Source and final-byte admission still apply to the complete
manifest. See
the producer and publisher contract
for schema, recovery, review and merge requirements.
Choose the correct maintainer path§
Use the smallest lifecycle that corresponds to the changed ownership boundary:
| Change | Required maintainer action |
|---|---|
| Roc source, examples, tests, docs, or an unrelated workflow | Ordinary pull-request validation using the committed host and linker-input locks. No native dependency release. |
| A new nightly breaks the released platform | nightly.yml reports it. Fix the source and release a new platform version. Native inputs are rebuilt only if the compiler contributes to their fingerprint. |
| A new nightly changes the generated host bindings | Upgrade the bindings, with any host change, in one pull request. |
| Host implementation, host dependency, host toolchain, host ABI, or exported project symbol | The host job rebuilds, because its fingerprint changed. The new host ships in the next platform release. Reuse the existing linker inputs unless their own inputs changed. |
| Linker-input source, upstream binary, interface catalog, resource source, generation recipe, target matrix, or toolchain | Build the complete linker-input candidate on a same-repository pull-request branch, then run the trusted publisher and review its signed lock commit. |
Blueprint.lock, where it is part of a build-input fingerprint | The same producer and publisher cycle, in the pull request that changes the lock. |
platform/main.roc linker entries | Check classification first. A new project implementation symbol normally changes the host; a new external library or interface normally changes linker inputs. Validate that every referenced released path exists for every advertised target. |
| Platform release | Require a current linker-input lock, build or restore the host, assemble once, exercise applications on every target, and publish the exact tested bundles. |
For a stale linker-input lock, stage the material change in a same-repository pull request first. An unprivileged producer run at that head can build, test and attest a candidate without publishing. Then dispatch the trusted publisher from the default branch with the pull-request number. It reruns the producer at the exact head, verifies the candidate, publishes the immutable linker-input release, and adds a signed lock-only commit to the pull request. Revalidate the updated pull request against the released lock before merging. A platform bundle is a separate, later release that uses the selected lock. Publish linker inputs from a pull request defines the admission and review checks.
An input fingerprint mismatch must be explicit. For a host, the read-only host job simply builds it, because host source commonly changes with the pull request. For a locked linker-input release, routine validation fails with instructions to run the producer and publisher cycle. It must not bootstrap a replacement as a side effect of a cache miss. An implicit rebuild would substitute unreviewed bytes for the dependency the lock selects, make results depend on whichever runner missed its cache, and hide that the declared dependency is stale.
Keep routine pull requests ignorant of production§
Routine consumers need only the lock schema, a downloader/cache, digest and archive validation, and the selected extracted files. They do not need a native SDK, linker-input generator, publication token, attestation lookup, or knowledge of how the archives were produced. Keep producer jobs in their own workflow and path scope; do not call them from general CI merely to populate a cache.
Every cache read is untrusted until the archived bytes match the locked size and SHA-256. A cache miss downloads the exact locked release asset. A download failure fails the job. A digest mismatch removes or ignores the bad entry and fails closed; it never selects another release. Extraction and manifest checks happen after the archive hash succeeds and before files become visible to the build.
Derive cache keys from the locked content identity rather than a branch, workflow run, or mutable release label. This prevents unrelated revisions from sharing a slot merely because their human-readable names match and makes identical reviewed bytes reusable across PRs. Still rehash a cache hit: cache keys select storage but do not authenticate its contents, and a poisoned, truncated, or incorrectly restored entry can otherwise bypass the download-time check.
Preserve reviewed history and authority§
Use merge commits for pull requests whose evidence names exact commits. A merge commit keeps the attested producer commit and the publisher’s signed lock-only commit reachable under their original identities. Squashing manufactures a different commit and discards that correspondence. A maintainer may still tidy an ordinary development branch before review; the automation must not depend on history being rewritten after it has bound evidence to a SHA.
Keep the authorities distinct:
producer and ordinary validation jobs may execute candidate code and are read-only;
the trusted publisher job invokes only its full-SHA-pinned shared action, with release and lock-write permission;
the platform release publisher consumes already selected dependencies and publishes only explicitly dispatched, tested platform bundles.
This split is what makes running candidate build code acceptable: a compromise of that job cannot write a release or a branch. The publisher has powerful credentials, so it accepts only inert declared files and never executes the pull request. Least-privilege permissions reduce what is possible if the pinned action or the hosting account is compromised; they do not make unreviewed code safe to run in that job.
A shared action is tried from a reviewed roc-automation pull request by pinning the caller to that pull request’s full commit SHA. A branch name or abbreviated SHA is never pinned. If review changes the shared implementation, pin the new commit and repeat the trial.
Keep rebuild selection narrow and complete§
Producers computing reuse identities must include every input that can affect an output:
relevant source paths and contents, generated inputs, dependency locks, build
recipes, tool versions, target configuration and build flags. Record file paths
and modes as needed to distinguish renames, deletions and symlinks. Validate the
permitted source-file kinds. Include relevant environment and SDK identities;
an unpinned runner image or ambient library can change output without a source edit.
Where the toolchain comes from Blueprint, Blueprint.lock is one of those inputs,
so a lock update that changes the toolchain makes the selected outputs stale.
Use a versioned fingerprint recipe and compare the same input definitions.
A whole-commit identity, together with the non-source inputs above, is a conservative
starting point, but causes needless rebuilds for unrelated edits. Narrow the source
selection only after testing the input inventory.
Include the source revision separately for traceability.
Treat workflow triggers and fingerprints as different controls. Path filters decide when to run a producer audit; fingerprints decide whether the selected published outputs still correspond to current inputs. A test-harness edit may justify rerunning an audit without requiring a new dependency release. Trace shared scripts and generated inputs transitively so a narrow filter cannot hide a real build change. For repository-owned inputs, record the published fingerprint in lock data or a digest-bound manifest so a shallow checkout can compare current inputs without fetching an old producer commit. A stale host blocks reuse; PR validation may explicitly build a new candidate in a read-only job without publishing it.
For a narrowed fingerprint, require meaningful checks for both directions: a relevant source, tool pin or build-option change invalidates reuse, while an unrelated example or documentation change preserves it. Check archive digest rejection and target/path confinement. Exercise final linking against reused artifacts; a selector test alone does not
Keep final release preparation small§
Once the linker inputs are locked and the host job has built or restored the host, the rest of the release needs only the latest nightly to bundle and link applications, the artifact verification step, and the runtime dependencies of its tests. It downloads and hash-verifies the locked linker inputs. SDK generators belong in the linker-input producer; a stale lock sends the maintainer back to that cycle.
Bundle once, then pass those exact archives to the target runners. Each runner serves the applicable bundles on a loopback address and runs the archived examples against them, as in Build and test the exact archive. Validate every advertised target before publication. Check package size limits and target contents early, so that publication does not discover that a bundle cannot carry its advertised hosts.
Keep preparation, validation and publication connected by recorded artifact digests and the exact source SHA. Record archive digests, selected dependency identities, the source revision and the compiler versions in the release notes. The remaining steps are those of every release; see The order of a release.