Push and sign a branch
Push the branch of a Workspace with a push Effect, signed with a key that the Agent never reads.
push(options) pushes the branch to the Repo that the Binding of its Repo Slot names. With sign: true, it pushes signed copies of the Agent's commits. In this Workflow, a coder fixes the tests and commits. A person reads the branch at the Gate of the state review, then sends approve. Then the Machine pushes the branch, signed. Save the file as workflows/publish.ts in your Instance:
import { z } from "zod";
import { assign } from "xstate";
import {
agent,
defineEvent,
fail,
jr2Setup,
push,
workspace,
type PushOutput,
type Workspaced,
} from "@jr2/orchestrator";
const finished = defineEvent({
name: "finished",
description: "Call this when the tests pass and you committed the fix.",
input: z.object({}),
});
// The Gate's two answers.
const approve = defineEvent({ name: "approve", description: "Push the branch.", input: z.object({}) });
const stop = defineEvent({ name: "stop", description: "End the run without a push.", input: z.object({}) });
const runInput = z.object({ prompt: z.string(), branch: z.string() });
type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
type Context = BodyInput & { pushed?: PushOutput };
const body = jr2Setup({
types: {} as { input: BodyInput; context: Context },
events: [finished, approve, stop],
actors: {
coder: agent({
model: "anthropic/claude-haiku-5-5",
instructions: "You fix failing tests. You change no test. You commit the fix.",
}),
// Push signed copies of the Agent's commits, with the key of the git.credentials entry.
publish: push({ sign: true }),
fail: fail(),
},
}).createMachine({
id: "publish",
context: ({ input }) => input,
on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
initial: "coding",
states: {
coding: {
invoke: {
src: "coder",
input: ({ context }) => ({ prompt: context.prompt, cwd: context.workspace.repos.app }),
},
on: { finished: "review" },
},
// A person reads the branch, then answers.
review: {
invoke: { src: "gate", input: ({ context }) => ({ meta: { branch: context.workspace.branch } }) },
on: { approve: "publishing", stop: "done" },
},
publishing: {
invoke: {
src: "publish",
onDone: [
{ guard: ({ event }) => event.output.outcome === "pushed", target: "done" },
// rejected, diverged or unknown: the run waits, so that the Sandbox and its commits stay.
{ target: "notPushed", actions: assign({ pushed: ({ event }) => event.output }) },
],
},
},
notPushed: {
invoke: { src: "gate", input: ({ context }) => ({ meta: { pushed: context.pushed } }) },
on: { approve: "publishing", stop: "done" },
},
done: { type: "final" },
failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
},
});
export const machine = workspace(body, {
input: runInput,
repos: { app: "https://github.com/<you>/app.git" },
spec: ({ input }) => ({ branch: input.branch }),
});Write your GitHub user name in place of <you>. The Agent uses the model of the tab.
The sections below give the push a credential and a signing key, and give the Sandbox Effector the program that signs. In the commands, write the namespace of your Instance in place of <namespace>. It is the name in jr2.config.ts, or the name of the Instance folder when jr2.config.ts sets no name.
Give the push a credential
A push uses the entry of git.credentials that the URL of the Repo Slot matches. By default, the clone credential of the entry also pushes. To push with a different credential, give the entry a push field.
The example pushes over https:// with the token in JR2_GIT_TOKEN. On GitHub, make a token with "Contents: Read and write" on the repository. Put it in .env, with the token in place of <token>:
JR2_GIT_TOKEN=<token>Give the cluster git credentials tells how to use an ssh key, and how an entry matches a URL.
Make a signing key
jr2 never signs with the clone key or the push key. Make a key for signing only. Run this command outside the Instance folder, so that the private key does not go into a commit. It writes the private key to jr2-sign and the public key to jr2-sign.pub, with no passphrase. -C gives the key a label. With -q, it prints nothing:
ssh-keygen -q -t ed25519 -N "" -C "<your email>" -f jr2-signAdd the public key in jr2-sign.pub to your account on the forge, as a signing key. On GitHub, add it in Settings, under "SSH and GPG keys", with the key type "Signing Key".
Put the private key in a Secret, under the key identity. The namespace exists after the first jr2 up:
kubectl -n <namespace> create secret generic jr2-git-sign --from-file=identity=jr2-signsecret/jr2-git-sign createdThen delete jr2-sign. The jr2 up in Run jr2 up copies the key into the push Secret of the entry, and the Sandbox Effector reads it from there.
Add the key to git.credentials
Give the entry that matches your Repos a sign field. sshKey is the name of the Secret. name and email are the committer of each signed commit:
import { defineConfig } from "@jr2/orchestrator";
export default defineConfig({
git: {
credentials: [
// Your Repos. The token clones and pushes. The key in the Secret jr2-git-sign signs the commits.
{
match: "github.com/<you>/",
token: "JR2_GIT_TOKEN",
sign: { sshKey: "jr2-git-sign", name: "<your name>", email: "<your email>" },
},
// The entry that jr2 init writes, for each other Repo.
{ match: "*", token: "JR2_GIT_TOKEN", sshKey: "jr2-git-ssh" },
],
},
});The file shows only the git field. Keep the other fields of your jr2.config.ts. Write your name and the email of your forge account in place of <your name> and <your email>. The forge checks the email of sign: it must be an email of the account that holds the signing key. Otherwise the forge shows the signature as not verified. The longest match wins, so the first entry is the entry for each Repo under github.com/<you>/.
To sign with GPG, write sign: { gpgKey: "<secret>", name, email }. The Secret holds the private key under the key private.asc, with no passphrase. <file> is the private key, in ASCII armor:
kubectl -n <namespace> create secret generic <secret> --from-file=private.asc=<file>Give the Sandbox Effector the program that signs
A push runs in the Sandbox Effector of the Workspace. By default, the Sandbox Effector runs in the Sandbox Image. It runs the programs of a push only from /usr/local/bin, /usr/bin and /bin. Each form of push needs these programs in the image:
| The push | The programs | The Debian package |
|---|---|---|
Over https://, with a token | git | git |
| Over ssh | ssh, and a passwd entry for its uid | openssh-client |
Signed with an ssh key (sshKey) | ssh-keygen | openssh-client |
Signed with GPG (gpgKey) | gpg and gpg-agent | gnupg |
The images/default/Dockerfile that jr2 init writes installs git and no ssh. Add openssh-client to its apt-get install line. The example shows the file without the comments that jr2 init writes:
FROM node:24-slim
# git for the Agent. openssh-client gives ssh-keygen, which the Sandbox Effector signs with.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client \
&& rm -rf /var/lib/apt/lists/*To keep these programs out of the Sandbox Image, give the workspace() an image for the Sandbox Effector only: effector: { image } (see SandboxOptions). Add tools to the Sandbox tells how to change an image.
Run jr2 up
Run jr2 up. It shows the push, and the push Secret of the entry:
jr2 up…
effects: workflow "publish" — what it runs outside itself (ADR-0067)
publish: push (sign) — 300s, in the Sandbox Effector
…
Workspace of workflow "publish": Sandbox Effector (image the Sandbox Image) holds no Effect secret; pushes Repo Slot "app" with git.credentials "github.com/<you>/"
…
git.credentials "github.com/<you>/": push Secret jr2-git-push-<hash> — pushes over https; signs with ssh
warning: git.credentials "*": the Secret "jr2-git-ssh" does not exist in namespace <namespace> — a push that needs it is refused at provision until it exists and jr2 up runs again (ADR-0051)
git.credentials "*": push Secret jr2-git-push-684888c0 — pushes over https
…<hash> comes from the match of the entry. The entry * names the Secret jr2-git-ssh for a push over ssh. When that Secret does not exist, jr2 up gives the warning, and jr2 refuses a push over ssh under *. A push to your Repos uses the entry github.com/<you>/, so the warning does not stop it. jr2 up reads the image of the Sandbox Effector when jr2 up built that image. When the Workspace names no image and the Instance has no images/default, the Sandbox Effector runs in the stock Harness image. jr2 up knows what that image holds. When the image does not hold a program that the push needs, jr2 up stops. See Rules.
Run the Workflow
Start the run. Write the task in place of <task>. Write the name of the coder's branch in place of <branch>:
jr2 run publish --input '{"prompt":"<task>","branch":"<branch>"}' --detach…
→ context <context> / namespace <namespace>
{"runId":"f000bc5a-4c4f-4cdc-be52-55bc43a5fba0"}The first line (…) is the .env line. It gives the variables that .env sets and your shell does not set, such as JR2_GIT_TOKEN. <context> is your kubectl context, and <namespace> is the namespace of the Instance. The runId is the id of the new run, and your run id is different. Give your run id to the next commands, in place of <run id>.
The Machine is the body of a workspace(), so the id of the Gate of the state review is body.review. jr2 status <run id> shows it under gates. When the run waits at this Gate, read the branch in the Sandbox. The branch is not on the remote yet. Look inside a Sandbox tells how. Then send approve:
jr2 send <run id> --gate body.review --event approve…
→ context <context> / namespace <namespace>
delivered approve to gate "body.review" on f000bc5a-4c4f-4cdc-be52-55bc43a5fba0Answer a Gate tells how to find the open Gate, and how to answer from the Console.
Read the output of the push
Each outcome is output for onDone. Route on outcome (PushOutput):
pushed: the remote has the commit.publishedis the commit on the remote, andsourceis the Agent's commit.rejected: git, the remote or the Sandbox Effector refused the push.stagetells where, andreasontells why.diverged: in a signed push, the commit on the remote is not in the history of the Agent's branch, and jr2 cannot pair it with an Agent commit. jr2 pushed nothing. An unsigned push to a remote branch that moved isrejected.unknown: jr2 cannot tell what the push did.
After a signed push, the commits on the remote are signed copies, with new ids. jr2 then moves the Agent's branch to them, and sync tells the result. The files, the index and the stash of the Agent do not change. jr2 tells the Agent what moved.
After a signed push, when the Agent amends, rebases or resets a commit that it pushed, the next push is diverged. Then jr2 pushes nothing, also with force.
When the push did not succeed, the example waits at the Gate of the state notPushed. Its id is body.notPushed. A final state ends the Workspace, and jr2 then deletes the Sandbox with the commits in it. At the Gate, the Sandbox stays. To push again, send approve:
jr2 send <run id> --gate body.notPushed --event approveOther options
pin: true: push the commit that the input of the Effect names,{ sha: "<full commit id>" }. Use it for a commit that a Gate approved. Withoutpin, jr2 pushes the tip of the branch when the Effect starts.squash: true: push one commit that holds all of the changes. The input gives its message,{ message: "<text>" }. Withoutsign, thecommitterof the entry commits it.force: true: replace what the remote branch holds.slot: the Repo Slot to push. Write it only when the Workspace has more than one Repo Slot.timeout: how long jr2 waits for the push. The default is"5m". After it, the outcome isunknown.
Rules
jr2 up refuses:
- a push in a Workspace with no Repo Slot:
workflow "<workflow>": push "<slot>" runs in Workspace of workflow "<workflow>", which has no Repo Slot: a push publishes a Repo Slot's branch, and there is none — declare one: repos: { app: "<url>" } (ADR-0051) - a signed push whose entry has no
signfield.<push>isworkflow "<workflow>": push "<slot>" pushes Repo Slot "<name>" (<url>):<push> signed, and git.credentials entry "<match>" holds no sign field — add sign: { sshKey: "<a Secret with identity>", name: "<committer name>", email: "<committer email>" } to it; the email must belong to the forge account that holds the key (ADR-0051, ADR-0067) - a squashed push without
sign, whose entry has nosignand nocommitter:<push> squashed without sign, and git.credentials entry "<match>" holds neither sign nor committer — the squashed commit is new and needs a committer; add committer: { name: "<committer name>", email: "<committer email>" } to it, or sign the push — push({ squash: true, sign: true }) — and add sign: { sshKey: "<a Secret with identity>", name: "<committer name>", email: "<committer email>" } to it (ADR-0051, ADR-0067) - a push to a local path:
<push>, a local path — jr2 never pushes to one: git's local transport runs that repository's config and hooks (ADR-0067) - a push over
git://:<push> over git://, which carries no credential — bind the slot over https or ssh (ADR-0051) - a push over
http://. The text continues with the fix for the entry that the URL matches, such asbind the slot to the https url:<push> over http://, and jr2 sends a push credential over https or ssh only — a token over http is the token in the clear, and no ssh key is spent over http; <fix> (ADR-0051) - a push to a URL that no entry matches.
<field>is atokenor ansshKeyfield:<push>, and no git.credentials entry matches "<identity>" — a push spends the entry its url matches; add one: git: { credentials: [{ match: "<match>", <field> }] } (ADR-0051) - a push over a scheme for which the entry holds no credential. For example, the slot binds an ssh URL, and the entry holds only a
token.<scheme>issshorhttps.<field>issshKey: "<a Secret with identity and known_hosts>"ortoken: "<the env var that holds the token>", in apush: { … }field when the entry has one:<push>, and git.credentials entry "<match>" holds no credential a push over <scheme> can spend; add <field> to it (ADR-0051) - a push over
https://when the variable of the token is not set:Workspace of workflow "<workflow>": the push of Repo Slot "<slot>" (<url>): git.credentials entry "<match>" cannot push — its token's variable <NAME> is not set — set it in .env (ADR-0051) - an image with no program that the push needs.
<program>isssh,ssh-keygen,gpgorgpg-agent, and<form>isover ssh,signed with an ssh keyorsigned with GPG.<package>is the package to install, for exampleopenssh-clientorgnupg:Workspace of workflow "<workflow>": the push of Repo Slot "<slot>" (<url>) <form> needs <program>, and its Sandbox Effector's image <ref> holds none in /usr/local/bin, /usr/bin, /bin — the Effector runs jr2's programs from there alone; install it in the image (<package>) (ADR-0068) - the same, when the Sandbox Effector runs in the stock Harness image:
Workspace of workflow "<workflow>": the push of Repo Slot "<slot>" (<url>) <form> needs <program>, and its Sandbox Effector runs in the stock Harness image <ref>, which holds git alone — the Workspace names no image and the instance has no images/default; give it an image that holds <program> (<package>): an images/default Sandbox Image, the workspace()'s image, or its effector: { image } (ADR-0068) - for a push over ssh, an image with no user for the uid that the Sandbox Effector runs as:
Workspace of workflow "<workflow>": the push of Repo Slot "<slot>" (<url>) over ssh needs a passwd entry for the <user> the Sandbox Effector runs as, and its image <ref> has none — ssh exits "No user exists for uid" before it connects; add <fix> (ADR-0068).<user>isuser <name>when the image has aUSERline with a name, oruid <uid>.<fix>isa USER line naming a user the image's /etc/passwd holds, orRUN adduser -D -u <uid> <name> in the image.
jr2 up checks an image that it built, or the stock Harness image. It does not check an image that is a registry ref. It checks only a Repo Slot that the Machine binds. It does not check a URL that the run input or customize() gives.
When the Secret of the signing key does not exist, jr2 up does not stop. It shows this warning: warning: git.credentials "<match>": the Secret "<secret>" does not exist in namespace <namespace> — a push that needs it is refused at provision until it exists and jr2 up runs again (ADR-0051). Then the Orchestrator refuses the push when it makes the Sandbox: Sandbox "<name>" refuses the push of Repo Slot "<slot>" (<url>), and jr2 up placed no signing key for git.credentials entry "<match>" — its token's variable was not set, or a Secret it names did not exist, when jr2 up last ran; set it or create it, and run jr2 up again (ADR-0051)
When a push starts, it refuses:
- a state outside a
workspace():push "<slot>" runs in the Sandbox Effector of a Workspace, and its state is not inside a workspace() — invoke it from a Workspace's body (ADR-0067) - no
slotunder a Workspace with more than one Repo Slot:push "<slot>" runs under a Workspace carrying more than one Repo Slot (<slots>) — say which: { slot: "<slot>" } (ADR-0051) - a
slotthat the Workspace does not have:push "<slot>" names Repo Slot "<name>", which its Workspace does not carry (<slots>) - a Workspace with no Repo Slot:
push "<slot>" runs under a Workspace with no Repo Slot: a push publishes a Repo Slot's branch, and there is none — declare one: repos: { app: "<url>" } (ADR-0051) - with
pin: true, ashathat is not a full commit id:push({ pin: true }): its input's sha is not a full commit id (got <sha>) - with
squash: true, an input with no message:push({ squash: true }): its input carries no message
push() refuses a timeout that is not a positive duration: timeout "<timeout>" is not a positive duration (like "30s", "5m")
A GPG key with a passphrase cannot sign. The push is rejected, with stage sign and this reason: the GPG signing key "<name>" has a passphrase, or does not sign; jr2 signs only with a key that has none. <name> is the push Secret of the entry, jr2-git-push-<hash>, as jr2 up shows it. It is not the Secret that gpgKey names.
Read next
- Effects: the
pushEffect, where it runs, and at most once. - Secrets and trust: who holds the push credential and the signing key.
- Give the cluster git credentials: the entries of
git.credentials. - Add tools to the Sandbox: the Sandbox Image.
- Reference:
push,PushOptions,PushOutputandgit.credentials[].sign. - ADR-0067: the
pushEffect, signed copies and the sync of the branch. - ADR-0068: the Sandbox Effector and its image.
- ADR-0051: the credential entries.