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:

  1. Obtain the glue specification that belongs to the resolved nightly, from that nightly’s source archive, and verify the archive’s digest.

  2. Run roc glue <spec> <output directory> platform/main.roc with the latest nightly.

  3. Run git diff --exit-code on 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.

  1. Run the bindings script with its update flag, using the latest nightly.

  2. Review the diff. A changed layout or signature needs matching host changes.

  3. Build the host for every target and run the examples.

  4. Open a pull request containing the bindings and any host changes. Merge after ci.yml passes.

  5. If the released platform no longer works with the new nightly, nightly.yml has 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.

One builder, a matrix of runners
Figure 1. One builder, a matrix of runners

The builder job§

The runner jobs§

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§

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 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 builtHow it is releasedIn an ordinary pull request
Linker inputsTheir own immutable release, selected by a content lockDownload the locked archives and verify their hashes. Never rebuild them.
The host, such as libhost.aAs part of the platform releaseBuild it in its own job, and skip that job when the host’s fingerprint has not changed.
The platform bundleThe platform release: the Roc source with the host and linker inputsAssemble 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.

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 shapeLifecycle
Roc packageBundle source. No native producer.
Package with a test-only hostBuild the host in a read-only job of ci.yml. It is never published.
Platform with a small host and no external inputsBuild the host in its own fingerprinted job, bundle once, test the bundle, then publish those bytes.
Platform with expensive external inputsThe 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.

ArtifactWhat it containsWhat requires a new build
Project host and engine outputs, such as libhost.a, libengine.a or a Wasm host libraryCompiled project implementation for a targetRelevant 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 filesThe external libraries or interfaces used by the final application linkTheir upstream inputs, target contract, generation recipe or required interface catalog changes
Roc platform bundlePlatform API source plus the selected target outputs and linker inputsPackage source or selected input bytes change
Application root and companion modulesAn application targeting a particular platform API and URLApplication 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.

Diagram

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:

ChangeRequired maintainer action
Roc source, examples, tests, docs, or an unrelated workflowOrdinary pull-request validation using the committed host and linker-input locks. No native dependency release.
A new nightly breaks the released platformnightly.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 bindingsUpgrade the bindings, with any host change, in one pull request.
Host implementation, host dependency, host toolchain, host ABI, or exported project symbolThe 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 toolchainBuild 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 fingerprintThe same producer and publisher cycle, in the pull request that changes the lock.
platform/main.roc linker entriesCheck 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 releaseRequire 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:

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.