# Push directly to a branch Land a session's reviewed work straight onto its base branch with delivery "direct_branch" — what authority it takes, what the server guarantees, and how a rejection arrives. Source: https://docs.cloud.vampikez.fun/guides/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](/guides/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 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](/guides/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](#what-the-server-guarantees) below. ## Choosing the branch The branch is selectable, but not on this call — it is pinned when the session is created, through `source.baseBranch`: ```json { "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](/guides/connect-a-repository/) for the full `source` contract. ## 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. ```bash 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" }')" ``` ```ts check 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 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 | `[bot]` — for example `wamp-cloud[bot]` | | Author and committer email | `[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 The push is bound to what the session actually started from, at four levels: 1. **Session-pinned base branch.** Direct delivery pushes to `refs/heads/` and to nothing else. A moved default branch does not change where an existing session delivers. 2. **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. 3. **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. 4. **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 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: ```json { "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](/reference/events/#wamppublicationcreated). ## 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](#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](/guides/connect-a-repository/#when-a-grant-is-replaced). | | `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 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 - [Publications](/concepts/publications/) — the lifecycle and the `delivery` field in context. - [Open a pull request](/guides/open-a-pull-request/) — the review-first delivery this guide replaces. - [Connect a repository](/guides/connect-a-repository/) — grants, `baseBranch`, and what a grant authorizes. - [Publications operations](/api/operations/tags/publications/) — the generated endpoint reference. - [Errors](/reference/errors/) — conflict semantics and failure codes.