Every package and platform repository has the same three workflows. Each answers one question, and none of them changes the repository.

Workflow Trigger What it proves Token access

ci.yml

Pull request; push to the default branch

Scripts pass on roc-stable. The working tree and its examples pass on the latest nightly. A platform’s committed host bindings match that nightly.

contents: read

release.yml

Manual dispatch; pull request as a dry run

The exact bundle, examples archive and documentation proposed for publication work.

contents: read. The publish job alone has contents: write, behind a protected environment.

nightly.yml

Daily schedule; manual dispatch

The latest published release and its examples archive work with the latest nightly.

Test jobs contents: read; the report job issues: write

Rules that hold in all three:

  • Every uses: reference is a full commit SHA.

  • Top-level permissions is {}, and each job states what it needs.

  • Checkout sets persist-credentials: false.

  • A job that runs repository code has no write access. A job with write access runs no repository code.

  • The nightly tag is resolved once, in the first job, and passed to every other job as the nightly-tag input of roc-lang/setup-roc. Matrix jobs never each resolve "latest" for themselves, or one run could test several compilers.

  • Each job calls a blueprint run <task> and holds no logic of its own.

Workflows beyond these three, such as a documentation site or scheduled fuzzing, are described in Optional workflows.

Continuous integration

ci.yml tests the working tree.

  1. Set up Blueprint and install the latest nightly, as in Use the environment in CI.

  2. Run blueprint run check.

What runs on every pull request lists what the check task covers.

A pure Roc package runs this as one job on ubuntu-latest, and that job’s name is the required status check.

A repository whose code can behave differently by target runs it as a matrix, one job per runner, and ends with a single aggregate job that requires every matrix job to have succeeded. The aggregate job’s name is the required status check, so the ruleset does not need editing when the matrix changes.

A platform adds the host-bindings check described in Host bindings. It runs here and nowhere else.

A pull request can fail because a new nightly arrived, with no fault in the change itself. Compare with the latest nightly.yml run and with the default branch before treating a failure as a regression in the pull request.

Release

release.yml builds what a release would contain, tests those exact bytes, and publishes them once. A maintainer dispatches it from the default branch with a version. A pull request runs the same build and tests as a dry run and publishes nothing.

What a release contains

One immutable GitHub release per version holds:

Asset Purpose

The bundle, under the filename roc bundle emits

What users depend on. The filename ends in its content hash, which is never changed. Several bundles in one release says when the filename also carries a name.

<name>-examples-<version>.zip

Examples pinned to this release’s bundle, with their expected output.

<name>-docs-<version>.zip

The generated API reference and site for this version, so the documentation of any release can be restored.

<name>-<version>.pdf

The manual, where the repository has one.

Release notes

An authored account of what changed, with the real bundle URLs and the compiler versions used.

SBOM and build-provenance attestations

Where the repository produces them.

Managing releases carefully is a large part of what makes a package dependable. A user meets a release through three things: its version number, its notes, and the URL they paste into a header. Each is handled deliberately.

Choose the version with roc bump

A version number is a promise about compatibility, and roc bump checks the promise against the code. It compares the public API of the package with a previous release and reports the semantic-version bump the difference requires: patch, minor or major.

# what bump does this change need, and what is the next version?
roc bump --old <previous release bundle URL> package/main.roc

# in release.yml: fail unless the chosen version bumps far enough
roc bump --old <previous release bundle URL> --expect 0.13.0 package/main.roc
  • Run it before choosing a version, so the number follows from the change and not from habit.

  • release.yml runs it with --expect in its validate step, against the latest release, and stops when the requested version understates the change. A breaking change can then never go out as a patch.

  • A repository with several packages runs it once per package root.

  • It compares only the modules the package header exposes. A change in behavior behind an unchanged signature is still the maintainer’s call, and belongs in the notes.

A package version is independent of any compiler version. A changed package is always a new version. Nothing is ever republished under an existing version.

Write the release notes

Each release has its notes in docs/releases/<version>.adoc, for example docs/releases/0.12.0.adoc. They are committed in a pull request before the release is dispatched, and the workflow refuses to release a version whose notes file is missing. A top-level docs/releases.adoc chapter includes each file, so the notes of every release are part of the manual; see Write for the shared renderer.

