Start a Riff
A riff is the place where your sessions and the sessions of your team meet. In a riff, your Claude Code sessions find each other, talk and share work. riff runs on Linux only.
Did a person give you a riff address?
- Yes: go to Join a Riff.
- No, and the riff is only for this machine: do Just this machine. You do not sign in.
- No, and the riff is for a team, or for more than one machine: go to Start a Team Riff. Each person signs in.
flowchart TD
Q{Did a person give you a riff address?}
Q -- yes --> J[Join a Riff]
Q -- no --> P{Only this machine?}
P -- yes --> L[Just this machine]
P -- no --> T[Start a Team Riff]
You need
- Linux with a desktop. riff keeps a key in the keyring of your desktop.
- Claude Code.
- Rust. If you do not have it, install it with rustup.
- A C compiler. On Ubuntu, install it with
sudo apt install build-essential.
Just this machine
-
Install riff:
cargo install --locked --git https://github.com/como-technologies/riff riff riff-server -
Start the riff of this machine in a terminal of its own. Keep that terminal open: the riff stops when you close it or press Ctrl-C:
riff-server -
In a second terminal, go to your project. Get ready for riff, and start your lead. You do not sign in: riff uses the name that you log in with on this machine. See Get this machine ready and Let the lead start the work.
Use it
In your project, run riff lead start. Your lead runs in a container.
The riff daemon starts the other sessions in containers too. Ask the
lead: “Who else is in the riff?” The sessions ask their questions
there. See The lead. The lead starts at
effort xhigh.
Resume the new riff
A new riff is paused. The sessions talk, but they take no work. When you want them to work, run this in a terminal:
riff resume --riff
See Pause the riff.
Let riff work in auto mode
In auto mode, Claude Code can block riff work: a riff tool,
riff wave run, or a step of a pull request. A session cannot allow
this itself. Run this once in each project, then commit the file that
it names:
riff setup
It adds the missing permission rules of riff to
.claude/settings.json at the top of the repository. It keeps each
rule that is there. It also denies a push to the default branch and
gh pr merge --admin. Start your sessions again to use the rules.
Check the rules
This command changes nothing. It names each missing rule, and exits with status 1 when a rule is missing:
riff setup --check
When rules are missing, riff setup adds them. Run it again.
For the rules of your sessions, see The permission rules of Claude Code.
Update riff
Update riff on your machine. It installs riff and riff-server of
a release. On Linux x86-64, it
downloads the static binaries of the release from GitHub. On another
machine, or for a release with no static binaries, cargo builds them
(see
How riff is built and installed):
riff update
It installs the release that your riff runs. That is not always the newest release (see Releases):
- The riff of this machine: it installs the release that GitHub marks Latest.
- A shared riff: it installs the release of the shared server. After a new release, update when the shared riff runs it. Before that, the command installs the release that the shared riff still runs.
When the riff of this machine runs the old build, the command tells
you to start it again. Press Ctrl-C in the terminal of the riff, then
do step 2 of Just this machine again. A new
start of the riff forgets its messages and its claims, and the riff is
paused again. Run riff resume --riff when you want the sessions to
work.
A riff of another version can refuse riff (see
Builds). When you joined a riff, see
Update riff of Join a Riff.
With the update by itself on, riff installs each release with no
command. The riff daemon of the release then does each step of
riff init that needs no person: its unit and the session
images. It tells you each step that needs you. See
Each step of an update, and
See each step of an update
for riff update --check.
Approve a release
The owner or an admin of a riff that deploys its own server gives the
human gates of each release: the deploy, a rollback and a skip. The
riff daemon tells you in riff chat when a deploy waits. Approve it in
a plain terminal, outside Claude Code:
riff release approve v3.3.0
See The release cycle for each gate.
Get this machine ready
riff runs each work item in a container of its own on your machine: the riff daemon runs it. To get ready, install podman and catatonit, the init of podman. riff starts each container with it. On Ubuntu:
sudo apt install podman catatonit
Then run this in your clone of the project:
riff init
flowchart LR
C["check this machine"] --> R["ask to remove<br/>wide rules"]
R --> S["ask to set up the<br/>repository on GitHub"]
S -- "each check passes" --> D["install and start<br/>the riff daemon"]
S -- "a check fails" --> F["print the fix"]
D --> I["get the session image<br/>of the project"]
riff init prints one line for each check, with pass, step or
fail:
| Check | It must be true |
|---|---|
| podman | podman --version runs. |
| The init of podman | catatonit is in a folder of podman, for example /usr/libexec/podman. Each session runs it as its first process. |
/etc/subuid, /etc/subgid | Each gives you 65536 IDs or more. The fix adds a free range: it starts after the end of each range in the file, and at 100000 or more. |
| cgroup v2 | Your user gets cpu and memory. |
| The session image | riff.toml of the project pins it. |
| The riff binary | It is static, so riff relay starts in each session image. |
| The repository on GitHub | In a clone of a repository on GitHub: riff.toml is on the default branch, auto-merge and squash merge are on, a ruleset of the default branch requires riff/verify, the Backlog and a wave are there, and a pull request runs a CI check. riff init checks no CLAUDE.md or AGENTS.md: such a file is the choice of the repository. riff init does the first five itself, after one question. See Add another repository to the riff. |
A line with fail also gives the fix. A line with step is a check of
your repository on GitHub that riff init fixes itself, after one
question. Do each fix, then run riff init again. When each check
passes, riff init installs and starts the riff daemon, and the daemon
gets the session image. A riff daemon that runs already stays. Then do
one item: see
Run one item in a container.
Let the lead start the work
You start no worker by hand. When the current wave has free work, the rollout of your lead asks the riff daemon of your machine to run the wave of the repository. The daemon runs each free item and each verify in a container, in the limit of the machine. The lead gets a note.
To run your lead in a container too, run this in your clone of the project. It also starts the wave of the repository, when none runs. The lead needs the claude token of this machine: see Start the lead.
riff lead start
When another session of you is the lead, riff lead start makes the
new session the lead. It prints which session was the lead before. In
the lead container, whoami says in its first line when the session is
not the lead. To see which session is the lead:
riff who
To see the containers:
riff daemon status
A second machine takes work when its daemon runs the wave too: see Use a second machine.
Get the static riff
A riff of cargo install or just install links the libc of your
machine. A session image can have an older libc, so that riff may not
start there. Then the check of the riff binary fails, and the riff
daemon starts no session. Get the static riff of the release:
riff update
Then run riff init again. The riff daemon needs no restart: it reads
the riff at each start of a session. With update.auto on, riff gets
the static riff by itself: see
Update riff by itself.
The permission rules of Claude Code
In auto mode, Claude Code can refuse a step of riff. So riff writes the
permission rules of its steps in two places. riff init writes no rule
into your own Claude Code settings.
| Place | Written by | Into |
|---|---|---|
| The project | riff setup: see Let riff work in auto mode | .claude/settings.json at the top of the repository |
| The session | the riff daemon, for each session | the settings.json of the own Claude folder of the session |
The project place has these rules:
| Rules | Allow |
|---|---|
mcp__riff | the riff tools |
Bash(riff), Bash(riff *) | each riff command |
Bash(gh pr create *), Bash(gh pr merge --auto --squash *), Bash(gh pr comment *), Bash(gh pr view *) | the steps of a pull request |
Each rule names its program and its words. Its only * is at its
end, after a space. So a rule allows only the steps that it names.
The project place also denies a push to the default branch and
gh pr merge --admin. The deny rules never leave the project.
The riff daemon gives each session the rules of its steps. The lead in
a container calls its riff by the path /opt/riff/riff, so it gets
these rules of its own:
| Rules | Allow |
|---|---|
Bash(/opt/riff/riff relay watch), Bash(/opt/riff/riff relay watch --follow) | the wakes of the lead |
Bash(/opt/riff/riff wave *) | the steps of the waves of the lead: see Waves |
Bash(/opt/riff/riff wave move *), Bash(/opt/riff/riff wave end *) | move items to another wave, end a wave |
Bash(/opt/riff/riff release status), Bash(/opt/riff/riff release cut *), Bash(/opt/riff/riff release hold *) | the steps of the release cycle of the lead: see The release cycle. No rule names riff release approve: only you approve |
When the auto mode check still refuses a step of riff, the session writes the step on its issue and tells your lead. The fix is a new rule in the code of riff, not a step that you type.
The rules of gh on GitHub
The session place has these rules of gh, by role:
| Rules | Allow |
|---|---|
Bash(gh pr create *), Bash(gh pr merge --auto --squash *), Bash(gh pr view *), Bash(gh pr checks *) | the steps of a pull request |
Bash(gh run view *), Bash(gh run rerun *) | a worker reads the log of a failed check, and runs a flaky job again |
Bash(gh issue view *), Bash(gh issue list *) | read an issue |
Bash(gh issue comment *) | a worker and a verifier add to an issue: see Add to an issue |
Bash(gh issue edit *) | the lead edits the body of an issue. A worker and a verifier have deny rules for the body and the title |
One step in each call
Claude Code matches a rule to each part of a Bash call. So a session
of riff runs each riff and gh step alone, in its own call, and
gives each file by its absolute path:
gh issue comment 12 --body-file /home/you/tmp/comment-12.md
A rule allows no call that also has a cd out of the working folders
of the session, for example cd ~/tmp && gh issue comment 12 --body-file comment-12.md. Then the auto mode check judges the whole call, and it
can refuse it. A -R before the words of a step also matches no rule:
put --repo OWNER/REPO after them.
Remove a wide rule
riff adds rules, and removes none with no yes. A rule that you added
by hand can be much wider than its step. riff init reads the allow
rules of your user settings, and of the project settings and the local
settings of the repository of the current folder. It names each rule
that is wider than the steps of riff, with its file, and asks one
question. Answer y to remove them:
riff init
riff removes only the wide rules. It keeps each other rule and each
other key. A wide rule fails no check. riff init --yes and
riff init --check remove no rule. A rule of the Bash tool is wide
when:
- it has no pattern:
Bashallows each command; - or it has a
*that is not at its end after a space, for exampleBash(gh pr merge * --auto --squash); - or it allows a command that no step of riff needs, for example
Bash(git push *)allows a push to the default branch.
The old form Bash(cargo test:*) is the same as Bash(cargo test *).
A rule of another tool, for example Read, is never wide.
Run riff init with no question
This command asks no question. It sets up the repository on GitHub: see Add another repository to the riff. It removes no wide rule:
riff init --yes
Check this machine only
This command changes nothing and asks nothing. It prints the fix of each check that fails and each wide rule. It exits with status 1 when a check fails:
riff init --check
Add another repository to the riff
riff works on other projects too. In a clone of the repository, run
riff init. On a repository that is not set up for riff, it shows
each step and asks one question. Answer y. You must be an admin of
the repository:
riff init
riff init sets up the repository with gh, in this order. It skips
each step that is done already:
| Step | What riff init does |
|---|---|
| Merge settings | It allows auto-merge and squash merge. |
riff.toml | When the default branch has none, it makes the branch riff-setup with riff.toml, opens the pull request “Set up riff”, and squash-merges it. riff.toml pins riff-base, the image of riff with claude, git and gh and no language. |
| The Backlog | It makes the Backlog. |
| The first wave | When the repository has no open wave, it makes Wave 1. |
| The ruleset | It makes the ruleset riff of the default branch: a pull request, squash merges only, and the checks that your CI runs on a pull request, and riff/verify. A repository with no CI gets riff/verify alone. |
flowchart LR
I["riff init<br/>in a clone"] --> C["checks the<br/>repository"]
C -- "a step to do" --> Q["one question<br/>y, or --yes"]
Q --> S["the steps, with gh"]
S --> D["the daemon and<br/>the session image"]
C -- "all done" --> D
riff init makes riff.toml on the default branch of origin. When
your clone is on the default branch, has no change, and lacks only
commits of origin, it runs git pull --ff-only there. Else it says
why it did not, and you run git pull in your clone. A second run of
riff init changes nothing on GitHub and asks nothing. See
Read the output for a repository on GitHub.
One part is your own work: the CI. The full check of a pull request, its
CI, is the gate of a change: riff waits for it before it starts a
verify, and the ruleset lets no change merge without it. riff needs no
CI, though. A repository with no CI has no check but riff/verify, and
riff init says so in a note. See
Use the checks that you have and
A repository with no CI.
A CLAUDE.md or an AGENTS.md is the choice of the repository. riff
needs neither, and riff init checks neither: it prints no line for
them, and a repository with none passes. A session reads the rules of
the repository for agents when it has them. See
Write a CLAUDE.md, when you want one.
The wave starts a session only for an issue with a Done when: line.
riff init names the open issues that lack it, in a note that fails no
check. See
Find the issues with no Done when line.
The names that riff uses on the forge are in
The names riff uses on the forge.
To do the steps with no question, run riff init --yes. To see the
steps and change nothing, run riff init --check. A person who is not
an admin of the repository gets the fix of each check, and riff changes
nothing: ask an admin to run riff init.
Run the checks in the container of the lead
A lead can run riff init --check in its container. A container runs
no podman, and the host runs the checks of the machine. So in a
container riff init skips them, says so in one line, and runs the
checks of the repository:
riff init --check
riff checks this machine:
skip the checks of the machine: the host runs them
...
riff init with no --check does the steps of the repository in the
container, as on the host. It installs no daemon and pulls no image: the
host has them.
Read the output for a repository on GitHub
This is the output of riff init in a clone of acme/app that is not
set up for riff. The answer to the question is y. A line with ...
stands for lines that this page leaves out:
riff checks this machine:
...
step the session image: cannot read the riff config /home/mike/app/riff.toml: No such file or directory (os error 2). Step: make riff.toml on main with a pull request
...
step riff.toml on main: main of acme/app has no riff.toml, so riff lead start stops. Step: make riff.toml on main with a pull request
step auto-merge and squash merge: acme/app allows no auto-merge. Step: allow auto-merge and squash merge
step the ruleset of main: no ruleset of main requires the check riff/verify, so a merge does not wait for the verify. Step: make the ruleset riff on main
step the milestone Backlog: acme/app has no open milestone Backlog. Step: make the milestone Backlog
step the first wave: acme/app has no open wave. Step: make the milestone Wave 1
pass the checks of a pull request: pull requests ran checks / lint and checks / test, and the ruleset that riff makes requires them
...
riff can set up acme/app for riff. These steps change GitHub:
allow auto-merge and squash merge
make riff.toml on main with a pull request
make the milestone Backlog
make the milestone Wave 1
make the ruleset riff on main
Do them? [y/N] y
done allow auto-merge and squash merge: allowed both
done make riff.toml on main with a pull request: opened https://github.com/acme/app/pull/7 and merged it
done make the milestone Backlog: made Backlog
done make the milestone Wave 1: made Wave 1
done make the ruleset riff on main: requires checks / lint and checks / test and riff/verify
riff.toml is on main now. riff ran git pull --ff-only in this clone, so the clone has it.
riff checks this machine again:
...
pass riff.toml on main: main of acme/app has it
pass auto-merge and squash merge: acme/app allows both
pass the ruleset of main: it requires the check riff/verify
pass the milestone Backlog: acme/app has it open
pass the first wave: acme/app has the open wave Wave 1
pass the checks of a pull request: the full check is checks / lint and checks / test
...
This machine is ready. Run one item in a container: riff run ITEM
- A line with
stepis a check that fails, and thatriff initfixes with a step. No fact that a step fixes shows asfail. A line withfailis your work: it gives the fix. - After the steps, riff checks again. A check passes when its step worked.
riff initmakesriff.tomlonorigin. Then it runsgit pull --ff-onlyin your clone when your clone is on the default branch, has no change, and lacks only commits oforigin. Else it says why it did not, and you rungit pullyourself.
When a check still fails, the end of the output names each one with its fix. riff installs nothing then, and exits with 1. For example, when the pull requests ran a check of your CI and no rule requires it, the check of a pull request fails until you add that check to the ruleset:
riff checks this machine:
...
fail the checks of a pull request: pull requests of acme/app ran checks / lint, and no rule requires them, so a merge does not wait for them. Fix: add them to the ruleset of main. See "Use the checks that you have" in the book. Then run riff init again
...
1 check failed. riff installed no daemon and pulled no image. Do each fix, then run riff init again:
the checks of a pull request: add them to the ruleset of main. See "Use the checks that you have" in the book. Then run riff init again
A repository with no CI
riff needs no CI. A repository with no CI has no check but
riff/verify. riff init makes the ruleset with that check alone, and
it says so in a note:
riff checks this machine again:
...
pass the checks of a pull request: acme/app has no CI: no pull request ran a check, so riff/verify is the only check
...
This machine is ready. Run one item in a container: riff run ITEM
The wave starts the verify of a pull request at once, as no check must
end first. The verifier runs the tests that the criteria name, as no
check ran them. GitHub merges the pull request when riff/verify
passes. riff never invents a check for you. When you add a CI later, run
riff init again: it names the checks to add to the ruleset, and
Use the checks that you have shows how.
Do the steps by hand
You can do each step yourself. riff init --check names the step of
each check that fails:
riff init --check
flowchart LR
T["riff.toml on the<br/>default branch"] --> S["a session:<br/>gh pr merge --auto --squash"]
S --> G["your full check<br/>passes"]
G --> V["riff sets<br/>riff/verify"]
V --> R["the ruleset lets<br/>the merge go"]
R --> M["GitHub squash-merges"]
Check of riff init | Step |
|---|---|
riff.toml on the default branch | Add riff.toml |
| auto-merge and squash merge | Allow auto-merge and squash merge |
| the ruleset of the default branch | Make the ruleset of the default branch |
| the Backlog, the first wave | Make the Backlog and the first wave |
| the checks of a pull request | Use the checks that you have |
The checks of a pull request are your own work. Your repository may have a CI already: see Use the checks that you have. With no CI, see A repository with no CI.
Add riff.toml
Each repository of the riff has the file riff.toml at its root, on
its default branch. Its key image pins the session image. Without
the file on the default branch of origin, riff lead start stops
with the error riff.toml is not on origin/main. It starts no lead and
no wave. The base image of riff, riff-base, has claude, git and
gh, and no language. A project adds its toolchain in an image of its
own: see Build the image of a project.
riff sets no cache and no variable of a tool in a session. A project
that wants a build cache adds the tables [session.env] and
[session.volumes] to riff.toml: see
Set the env and the volumes of a session.
This command writes riff.toml with riff-base:
IMAGE=$(gh api repos/como-technologies/riff/contents/deploy/rust/Dockerfile --jq .content | base64 -d | sed -n 's/^FROM //p' | head -n 1)
printf '[session]\nimage = "%s"\n' "$IMAGE" > riff.toml
Commit it on the default branch, and push it. Do this before you make the ruleset: after the ruleset, each change needs a pull request.
git add riff.toml
git commit -m "Pin the session image"
git push
Set up the repository on GitHub
Run each step once, in a clone of the repository, as an admin of the
repository. gh fills {owner} and {repo} from the remote of the
clone.
Use the checks that you have
A repository whose ruleset of the default branch requires checks has a
full check. riff reads the required checks of that ruleset. The full
check is each of them but riff/verify. riff starts the verify of a
pull request when each of them passed or ended on its head. A pass of
riff verify pass needs a success of each of them. A check that GitHub
skipped, or that ended neutral, counts as a success. The daemon
watches main with the same checks: see
When main breaks. riff reads the
rulesets of GitHub, and not the branch protection of the old kind.
To see the checks that riff waits for:
BRANCH=$(gh api repos/{owner}/{repo} --jq .default_branch)
gh api "repos/{owner}/{repo}/rules/branches/$BRANCH" --jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[].context] - ["riff/verify"]'
The answer is a list, for example ["checks / docs"]. An empty list
means that the repository has no CI: riff/verify is the only check,
and riff waits for nothing else. When your CI has checks that the list
does not name, put them in the ruleset before riff/verify: see
Make the ruleset of the default branch.
Allow auto-merge and squash merge
Each work session runs gh pr merge --auto --squash. GitHub refuses
it until the repository allows both:
gh api -X PATCH repos/{owner}/{repo} -F allow_auto_merge=true -F allow_squash_merge=true
Make the ruleset of the default branch
Without a rule, nothing on GitHub makes a merge wait for the verify.
This ruleset needs a pull request, and the check riff/verify. It
allows squash merges only, and it lets nobody bypass it. When your CI
has checks, put each of them before riff/verify, in the same list:
gh api -X POST repos/{owner}/{repo}/rulesets --input - <<'JSON'
{
"name": "riff",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"bypass_actors": [],
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_review_thread_resolution": false,
"allowed_merge_methods": ["squash"]
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": false,
"required_status_checks": [
{ "context": "riff/verify" }
]
}
}
]
}
JSON
Make the Backlog and the first wave
A wave is a milestone named Wave N. The Backlog is the milestone
Backlog: riff wave move --to Backlog and
riff wave end --move-open-to-backlog need it. This command makes the
first wave. It makes the Backlog too, when the repository has none:
riff wave make "Wave 1"
A repository that has waves and no Backlog gets the Backlog alone:
gh api -X POST repos/{owner}/{repo}/milestones -f title=Backlog
Write a CLAUDE.md, when you want one
A CLAUDE.md or an AGENTS.md is the choice of the repository. riff
needs neither. The start text of riff says only what riff needs: the
item, the branch, the frame of the pull request and, for a verify, the
report. A session reads the rules of the repository for agents when it
has them. Write one when you want to give each session rules.
flowchart LR
S["start text of riff<br/>the item, the branch,<br/>the pull request"] --> A["a session"]
C["CLAUDE.md of the repository<br/>its rules, when it has them"] --> A
| Part | What it says |
|---|---|
| Work | The commands to run before a push, and how to read the result. |
| Issues | What a Done when: line names. |
| Verify | What a verifier reads and runs, and what it does not run. |
Write the file with your own commands in place of make check, and push
it with a pull request:
cat > CLAUDE.md <<'EOF'
# Notes for agents
## Work
- Run `make check` before each push. It passes with no warning.
- Each change has a test.
## Issues
- Each "Done when:" line of an issue names a test that you can run.
## Verify
- A verifier reads the diff and runs the test of each criterion.
- A verifier does not run `make check` again: the CI did it.
EOF
The CLAUDE.md of riff is an example of a longer file.
Use any default branch
The default branch can have any name: main, master or trunk.
The wave reads it from GitHub, and keeps it in no file. A
verify reads its diff against that branch, a rebase starts from it, and
the look watches its full check. This command shows the branch that riff
reads:
gh api repos/{owner}/{repo} --jq .default_branch
After you rename the default branch, do nothing: the wave reads the new
name within 10 minutes. When GitHub does not answer, the wave starts no
work at that look, and riff daemon status says why. The wave looks
again each minute.
Build the image of a project
riff publishes two images that a project builds its session image
FROM:
| Image | What it has | Use it |
|---|---|---|
riff-base | Debian, bash, CA certificates, git, curl, ps, gh and claude. No language, no build tool. | For any project. Install your toolchain on it. |
riff-rust | riff-base, the C linker, and one stable Rust with rustfmt and clippy. | For a Rust project that can use that Rust. |
flowchart LR
B["riff-base<br/>claude, git, gh"] --> R["riff-rust<br/>+ Rust"]
B --> P["your image<br/>+ your toolchain"]
R --> P
P -- "push, then the digest" --> T["riff.toml<br/>pins the digest"]
Your image adds the toolchain of your project to one of them. Do not add riff to the image: riff binds its own binary into each session. A session cannot write the toolchain folder of the image, so the toolchain must be in the image.
These two commands read the digest of each image from main of riff.
You pin a digest, so a tag cannot move under you:
BASE=$(gh api repos/como-technologies/riff/contents/deploy/rust/Dockerfile --jq .content | base64 -d | sed -n 's/^FROM //p' | head -n 1)
RUST=$(gh api repos/como-technologies/riff/contents/deploy/session/Dockerfile --jq .content | base64 -d | sed -n 's/^FROM //p' | head -n 1)
The notes of each release of riff name the same two digests. Then write the Dockerfile. This one is for a project that needs Node:
mkdir -p deploy/session
cat > deploy/session/Dockerfile <<EOF
FROM $BASE
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
EOF
A Rust project has two ways. When its CI uses the Rust of riff-rust,
build FROM riff-rust and add what the project needs:
mkdir -p deploy/session
cat > deploy/session/Dockerfile <<EOF
FROM $RUST
RUN apt-get update && apt-get install -y --no-install-recommends libssl-dev && rm -rf /var/lib/apt/lists/*
EOF
When its CI pins another Rust, build FROM riff-base and install that
version. Put the version of rust-toolchain.toml in place of 1.88:
mkdir -p deploy/session
cat > deploy/session/Dockerfile <<EOF
FROM $BASE
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev make pkg-config && rm -rf /var/lib/apt/lists/*
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo PATH=/usr/local/cargo/bin:\$PATH
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal --default-toolchain 1.88 --component rustfmt --component clippy && chmod -R a+rX /usr/local/rustup /usr/local/cargo
EOF
Try the image on your machine before you push it:
podman build -t localhost/session:test deploy/session
podman run --rm localhost/session:test claude --version
Build the image with a workflow
A workflow builds the image on each change of its folder, and pushes it
to the registry of GitHub. The package name is the repository name
with -session:
mkdir -p .github/workflows
cat > .github/workflows/session-image.yml <<'EOF'
name: Session image
on:
push:
branches: [main]
paths: [deploy/session/**]
pull_request:
paths: [deploy/session/**]
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}-session" >> "$GITHUB_ENV"
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v4
- id: build
uses: docker/build-push-action@v7
with:
context: deploy/session
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
provenance: false
- run: echo "image = \"$IMAGE@${{ steps.build.outputs.digest }}\"" >> "$GITHUB_STEP_SUMMARY"
EOF
Merge the Dockerfile and the workflow with a pull request. A pull
request from a branch of the repository runs the workflow too, so the
summary of the run shows the line for riff.toml. Show it:
gh run list --workflow session-image.yml --limit 1
gh run view RUN_ID
Pin the image of the project
Put the digest from the summary in riff.toml. Put your owner and your
repository in place of OWNER and REPO, and the digest in place of
DIGEST. riff refuses a tag:
IMAGE=ghcr.io/OWNER/REPO-session
DIGEST=sha256:DIGEST
printf '[session]\nimage = "%s@%s"\n' "$IMAGE" "$DIGEST" > riff.toml
Merge riff.toml into the default branch with a pull request: the
sessions use only the merged pin (see
Only a merged pin counts).
Make the package public in its settings on GitHub, and check that each
host can pull it: see
Check that each host can pull the image.
Then run riff init. When each check passes, the riff daemon pulls the
image:
riff init
For a repository that has no riff.toml yet, riff init --image pins
the image in the first pull request:
riff init --yes --image "$IMAGE@$DIGEST"
Check that each host can pull the image
The session image must be pullable by each host of the wave. The riff
daemon runs podman pull as your user on the host. riff manages no
registry login, so only a podman login of your user on that host
counts. A host that cannot pull the image starts no session of the
repository.
flowchart LR
P["a pull request<br/>changes the image"] --> V["riff verify pass<br/>asks the registry"]
V -- "no manifest" --> F["no pass, and the fix"]
V -- "the manifest,<br/>with no login" --> M["merge"]
M --> H["each host:<br/>podman pull"]
H -- "fails" --> S["riff daemon status<br/>and a message to the lead"]
To ask the registry the question of a host that has no login, run
this in a clone of the repository. It reads riff.toml of the working
tree, so you can try an edit before its pull request:
riff image --check
When the registry gives the manifest, it prints one line and exits with status 0. When it gives none, it prints the fix and exits with status 1. This is the line for a private package:
ghcr.io gives no manifest of ghcr.io/acme/session@sha256:… with no login (the registry answered 403 Forbidden). A host with no login cannot pull it, and then each new session of the repository stops there. Fix: make the package public, or run podman login ghcr.io on each host of the wave.
Do the fix. Make the package public in its settings on GitHub. Or run this as your user on each host of the wave, and give the login of the registry:
podman login ghcr.io
The verify of a pull request that changes the image in riff.toml
asks the registry the same question. It passes no pull request while
the registry gives no manifest: see
Report a verify. When a host cannot
pull the image of a wave, the daemon starts no item there. It shows the
image and the reason in the line of the wave, and it tells the lead
once with the fix. It pulls again each minute:
riff daemon status
Build the image again for a new riff-base
riff needs a version of claude and of gh at least as new as those of
its riff-base. A new release of riff can need newer tools. At the
first job of a wave of your repository after the update, the daemon
reads the version of both tools in your image. When one is too old, the
lead gets one message. It names the tools and the digest of the new
riff-base. File an item for it, and in the item change the FROM line
of the Dockerfile to the new digest:
BASE=$(gh api repos/como-technologies/riff/contents/deploy/rust/Dockerfile --jq .content | base64 -d | sed -n 's/^FROM //p' | head -n 1)
sed -i "s|^FROM .*|FROM $BASE|" deploy/session/Dockerfile
Merge it. The workflow builds the new image. Then pin its digest in
riff.toml, as in
Pin the image of the project. A
project that builds FROM riff-rust takes the digest of riff-rust
from the notes of the release, in place of $BASE.
To learn more, read How It Works.