This chapter states who may do what in the workflows and shared actions this manual describes, and why each boundary is drawn where it is. It is normative for the shared actions in roc-automation: a change to their behavior or permissions must keep these boundaries and arrive with regression tests for them.
Two rules carry most of the weight:
-
A job that runs repository or candidate code has no write access.
-
A job with write access runs only reviewed code at a full commit SHA, and handles candidate output as inert files.
The three workflows
| Workflow and job | Token access | Runs repository code |
|---|---|---|
|
|
Yes, including pull-request code |
|
|
Yes |
|
|
No: it runs |
|
|
No: it deploys an artifact built by a read-only job |
|
|
Yes: default-branch scripts and the published examples archive |
|
|
No: it runs |
The publish job accepts only a manual dispatch on the default branch, uses the event commit throughout, and consumes the artifacts of its own run. It never checks out the repository, so a script changed in the tested commit cannot run with release credentials.
The nightly compatibility workflow has no contents, pull-request, actions or
status write access in any job. It cannot commit, merge, dispatch or publish.
Its report job runs actions/nightly-report at a full commit SHA and checks out
nothing. It receives only the outcome of the examples step, the nightly tag and
the release tag from the test job, never text produced by the examples under
test. It edits a single tracking issue, identified by its exact title and a bot
author. Every workflow in a repository shares the GitHub Actions bot identity,
so that test excludes issues opened by people and does not prove which workflow
opened the issue.
No workflow in the model opens a pull request, approves one, or merges one. Every change to the repository is a maintainer’s pull request under the repository’s own rules.
What the jobs with write access run
The publish job and the report job hold the tokens that can change a
repository’s releases and issues. Each runs one shared action,
actions/publish-release or actions/nightly-report, and each action is a Roc
script that runs from the action’s own copy of roc-automation, at the full
commit SHA the workflow names. What runs in those two jobs is therefore:
-
Nix, installed by an install action that the shared action references by full commit SHA; that action fixes the Nix version and downloads its installer from the Nix project over HTTPS;
-
the
blueprintbinary, for the release that roc-automation’s ownBlueprint.rocnames, admitted by its reviewed SHA-256 as Theblueprintbinary describes; -
the environment that roc-automation’s
Blueprint.lockpins:roc-stable, which runs the script, the basic-cli platform the script is built on, theghCLI, which makes every request, and coreutils, which providessha256sum; and -
the script and its modules under
scripts/in roc-automation.
The one full SHA in the workflow’s uses: line fixes all of these. The install
action’s SHA, the recorded hashes of the binary, the lock and the script are all
files of that commit, and the lock pins each input by revision and content
digest. Nix takes the compiler and the platform by the hashes those inputs
record, and takes the tools from nixpkgs at the pinned revision, as built by
its binary cache, whose signatures it checks.
Neither job checks out the calling repository, and neither action runs any of
its code. The calling repository’s Blueprint.roc, lock and scripts play no
part. The report action reads no file from the job’s workspace. The publish
action reads the notes file and the asset files it is given, which an earlier
job built: it sends the notes as text, and it hashes, uploads and compares the
assets as bytes, and executes none of them.
The token is in the environment of the one step that runs the script, where
gh reads it. It is never a command-line argument and is never printed, and
the scripts check every input before the first request.
Nix does not hold the token. The install action would otherwise write the job’s default token into Nix’s configuration, for Nix to present to GitHub on its own fetches. These two actions clear that setting, and stop before installing anything further if Nix still reports a token. Every input Nix fetches is public and pinned by the lock, so it needs none.
Compilers
Blueprint.roc names the release tag used for repository tooling, and
Blueprint.lock pins the overlay by revision and content digest. Changing the
tooling compiler is therefore a reviewed source change. Nix verifies the
compiler archive against the hash the pinned overlay revision records for that
tag. This is content integrity under a reviewed hash, as in
Released artifact verification;
it is not provenance for the upstream compiler build.
Bundles listed under RocPackages are locked the same way. A bundle a script
names and the environment does not list is downloaded by Roc, which checks it
against the content hash in its URL.
The package compiler is the latest published nightly and is not pinned. It is
installed by the official roc-lang/setup-roc action, referenced by full
commit SHA, from a roc-lang/nightlies release over HTTPS. That action does not
check a nightly download against a digest, so the compiler’s integrity rests on
GitHub’s release hosting and the action’s pinned revision. This is accepted
because the nightly only ever runs in jobs that hold no write access. A workflow
resolves the moving release once and passes the tag to every job. Release notes
record the version used.
The blueprint binary
actions/setup-blueprint installs Nix, through an action referenced by full
commit SHA, and downloads the prebuilt blueprint binary from a roc-blueprint
release. It builds nothing. Before the binary runs, the action checks it against
a reviewed SHA-256 and fails on a mismatch. Routine CI verifies that reviewed
hash locally and does not call an attestation service. The hash establishes
which bytes run; it is not provenance for the roc-blueprint build.
The reviewed hashes are recorded in the action, one set for each roc-blueprint release, and are reviewed in the pull request that adds them. A caller references the action by full commit SHA, so the hashes it trusts are fixed by the same reference as the code that checks them. A caller may supply a hash of its own for its runner, which then takes the place of the recorded one.
A release’s binary must be used with that release’s platform URL in
Blueprint.roc, so the action takes the release from that URL. The file is
candidate content in a pull request, and that is safe: the version it names can
only choose among releases whose hashes are already reviewed. A version the
action does not record, with no hash from the caller, fails the step.
The action accepts only a release version, from its version input or from
Blueprint.roc, and builds the download URL itself, under the roc-blueprint
releases of this project, so neither can redirect the download. The binary is
written to a private temporary file and becomes executable and visible on PATH
only after its hash matches. A mismatch, a release with no reviewed hash for the
runner, or a system with no released binary fails the step and installs nothing.
The binary evaluates Blueprint.roc with a Roc compiler of its own. It uses a
roc on PATH only when that is exactly the nightly the binary was built with.
Otherwise it fetches that nightly through Nix from a roc-overlay revision pinned
inside the binary, and Nix verifies the archive against the hash that revision
records. The ROC environment variable overrides this selection.
The action takes no token as an input. The Nix installer it runs reads the
job’s default token and configures Nix to present it to GitHub when it fetches
from there. A cache that requires id-token: write widens a test job’s token
and must be an explicit caller choice, never a default.
Documentation deployment
The build job of docs.yml runs repository sources through
actions/build-docs with contents: read. The deploy job holds pages: write
and id-token: write, runs only for the default branch, checks out nothing, and
deploys the artifact the build job uploaded. A pull request can build the site
and cannot deploy it.
Linker-input release publisher
The pull-request linker-input publisher is a separate controller action for inert, fully declared files produced by a caller’s unprivileged build jobs. A privileged consumer workflow invokes the controller at an exact reviewed SHA. Caller source is never checked out, imported, sourced, or executed in its privileged job. Publication requires manual dispatch on the caller’s live default-branch commit. The controller validates the complete regular-file set and content identities, verifies the producer’s attestations, refuses mismatched existing tags/releases, re-downloads and hashes the complete draft asset inventory, and requires immutable-release enforcement after publication. See Trusted publisher contract for the caller contract.
Released artifact verification
Consumers use reviewed content hashes as the runtime identity of released binary inputs. Routine CI verifies downloaded and cached bytes locally before extraction or execution. It must not depend on a remote signing, transparency, or attestation service when a reviewed hash is available. Provenance attestations are verified when a new release or lock is adopted, during an explicit audit, or by a downstream verifier. See Verify released artifacts for the complete admission and consumption policy.
The pin-bump controller and the release guard
roc-automation also contains actions/nightly, the reusable
update-roc-nightly.yml and check-config.yml workflows, and
actions/check-release. They are not part of the model this manual describes.
This section is the trust reference for that code: read it before changing
their behavior or permissions.
Consumers pin both reusable workflows to a reviewed full SHA. Each shared workflow
pins the controller action and checkout action to full SHAs. The controller source
runs from github.action_path; configuration and git operations use the caller’s
GITHUB_WORKSPACE. Consumer Python files are never imported by the controller.
The scheduled/manual caller must run on the default branch. Prepare records that
trusted checkout’s commit and creates one pin-only commit on automation/roc-nightly.
It refuses an existing branch tip with other changed files and uses an exact git
lease. GitHub’s commit API signs the pin commit, and the controller verifies that
GitHub reports the signature as verified.
The caller grants a permission ceiling. Actual jobs reduce it:
| Job | Token access |
|---|---|
prepare |
contents: write; pull-requests: write |
validate |
contents: read; actions: write; statuses: write |
report |
contents: read; pull-requests: write |
configuration check |
contents: read |
The controller uses GH_TOKEN from github.token, DEFAULT_BRANCH, and GitHub’s
standard repository/workspace/run environment. Prepare additionally accepts FORCE
for an explicit retry. Its outputs are changed, sha, and nightly. Validate
accepts CANDIDATE_SHA and emits JSON runs with workflow, ID, URL, and conclusion,
plus passed, which is true only when every configured run succeeds. Report accepts
that SHA, NIGHTLY_TAG, TEST_RESULT, TEST_PASSED, and VALIDATION_RUNS.
Validation dispatches workflows on the candidate branch with
nightly_validation: true. GitHub API version 2026-03-10 returns their exact run
IDs. The controller verifies run commit, branch, and event, waits for all results,
and refuses stale branch heads. A normally completed failed run becomes a handled,
non-passing candidate: it is reported without failing the controller itself. Missing,
cancelled, skipped, timed-out, or mismatched evidence fails the controller. None of
these states becomes a passing report. Previous success is cleared before a new
candidate is prepared. A timeout links to runs for diagnosis.
A workflow dispatch executes project code under that validation workflow’s own permissions. Each consumer must keep test jobs read-only and guard every release or deployment job. This is a required review boundary, not something the controller can infer from a green workflow result. PR review and branch protection remain maintainer responsibilities; this automation grants no bypass or self-approval.
No credentials are persisted by checkout. Authenticated git pushes receive a short-lived token in the child process environment, never command-line arguments. By default the action merges only its validated pin-only PR. It never approves PRs, bypasses repository rules, publishes releases, or deploys sites. The merge policy is described below.
Merge authority
The boolean auto_merge in trusted configuration defaults to true. Consumers may
set it to false to opt out. The merge job still starts after successful
validation and reporting; its merge phase reads that policy at the trusted event
SHA and exits without merging. The workflow does not gate the job on a value
produced by an earlier job.
It performs no consumer checkout: it reads configuration through the API at the
original default-branch event SHA and executes only the pinned shared controller.
It has contents write, pull-requests read, and actions read permissions. Test jobs
never receive this merge token.
Immediately before merging, the controller independently rechecks the PR, signed bot commit, published upstream tag, configured workflow paths and live run results, branch heads, and active pull-request/strict status-check rulesets. It passes the expected head SHA to GitHub’s normal merge endpoint. The merge commit preserves the exact verified, GitHub-signed candidate commit in history. No bot approval or protection bypass is used. Repository rules must have no bot bypass; administrators remain responsible for protecting those rules and the trusted workflow/config.
Token scopes cannot restrict contents write to one file or one operation. The pin-only restriction is controller policy, backed by required PRs and checks; it is not a native GitHub file-scoped merge permission. All consumers share the GitHub Actions bot identity, so the identity check alone is not proof that a particular workflow created a commit. The shape, trusted base, signature, upstream release, and independently checked validation are also required.
All controller jobs explicitly restrict execution to schedule/manual events on the default branch (and reject tags). Every non-check controller invocation also validates that event/ref boundary before performing any API or git operation. Prepare, validate, and report use checkout’s original event commit under these explicit event/ref guards and verify it equals the event SHA before doing work. The merge job reads that immutable SHA through the API and checks the live default branch before merging. If the branch moves, retry the updater on its current commit.
Opted-in validation publishes pending commit statuses for the active ruleset’s required Actions contexts, then publishes success only after the exact dispatched runs and all corresponding jobs have succeeded. Missing, skipped, or failed jobs cannot produce success. This is needed because GitHub can leave dispatched check runs unattached to an Actions-bot PR. Statuses use the same check names and Actions identity; they do not replace or bypass the required checks. The merge job has no status-write permission and independently rechecks the run and job evidence.
Read-only release policy
actions/check-release is separate from the nightly controller. It requires an
explicit dispatch and exact event checkout, reads the compiler pin from that
checkout, and defaults to a final compiler version for publication. It uses
github.token to read roc-lang/roc release metadata for the exact pin spelling;
missing releases, drafts, prereleases and releases without assets are rejected.
Compiler compatibility branches must match the compiler line. Optional default-
branch permission alone does not permit nightly publication. A separate default-off
allow-nightly-bootstrap input permits an exact nightly only on the actual default
branch; metadata then comes from roc-lang/nightlies with the same published
release and asset checks. Compatibility branches cannot use this exception. Remove
the bootstrap opt-in when the consumer adopts the intended stable-only policy.
This action has no write operations, consumer Python imports, approval, or bypass. The official release metadata is upstream evidence, not verification of compiler binary integrity. Consumers retain compiler installation, actual version checks, tests, artifact identity, tag uniqueness, and publication permissions.
Header compiler roots
Consumers may set compiler_roots in trusted .github/roc-nightly.json to select
exact source-root paths instead of legacy .roc-version. This is an edit-authority
list, not a version registry; versions come from literal roc fields in the
selected app/package/platform headers. Public-example roots belong in the list
when testing released dependencies with each new nightly; their dependency URLs
remain immutable. Independent-compiler example roots stay outside the list.
Paths reject escapes, globs, duplicates and non-Roc files;
local source reads reject symlinks and paths outside the checkout.
Prepare preserves bytes outside each selected pin literal. Before replacing an
existing candidate, and independently before merging, the controller
reads immutable parent/candidate blobs via the API and compares the full contents
against the exact expected literal replacements. The complete changed-file set
must match those roots, with every file modified in place. Extra files or a body
change cannot pass as a compiler update. A stale candidate is interpreted using
the immutable configuration from its own parent, so a reviewed change to selected
roots or migration between .roc-version and header pins cannot make that branch
unverifiable or broaden its historical authority. Merge uses trusted event
configuration and does not check out or execute candidate source. Tests cover
body mutations, config evolution, extra files, selected-root limits and
preservation of unselected examples.
The read-only release guard accepts compiler-root to read the source header;
empty retains legacy .roc-version. An optional paired simulated-stable-pin
and simulated-stable-line permits a clearly labeled simulation only on the exact
roc-<line>.x branch and only for that exact nightly. It still verifies the
published nightly asset release and emits compiler-channel: simulated-stable.
It never reports stable upstream availability. Remove the mapping after rehearsal.
Manifest compiler authority
A consumer may instead set compiler_manifest to an object with a repository-relative
JSON path and a top-level string key. This mode is mutually exclusive with
compiler_roots; omitting both retains the legacy mode. The controller validates
the whole JSON document, rejecting duplicate keys, and replaces only the selected
literal’s contents. It preserves whitespace, line endings, and all other fields.
Paths and symlinked parent directories are checked before local reads. The API
candidate verifier compares the full immutable blobs and exact changed-file set,
including when validating an older candidate under its parent’s configuration.
The manifest does not give the controller permission to edit arbitrary metadata.
The release guard accepts paired compiler-manifest and compiler-key inputs,
mutually exclusive with compiler-root. This records a tested toolchain without
embedding a compiler constraint in a reusable platform’s header. Consumer workflows
must install the selected compiler and verify its actual version. A moving latest
release must be resolved once, not independently by each matrix job.