The notes are an authored narrative, written for a user deciding whether and how to upgrade. They are not a list of commits or merged pull requests.

  • Open with what this release is for, in a sentence or two.

  • Describe what changed in terms of what a user can now do, or must now do differently.

  • For a breaking change, show the code before and after.

  • Say what was fixed, naming the visible symptom.

  • Thank contributors, and link issues where a reader may want the background.

docs/releases/0.12.0.adoc
= roc-example 0.12.0

This release adds streaming decoders and removes the deprecated `Table.render`.

== Use this release

[source,roc]
----
app [main!] {
    cli: platform "https://github.com/roc-lang/basic-cli/releases/download/<version>/<hash>.tar.zst",
    example: "{package-url}",
}
----

== Streaming decoders

...

The bundle URL cannot be written by hand, because it contains the content hash of a bundle that does not exist until the release builds it. The notes therefore use a placeholder, and the workflow fills it in:

  • {package-url} stands for the released bundle’s URL. A release with several bundles has one placeholder for each, named after the package, such as {tzdb-url}.

  • After building the bundles, release.yml renders the notes with each placeholder set to the real URL, tag and hash. A placeholder that is left unresolved, or one the notes never use, fails the release.

  • The rendered notes become the body of the GitHub release, followed by a section headed "Compilers" that holds the output of roc version and roc-stable version. That record is the statement of what the release was built and tested with; no header pin makes it.

  • A dry run renders the notes too, so a mistake in them fails on the pull request. It renders one file: the notes for the highest version under docs/releases/, which are the notes of the next release whenever a pull request adds or edits them. It asks nothing of GitHub to choose that file.

The committed file keeps its placeholders. It is never rewritten after the release, and no follow-up pull request is needed.

The order of a release

Diagram
Figure 1. A release tests exact bytes, then publishes once
  1. Validate. Require the default branch. Reject a version whose tag or release already exists. Require docs/releases/<version>.adoc. Check the version with roc bump --expect. Record the event commit and use that SHA in every later job.

  2. Check. Run blueprint run check on that commit.

  3. Build. Build the bundle with the latest nightly, then the examples archive with the real release URL, the documentation archive, and the manual. Render the release notes with the real bundle URLs. Upload them as workflow artifacts.

  4. Test. On every supported target, download those artifacts and test them as described in Build and test the exact archive.

  5. Publish. A separate job, with contents: write and a protected environment, downloads the tested artifacts. It checks out nothing and runs no repository script. It hands the version, the rendered notes and the asset files to actions/publish-release, which stages a draft at the tested commit, verifies every uploaded asset against the tested file, and then publishes.

  6. Update the site. For a final release, rebuild the documentation site so that it points at the new release, and deploy it; see What the site points at. A release candidate does not change the site.

The publish job consumes the artifacts of its own run. It never rebuilds, and never resolves a moving branch.

Enable immutable releases in the repository, so that a published release cannot gain, lose or replace an asset. Hash checks remain necessary even with immutable hosting.

Several bundles in one release

A repository that publishes several packages or platforms builds one bundle and one examples archive for each, in the same run, under the same version. Each archive names only its own bundle, plus any sibling bundle its examples need. The release notes list every bundle URL by name. All of them are tested before any of them is published.

Two bundles under one tag need different names as well as different hashes. Roc identifies a package by its URL with the version and the content hash removed. Two files named only <hash>.tar.zst under one tag are therefore one package with two contents, and Roc refuses the pair as soon as one depends on the other:

The same package version is being served with two different content hashes

Give each bundle a prefix that names its package and ends in a hyphen, such as example-tzdb-<hash>.tar.zst. Roc reads the hash as the run of base58 characters at the end of the filename, so the prefix must end in a character that is not base58, and a hyphen is one. The hash itself is never changed.

  • At most one bundle in a release may go without a prefix. In a repository that starts with several packages, name every one.

  • The prefix is part of the package’s identity. Adding, removing or changing it makes a different package as far as Roc is concerned, so choose it before the first release, and treat a change to it as a breaking release.

  • roc bundle writes <hash>.tar.zst. The script that builds the release copies that file to its prefixed name, and the prefixed file is what is tested and published.

  • roc bundle resolves the dependencies of the package it bundles. A package that names a sibling by its final release URL is bundled after that sibling, with the sibling’s bundle already in the package cache, because the URL does not exist until the release is published.

  • roc unbundle accepts only the bare <hash>.tar.zst name. A script that unpacks a prefixed bundle copies it to that name first.

