Push directly to a branch
After this page you can deliver a session’s reviewed revision to the branch it was based on — no pull request, no merge step — and you know exactly what the server guarantees about that push, which commit message and author land, what happens when the branch moved or GitHub refuses it, and how to retry safely.
This is the sibling of Open a pull request. Same
publication command, same reviewed-revision token, same authority, different
delivery. If your flow should go through review on GitHub, use that guide
instead.
What authority it takes
Section titled “What authority it takes”Direct delivery writes to the base branch itself — usually the repository’s default branch — and it runs on the same authority a pull request does:
| Requirement | Who provides it |
|---|---|
| A live repository grant covering the session’s repository | An administrator of the customer’s organization, in the WAMP Cloud web app. Grants are described in Connect a repository. |
A session bound to that grant, through source.grantId |
You, at session creation. A session whose repository comes from a person’s own GitHub connection instead of a grant cannot use direct delivery at all. |
The wamp.cloud.publications:create capability on the calling credential |
The installation’s consent. |
A grant carries no per-delivery permission, so there is nothing to preflight: any grant that lets a session open a pull request lets it push directly. What decides whether a given push lands is the branch’s own protection on GitHub, under What the server guarantees below.
Choosing the branch
Section titled “Choosing the branch”The branch is selectable, but not on this call — it is pinned when the session
is created, through source.baseBranch:
{ "source": { "kind": "github", "grantId": "…", "repositoryId": "1029384756", "baseBranch": "release/2026-08" }}Omit baseBranch and the repository’s default branch is used. Either way the
server resolves that branch to a commit at creation and stores both on the
session as baseBranch and baseOid; PATCH /v1/sessions/{sessionId} cannot
change them. The branch has to exist already — nothing here creates one, and
a name that does not resolve makes session creation answer
400 cloud_repository_branch_not_found. That code is about your request rather
than about anyone’s authority, so a branch name that arrives from a customer’s
configuration is worth validating where you collect it.
A different base branch is therefore a different session. See
Connect a repository for the full source
contract.
The request
Section titled “The request”The call is the same publication PUT as pull-request delivery, with
delivery: "direct_branch". There is no branch or ref parameter on it — the
destination is the base branch pinned above.
PUBLICATION_ID=$(uuidgen | tr 'A-Z' 'a-z')
REVISION=$(curl -sS "$WAMP_API/v1/sessions/$SESSION_ID/repository/review" \ -H "Authorization: Bearer $WAMP_TOKEN" | jq -r '.review.revision')
curl -sS -X PUT \ "$WAMP_API/v1/sessions/$SESSION_ID/publications/$PUBLICATION_ID" \ -H "Authorization: Bearer $WAMP_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -nc --arg r "$REVISION" '{ revision: $r, title: "Fix CSV export rounding", body: "Adds a regression test for the invoice total.", delivery: "direct_branch" }')"import { randomUUID } from 'node:crypto';import type { WampCloud } from '@wamp/app-sdk';
export async function pushDirectly(cloud: WampCloud, sessionId: string) { // Read the review immediately before publishing: `revision` names the exact // tree, and publishing anything else is refused. const review = await cloud.reviewRepository(sessionId);
// Mint and persist the publication id before the call — it is what makes a // retry a replay instead of a second push. const publicationId = randomUUID();
return cloud.publishRepository(sessionId, publicationId, { revision: review.revision, title: 'Fix CSV export rounding', body: 'Adds a regression test for the invoice total.', delivery: 'direct_branch', });}| Field | Required | Constraint |
|---|---|---|
revision |
yes | Exactly ^[a-f0-9]{64}$, from the review |
title |
yes | 1–256 characters, no CR or LF. Trimmed, and it becomes the commit subject |
body |
yes | Up to 65 536 characters and 65 536 UTF-8 bytes. May be "". It becomes the commit message body |
delivery |
yes here | "direct_branch" |
draft |
omit | Refused for this delivery. A direct push opens no pull request to draft, so the field is rejected rather than accepted and discarded — sending it at all, with either value, is 400 invalid_request, and issues names draft |
The commit that lands
Section titled “The commit that lands”One commit is created from the reviewed tree, with the pinned base commit as its
only parent. Its message is the conventional Git shape — title as the subject
line, a blank line, then body:
Fix CSV export rounding
Adds a regression test for the invoice total.An empty body leaves a bare subject. CRLF line endings and trailing blank
lines in body are normalized away.
Who authored it. Both the author and the committer are the app bot, not the end user and not the organization member who triggered the run:
| Field | Value |
|---|---|
| Author and committer name | <app-slug>[bot] — for example wamp-cloud[bot] |
| Author and committer email | <app-slug>[bot]@users.noreply.github.com |
| Author and committer date | The pinned base commit’s own date plus one second, in +0000 |
| Signature | None. Commits are not GPG-signed, so a repository requiring signed commits refuses the push |
That identity is deliberately the same for every publication your installation
makes, which means the commit alone does not say who asked for the work. (A
publication a signed-in person makes through a grant does add a Requested-by:
trailer to the message, for exactly that reason. One your installation makes
carries no trailer — its actor is the installation itself.) The audit trail for that is the publication record and the event log, not git: the
publication carries the actor that created it, and wamp.publication.created
carries the same commitSha you can find in the branch history. Keep the
publication id if you may have to answer “who shipped this” later — it is the
join between the commit and the requester.
The fixed date also means the commit is byte-identical when the same reviewed tree is published twice from the same base, which is what lets a lost response be recovered instead of pushed again.
What the server guarantees
Section titled “What the server guarantees”The push is bound to what the session actually started from, at four levels:
- Session-pinned base branch. Direct delivery pushes to
refs/heads/<baseBranch>and to nothing else. A moved default branch does not change where an existing session delivers. - A pure fast-forward. The reviewed commit must have the pinned base commit as its exact parent. The server validates this before any provider write; a commit that is not a direct child of the pinned base is refused.
- An exact expected-head lease. The push is performed as “update this branch only if it still points at the pinned base OID”. If the branch moved — someone pushed since the session was created — the lease fails and nothing is overwritten. There is no force mode; a divergent branch is a conflict, never a rewrite.
- GitHub stays authoritative. Branch protection, rulesets and required reviews apply exactly as configured. A push GitHub rejects is observed and reported, never retried around. The credential used is write-scoped to the repository’s contents only — it carries no pull-request authority and no way to bypass protection.
Watching it land
Section titled “Watching it land”The publication moves through the same lifecycle as pull-request delivery —
admitted ─▶ prepared ─▶ attempted ─▶ succeeded | failed — and you poll it
the same way. At most one non-terminal publication per session applies here
too.
On success, result carries the delivery discriminator, the branch that was
updated, and the commit that landed — and nothing else:
{ "publication": { "id": "…", "status": "succeeded", "delivery": "direct_branch", "result": { "delivery": "direct_branch", "branch": "main", "commitSha": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d" }, "completedAt": "2026-08-11T09:12:07.512Z" }}There is no pullRequest object — none is created. The event log records
wamp.publication.created with kind: "github.branch" and the same three
fields (branch, commitSha, delivery), and unlike pull-request delivery,
no github.pull_request artifact is appended. See
Events.
When it is refused
Section titled “When it is refused”Two different things are called a refusal, and they arrive by different routes.
The PUT itself is rejected. These are HTTP statuses on the request, and no
publication is created:
| Status | error |
What happened |
|---|---|---|
400 |
invalid_request |
The request body is malformed — a bad revision, a title over 256 characters, a draft field this delivery does not accept |
403 |
cloud_repository_grant_required |
No grant covers the repository, or it was revoked — including a session whose repository is not backed by a grant at all |
409 |
cloud_publication_conflict |
This publication id already exists with a different request. See Retrying |
409 |
cloud_publication_in_progress |
Another publication on this session is still non-terminal |
409 |
cloud_workspace_expired |
The session’s sandbox lease has lapsed, so there is no reviewed tree to publish. PUT /v1/sessions/{sessionId}/workspace, re-read the review, publish again |
503 |
cloud_workspace_unavailable |
The session holds no sandbox lease at all — it was released, or it moved. The same recovery |
The publication is created and then fails. The PUT answers 2xx, and the
reason is in lastError.code on the publication — the same code the event log
records on wamp.publication.failed. Every code below is terminal: the server
does not retry it, and the publication never leaves failed.
lastError.code |
What happened | What to do |
|---|---|---|
branch_conflict |
The branch no longer points at the pinned base OID — someone pushed to it — or GitHub refused the push (branch protection, rulesets, a required signature). Nothing was forced and nothing was overwritten. | See below; the recovery depends on which of the two it was. |
review_changed |
The workspace changed between the review and the push, so the approved revision no longer names the tree that would be committed. |
Re-read the review and publish the new revision under a new publication id. |
no_changes |
The reviewed tree is identical to the pinned base. There is nothing to commit. | Nothing to retry — the agent produced no change. Read the review first if an empty diff is a normal outcome for your flow. |
workspace_lost |
The sandbox holding the reviewed tree was gone by the time the push was prepared. The reviewed tree cannot be reconstructed from the publication. | PUT /v1/sessions/{sessionId}/workspace, re-read the review, publish again under a new publication id. |
repository_read_only |
The GitHub App installation can no longer write the repository — its contents permission was reduced, or the repository left the installation. |
An administrator restores it on GitHub; see Connect a repository. |
cloud_repository_grant_required |
Publishing authority was revoked between admission and the push. | As above. |
branch_conflict covers two different situations, and the recovery differs:
- GitHub refused, the branch unmoved. Protection or a ruleset said no, but the branch still points at the pinned base. Clear the block (or get an exception), re-read the review, and create a new publication from the same session — the lease still matches.
- The base moved. Someone pushed to the branch, so the pinned base OID is no longer the head and no publication from this session can ever fast-forward it. The work is not lost, but it needs a new session created against the current base — or switch this work to pull-request delivery, which does not fast-forward the base at all.
In both cases the answer is a fresh publication id (or a fresh session), never a changed request: there is no caller-selected ref, no force flag, and no way to move the lease. That is the design.
Retrying
Section titled “Retrying”Retries behave the same as everywhere in this API, with two properties worth knowing.
Replaying the identical PUT is safe, and “identical” means the values, not
the bytes. The server stores a hash of the request’s canonical fields —
session, repository, revision, title, body, delivery, draft and the
resolved destination ref — so JSON key order, indentation and whitespace around
the request are all irrelevant, and so is anything the schema normalizes on the
way in (title is trimmed before it is hashed, delivery is filled in from its
default). Replaying the same publication id with the same values returns the
existing publication. Replaying it with a different title, body or
revision is 409 cloud_publication_conflict — the id is bound to the request
it was minted for.
Server-side attempts keep the same lease. If an attempt fails on a transient fault, the publication is retried (up to six attempts) against the same persisted destination and expected OID; the grant is re-checked on every attempt. And if the branch already points at your commit — a push that landed but whose confirmation was lost — the retry observes that and reports success rather than pushing twice.
Once status is failed, it is terminal: mint a new publication id for the
next attempt.
Related
Section titled “Related”- Publications — the lifecycle and the
deliveryfield in context. - Open a pull request — the review-first delivery this guide replaces.
- Connect a repository — grants,
baseBranch, and what a grant authorizes. - Publications operations — the generated endpoint reference.
- Errors — conflict semantics and failure codes.