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

ci.yml test

contents: read

Yes, including pull-request code

release.yml build and test

contents: read

Yes

release.yml publish

contents: write, behind a protected environment

No: it runs actions/publish-release on artifacts produced by earlier jobs

release.yml site deploy

pages: write, id-token: write

No: it deploys an artifact built by a read-only job

nightly.yml test

contents: read

Yes: default-branch scripts and the published examples archive

nightly.yml report

issues: write

No: it runs actions/nightly-report

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 blueprint binary, for the release that roc-automation’s own Blueprint.roc names, admitted by its reviewed SHA-256 as The blueprint binary describes;

  • the environment that roc-automation’s Blueprint.lock pins: roc-stable, which runs the script, the basic-cli platform the script is built on, the gh CLI, which makes every request, and coreutils, which provides sha256sum; 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.