Release candidates

A release candidate, such as 0.12.0-rc1, is a prerelease published for users to test before the maintainers commit to the release. It answers the question the workflows cannot: does this version work in real projects, and is the API right?

  • Publish a candidate through release.yml with a prerelease version. It follows every step above and holds the same assets, so what users test is built exactly as the final release will be.

  • Mark it as a prerelease on GitHub. It is never the repository’s latest release.

  • Announce it where users will see it, and say what feedback is wanted and for how long.

  • Fix what the feedback finds on the default branch, and publish -rc2. A candidate is immutable like any other release and is never amended.

  • When a candidate has stood without change, release the final version from the same commit. Its bundle is a new artifact under a new tag, and it goes through the release workflow in full.

Everything that speaks of "the latest release" means the latest release that is not a prerelease. nightly.yml tests that one, and the documentation site links to that one. A candidate is found from the releases page and from its announcement.

Recover an interrupted publication

If publication stops, inspect exactly what exists before retrying. The publish job’s error says which of these states it left.

  • A draft and no tag. Nothing is public. The error names the draft, which holds whatever was uploaded before the failure. Look at it to see how far the job got, delete it, and re-run the failed publish job of the same workflow run. That job publishes the files the run already tested. The publish action never adopts a draft it finds, so the draft is deleted first.

  • A published release. It is immutable and is never repaired. If it is complete, the version is released and nothing is re-run. If it is not, publish a new version.

  • Unknown. When the answer to the publish request was lost, look at the releases page and treat what is there as one of the two states above.

Changed bytes are always a new version. Do not publish a draft by hand, replace an asset, or rebuild from a moving branch. If the run’s artifacts have expired, delete the draft and dispatch the release again from the default branch. The version was never published, so it is still free, and the new run builds and tests its own files.

Nightly compatibility

nightly.yml answers one question each day: does the release users download today still work with today’s compiler? A failure means the package or platform needs a patch release.

Diagram
Figure 2. The inputs to one nightly run and its two outcomes
  • Resolve the latest nightly tag and the latest release once. Accept both as optional dispatch inputs, so a maintainer can re-run a past combination.

  • Skip prereleases when choosing the latest release.

  • Download that release’s examples archive and run it unchanged, comparing with the expected output inside the archive. Do not rebind it to the working tree, and do not test the default branch’s examples here: new APIs on the default branch are not evidence about a release.

  • Use the default branch’s scripts, on roc-stable, to drive the test.

  • Run on every target the release is tested on: the one runner of a pure Roc package, or the same matrix as ci.yml.

  • A platform’s host bindings are not checked here. A released host is fixed; what matters is whether its applications still build and run.

The archive is immutable, so a failure is never a reason to change it. The fix is a patch release that works with the new compiler.

Reporting

A separate job reports the result by calling actions/nightly-report, which keeps one tracking issue: opened on the first failure, commented on by each later failure, and closed by the next pass. The action defines which issue that is and what each outcome does. The workflow supplies the rest:

  • The report job has if: ${{ always() }}, so that it runs when the test job fails, and holds issues: write and nothing else.

  • It checks out nothing and runs no repository or archive code.

  • The test job gives the step that runs the examples an id and exposes that step’s outcome as a job output, with the nightly tag and the release tag. The report job passes those three values to the action and nothing else: never text produced by the examples under test.

  • The outcome is the step’s, not the test job’s result. A job that failed while installing the compiler or downloading the archive has a failed result and an examples step that never ran. The action reports that as a setup failure and leaves the compatibility issue alone.

A repository with no release yet passes with a notice and does not call the action.

This workflow never opens a pull request, commits, merges, or publishes.