Build a release from files
Builds a release from the resources of .zitadel/ as authored on disk,
grouped by kind, in one transaction: a failure anywhere leaves no
revision and no release. The caller records nothing.
If the project already holds a release with the same content, the same
content_hash, the request answers 200 with that release and
allocates nothing, however long ago it was built. Otherwise every resource is compared against the
project's newest revision of the same handle: unchanged content reuses
that revision, changed content allocates a new one, and the request
answers 201 with the new release. A policy whose config matches only an
older revision allocates a new one too, so a request that resolves no
release, which reads the newest revision, applies it as well. Metadata is excluded from the
comparison, so the same content under a different message is the same
release.
A structurally malformed request is refused while decoding with
req.invalid, naming the offending fields. A well-formed bundle is then
checked resource by resource, as each kind's create endpoint checks it,
and every failure is reported at once: the request answers 400 with
rel.invalid, and details.resources lists each resource that failed.
A handle that resolves neither in the bundle nor in the project answers
rel.unresolved_reference, naming the pointer and the handle.
Building writes revisions, so besides release.write it needs the write
scope of every kind the bundle carries a resource of: schema.write,
flow_definition.write, branding.write, idp.write and
policy.write, as the per-kind create endpoints do. A missing scope answers 403 with rel.permission_denied,
naming the kind. An empty kind needs no scope.
Building a release does not deploy it. A release is not tied to an origin, and the same release can later be deployed to any number of origins unchanged.
oauth2 release.writeAuthorizationBearer <token>Scope: release.write
project_id*stringThe unique identifier of the project
^[a-zA-Z0-9_-]+$application/json- body
The resources to build a release from, plus the metadata to record alongside it.
created_at and created_by are derived server-side from the caller's
authentication context and are not accepted here.
bundle*The resources of a project's .zitadel/ directory as authored on disk,
grouped by kind.
Resources reference each other by handle: a flow definition's
user_schema names a schema by its objectType, and a flow step's
sso_providers, like a schema's x-auth-methods.sso.providers, name IDP
connections by slug. A reference resolves to
the resource with that handle in this bundle; failing that, to the
project's newest revision of it, which the release then pins as well. A
handle that neither holds refuses the release, naming the pointer and the
handle. A bundle that references only resources it carries builds the
same release on any project.
A handle names one resource of its kind in a bundle. Two schemas with the
same objectType, two flow definitions with the same name, two IDP
connections with the same slug, two policies for the same operation,
or a schema without an objectType refuse the release with rel.invalid,
with an entry for each such resource.
Each kind is optional, and the arrays may be empty: a project that never
ejected its branding omits branding. The bundle carries at least one
resource, and the release it builds pins at most 50, the most a release
pins: the resources the bundle carries plus those resolved from the
project. The server refuses anything outside that with rel.invalid.
Unknown keys are refused. More kinds join as they become release kinds.
message?stringA short summary of what the release changes, analogous to a git commit message. Recorded on the release and shown when listing releases.
length <= 1024git_sha?stringThe source commit the caller was operating from.
length <= 64git_dirty?booleanSet when the working tree had uncommitted changes, so the release content
does not correspond exactly to git_sha.
falseThe project already held a release with this content, built earlier, and it was returned unchanged. No new release and no new revision was created.
application/json- response
An immutable, project-scoped snapshot that pins one revision of every resource it includes.
A release owns pointers and metadata, not content — it records which
revisions belong together. Callers that need the resource bytes resolve each
revision_id through the per-kind read endpoints (GET /schemas/{id},
GET /flow_definitions/{id}, …).
A release exists on its own and is not tied to an origin. Deploying it is a separate call.
Two identifiers: id is the one paths and release_id fields take;
content_hash is the digest of the content the release pins, printed by
the CLI and pinned by builds, and looked up through fields that name it.
id*release-idThe opaque, immutable resource id, assigned at construction.
^rel_[a-zA-Z0-9_-]+$project_id*project-idThe project this release belongs to.
^[a-zA-Z0-9_-]+$content_hash*ReleaseContentHashThe digest of the pinned content. Identical for two releases with the same content, which is why a project never holds two such releases.
^sha256:[0-9a-f]{64}$71 <= length <= 71metadata*Who assembled the release, when, and from what source. Set when the release is assembled and never mutated afterwards.
revoked_at*|When the release was revoked, or null while it is not. A revoked release cannot be deployed and is refused on every path that would serve it, including to a build that pins its digest. No origin serves a revoked release, since a release an origin still serves cannot be revoked; earlier deployments that named it stay in the history.
pointers*array<>The revisions this release pins, one entry per (kind, handle). Never
empty: a release must contain at least one resource, and never longer
than the set POST /releases accepts.
1 <= items <= 50import { createZitadelClient } from "@zitadel/api/client";const client = createZitadelClient({ baseUrl: process.env.ZITADEL_URL ?? "http://localhost:8080", token: process.env.ZITADEL_PROJECT_SECRET!, // server-side only});const release = await client.buildRelease( { bundle: { schemas: [ { $schema: "https://json-schema.org/draft/2020-12/schema", kind: "user-schema", metaSchema: "https://auth.acme.com/user-schema.json", objectType: "human-user", "x-auth-methods": { password: { enabled: true } }, "x-identifier": "email", properties: { email: { type: "string", format: "email", "x-unique": "project", }, phone_number: { type: "string" }, }, }, ], flow_definitions: [ { name: "default-login", status: "active", user_schema: "human-user", purposes: { login: "identify" }, steps: [ { name: "identify", fields: ["email"], actions: [ { name: "submit", kind: "submit", primary: true, }, ], transitions: { submit: { target: "authenticate" } }, }, { name: "authenticate", fields: ["x-auth-methods#password"], actions: [ { name: "submit", kind: "submit", primary: true, }, ], transitions: { submit: { target: "done" } }, }, { name: "done", complete: "redirect" }, ], }, ], idps: [ { slug: "google", protocol: "oidc", template: "google", display_name: "Google", subject_claim: "sub", claim_mapping: { email: "email" }, oidc: { issuer: "https://accounts.google.com", client_id: "${{ GOOGLE_CLIENT_ID }}", client_secret: "${{ GOOGLE_CLIENT_SECRET }}", scopes: ["openid", "email"], }, }, ], policies: [ { operation: "auth.factor.password.set", config: { min_length: 12 }, }, ], branding: { layout: "centered", liquid_template: "<main>{{ step.name }}</main>", }, }, message: "add phone_number to human-user", git_sha: "4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7", git_dirty: false, }, { project_id: "proj_01KWH1P4MYS7F0N9WD3P2E4YM5" },);The content matched an existing release, so that release is returned — note the message it was originally created with, not the one just submitted.
{ "id": "rel_01KX3RG8A7F0N9WD3P2E4YM5C1", "project_id": "proj_01KWH1P4MYS7F0N9WD3P2E4YM5", "content_hash": "sha256:9f2c1a7b4e83d05f6c2b19ae7d430f821c6b5de90a4f73829f2c1a7b4e83d05f", "metadata": { "message": "add phone_number to human-user", "git_sha": "4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7", "git_dirty": false, "created_at": "2026-07-14T09:11:00Z", "created_by": "user_01KWH3B72K7M7F0N9WD3P2E4YM", "created_by_type": "human" }, "revoked_at": null, "pointers": [ { "kind": "schema", "handle": "human-user", "revision_id": "sch_01KWHF18816ZQE7F0N9WD3P2E4" }, { "kind": "flow_definition", "handle": "default-login", "revision_id": "flowdef_01KWHG09JXA7F0N9WD3P2E4YM5" }, { "kind": "branding", "handle": "default", "revision_id": "brnd_01KWH1P4MYS7F0N9WD3P2E4YM5" } ]}Was this helpful?