# Publications How an exact reviewed revision becomes a pull request or an explicitly authorized direct branch update, and how pull-request merges remain exact-head commands. Source: https://docs.cloud.vampikez.fun/concepts/publications/ After this page you can turn a reviewed working tree into a pull request, poll it to a terminal state, and land it at exactly the commit a human approved — or find out that it moved, instead of merging something nobody read. ## Two commands, not one Publishing and merging are separate durable objects, and the separation is the design: | Object | What it does | |---|---| | **Publication** | Delivers the session's exact reviewed revision through a pull request (default) or an explicitly authorized direct branch update. | | **Merge** | Integrates that pull request at one exact head commit. | They are split because opening a pull request and landing it have different authority, different retry behavior, and different things that can block them — a review, a required check, a branch protection rule. Folding them into one call would mean an integration could not express "publish now, land later, only if the head is still the one that was reviewed", which is the normal case. Both are created with `PUT` to an id you mint. Same id and same body replays as `200` with the object you already have; the same id with a different body is a `409`. Publications and merges are therefore safe to retry after a timeout, in the same way turns are — see [Sessions, turns, runs](/concepts/sessions-turns-runs/). ## Publishing You publish a **revision**, not a diff and not a branch name. Read it first from the session's repository review — the 64-character hex digest of the working tree described in [Repositories](/concepts/repositories/) — and pass it back. That is the "I reviewed exactly this" token: if the agent touches the tree after you read the review, the revision changes and your publish request no longer matches what you looked at. The body has four content fields plus an optional delivery choice: | Field | Required | Notes | |---|---|---| | `revision` | yes | 64 hex characters, from the review. | | `title` | yes | 1–256 characters, no line breaks. | | `body` | yes | Up to 65 536 characters and 65 536 UTF-8 bytes. May be `""`. | | `delivery` | no | `pull_request` (default) or `direct_branch`. Direct delivery is covered by [Push directly to a branch](/guides/push-directly-to-a-branch/). | | `draft` | no | Defaults to **`true`**. It describes a pull request, and direct branch delivery opens none — so the field is refused there rather than accepted and discarded: with `delivery: "direct_branch"`, sending `draft` at all, with either value, is `400 invalid_request`. | The branch is not yours to choose. It is derived from the session, stable across republishes of that session, and lives in a reserved namespace (`wamp/publications-v1/…`). Publishing the same session twice updates the same branch and the same pull request rather than opening a second one, which is why a successful result tells you whether it was reused. Because the pull request is reused, `draft` is reconciled on every publish rather than only honored on the first. Republishing the same session with `draft: false` takes the existing pull request **out of draft**, which is what makes it mergeable. The reverse does not happen: nothing this API does puts a ready pull request back into draft. Both deliveries run on one authority: the Session's repository grant, live at the moment of the publish. Direct delivery is accepted only for a Session bound to such a grant — one whose repository comes from a person's own GitHub connection instead is refused it outright. The direct request has no branch/ref parameter. The server uses the base branch and OID pinned when the Session was created, verifies that the reviewed head has that exact single parent, and uses an exact expected-head lease for the update. GitHub branch protections and rulesets remain authoritative. ### Publishing needs a live workspace `PUT /v1/sessions/{sessionId}/publications/{publicationId}` reads the reviewed tree out of the sandbox, so it needs one. A session whose lease has lapsed answers `409 cloud_workspace_expired`, and one that has no lease at all answers `503 cloud_workspace_unavailable` — the same pair `GET /v1/sessions/{sessionId}/repository/review` returns. Neither is a lost publication. `PUT /v1/sessions/{sessionId}/workspace` provisions a fresh sandbox and replays the session's checkpoint into it, after which you re-read the review and publish the revision it now reports. Read [Sandboxes and environments](/concepts/sandboxes/) for when that recovery is available; it is refused with `409 cloud_resume_unavailable` when `continuation.canContinue` is false. Publish while the session is still warm if you can. It is the cheaper path, and recovery re-reads the review because the revision is a digest of the tree the sandbox actually holds. ### Publication lifecycle ```text admitted ──▶ prepared ──▶ attempted ──┬──▶ succeeded └──▶ failed ``` `admitted` means the command is committed and will be worked on; the three non-terminal states are the server making progress, not something you drive. You poll the publication until `status` is `succeeded` or `failed`, exactly as you poll a run. **At most one non-terminal publication per session.** A second concurrent publish is `409 cloud_publication_in_progress`. That is a queue you do not have to build. On success, `result` appears: ```json { "publication": { "id": "e8b1c4d6-0a2f-4c3b-9d1e-5f6a7b8c9d01", "status": "succeeded", "delivery": "pull_request", "revision": "5c1e…", "title": "Fix CSV export rounding", "result": { "delivery": "pull_request", "branch": "wamp/publications-v1/cloud-3f7d4f4c-2b6a-4a2e-9c1a-1f2b3c-6b031ca6", "commitSha": "7c9e1a3b5d7f9012345678901234567890abcdef", "pullRequest": { "number": 412, "url": "https://github.com/…/pull/412", "draft": true, "reused": false } }, "completedAt": "2026-08-11T09:12:44.010Z" } } ``` `result` is present only on `succeeded`. A direct result instead has `delivery: "direct_branch"`, `branch`, and `commitSha`, with no fabricated `pullRequest`. On `failed`, read `lastError.code`. The `pullRequest.reused` flag is the one field people forget: `true` means an existing pull request was updated, so your product should say "updated PR #412", not "opened PR #412". Two other places the same fact arrives: the event log gets `wamp.publication.created` (or `wamp.publication.failed` with an `errorCode`), and the session gains a `github.pull_request` artifact carrying the `publicationId`. See [Artifacts](/concepts/artifacts/). Direct delivery emits the same `wamp.publication.created` event with `kind: "github.branch"` and `delivery: "direct_branch"`; it does not create a pull-request artifact. If the base moved or GitHub policy rejects the update, review again and create a new Publication. WAMP never retries it without the exact expected base OID. ## Merging A merge names the head it was prepared against. You do not supply that head — the server takes `expectedHeadSha` from the publication, and the merge is submitted to GitHub against that exact commit. If someone pushed to the branch in between, the merge **fails** rather than integrating commits nobody reviewed. That is the whole point of the separation. The body is optional, and all four fields have safe defaults or are omitted: | Field | Default | Notes | |---|---|---| | `strategy` | `squash` | `merge`, `squash`, or `rebase`. | | `mode` | `now` | `now` or `when_ready`. | | `commitTitle` | — | 1–256 characters, no line breaks. | | `commitMessage` | — | Up to 65 536 characters and 65 536 UTF-8 bytes. | `GET /v1/capabilities` advertises the strategies and modes the server accepts; read them from there rather than hardcoding the list. A merge can only be admitted for a publication that has already `succeeded` and has a pull request. Asking earlier is `409 cloud_publication_merge_not_ready`. ### Merge lifecycle ```text admitted ──┬──▶ attempted ──┬──▶ succeeded └──▶ waiting ────┴──▶ failed ``` | `status` | Meaning | |---|---| | `admitted` | Committed, not yet attempted. | | `attempted` | The merge has been submitted. | | `waiting` | Legal, but not yet allowed to land: a required check, a review, or a branch protection rule has not cleared. Only `mode: when_ready` parks here; it is retried. | | `succeeded` | Landed. `mergedCommitSha` is the resulting 40-character commit id. | | `failed` | Did not land. `lastError.code` says why. | Three invariants worth building on, each enforced in the database rather than by convention: - **`mergedCommitSha` is present exactly when the merge succeeded.** Its presence is equivalent to success; you do not need to trust the status field alone. - **At most one successful merge per publication, ever.** Not "one at a time" — once. A publication that has landed cannot be landed again, and a request to do so is a `409`. - **At most one live merge per publication.** While one is `admitted`, `attempted` or `waiting`, a second merge id for that publication is `409 cloud_publication_merge_conflict`. `attempts` is exposed because the server retries transient failures on its own. A rising `attempts` on a `waiting` merge is the system working, not your problem. Those last two invariants leave one trap, and it has a way out. A merge id is yours to mint, but a publication admits only one live merge — so if you lose the id you minted, you can neither address the command nor mint a replacement. `GET …/publications/{publicationId}/merges` is the recovery path: it pages every merge for that publication, including failed attempts, so you can find the live one and resume polling it. ### The failures that matter They arrive in `lastError.code` on the merge, and in the `errorCode` of `wamp.publication.merge_failed`: | `lastError.code` | What happened | |---|---| | `pull_request_changed` | The head moved after the publication. Nothing was merged. Publish the new revision and merge that. | | `pull_request_closed` | Someone closed the pull request. | | `merge_not_ready` | The pull request cannot land yet — most commonly because it is still a **draft**, which is the default a publication opens with. | | `merge_not_allowed` | The repository refuses this merge, for example a strategy the repository does not permit. | The draft case is the one that surprises people: `draft` defaults to `true`, and a draft pull request is not mergeable. With `mode: now` the merge ends `failed` with `merge_not_ready`; with `mode: when_ready` it sits in `waiting` and lands once the pull request is marked ready and its checks pass. Choose `when_ready` when a human or a CI pipeline is expected to act, and `now` when you believe the pull request is already landable and want a definite answer. Merge itself never clears a draft — it does one thing, against one head. The route out of `merge_not_ready` on a draft is therefore **two steps, in this order**: publish again with `draft: false`, wait for that publication to reach `succeeded`, and only then admit a new merge id. A backend that must land its own work should just publish with `draft: false` from the start. ## A worked sequence Review, publish, poll, merge — four calls, two ids you mint. ```bash PUB_ID=$(uuidgen | tr 'A-Z' 'a-z') MERGE_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/$PUB_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.", draft: false }')" # Poll until status is succeeded or failed. curl -sS "$WAMP_API/v1/sessions/$SESSION_ID/publications/$PUB_ID" \ -H "Authorization: Bearer $WAMP_TOKEN" | jq '.publication | {status, result, lastError}' curl -sS -X PUT \ "$WAMP_API/v1/sessions/$SESSION_ID/publications/$PUB_ID/merges/$MERGE_ID" \ -H "Authorization: Bearer $WAMP_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"strategy":"squash","mode":"when_ready"}' ``` Note `draft: false` in the publish body: it is what makes the merge above able to land at all. Then poll the merge the same way, and watch for `wamp.publication.merge_succeeded` or `wamp.publication.merge_failed` in the event log if you are already following the session. ## What you may assume, and what you must handle **Assume.** A publication's `revision` and a merge's `expectedHeadSha` are fixed at admission. A merge never lands a commit other than that head. A publication that succeeded twice does not exist, and a publication that merged twice cannot. Retrying an identical `PUT` is free. **Handle.** `409 cloud_publication_in_progress` while one is in flight. `409 cloud_publication_conflict` and `409 cloud_publication_merge_conflict` from a replay whose body drifted — usually a re-read review with a new revision, which needs a **new** publication id. `409 cloud_workspace_expired` or `503 cloud_workspace_unavailable` when the sandbox holding the reviewed tree is gone — recover with `PUT /v1/sessions/{sessionId}/workspace`, then review and publish again. `403 cloud_repository_grant_required` if the grant was revoked between session creation and publish. For direct delivery, also handle a protected or moved base as a review-again conflict; never change the request to a caller-selected ref or force mode. `409 cloud_publication_merge_not_ready` when you merge too early. And a merge that ends `failed` with `pull_request_changed`, which means starting the review-publish cycle again rather than retrying the same merge id. ## Related - [Repositories](/concepts/repositories/) — grants, what one authorizes, and where `revision` comes from. - [Open a pull request](/guides/open-a-pull-request/) — the same flow end to end, with the polling written out. - [Sessions, turns, runs](/concepts/sessions-turns-runs/) — caller-owned ids and idempotent `PUT`. - [Publications operations](/api/operations/tags/publications/) — the generated endpoint reference, and [the API reference](/api/) for the publication and merge objects field by field.