A Release promotes the current draft branch into main as a tagged, versioned snapshot. Core computes the semantic-version bump for you by diffing the two branches, so the version number reflects what actually changed. Releases are managed through core.releases.
With a remote, a Release publishes main, draft and its tag together, so the remote’s draft always holds what its main released.
For the branch model these build on, see git-and-sync.md.
The three operations
| Method | What it does | Touches main? |
|---|---|---|
prepare() | Read-only. Diffs draft against the released main and returns the computed bump and per-object changes. | No |
create() | Fetches origin if set, promotes draft into the released main, bumps the version, tags it, merges back into draft and pushes main, draft and the tag in one atomic push. | Yes |
createPreview() | Fetches origin if set, tags a pre-release snapshot on draft (for example 1.1.0-preview.3) without promoting, pushes draft and the tag in one atomic push. | No |
All three take { projectId }. prepare() returns a ReleaseDiff. create() and createPreview() return a ReleaseResult ({ version, diff }).
What a Release compares against
The released state is a main branch, and which one depends on the remote:
| The Project has | Released state | prepare() | create() and createPreview() |
|---|---|---|---|
no origin | the local main | reads it | read it, nothing is fetched |
an origin with a main | the remote’s main | reads it as last fetched | fetch first, LFS objects included, then read it |
an origin without a main | the local main | reads it | fetch first, then read it |
The remote’s main is read through origin/main, so a local main that is missing after a clone, or behind after another working copy released, changes nothing. create() moves the local main to the released commit before merging, and creates it when it is missing.
prepare() never fetches, so it stays read-only and works offline. A Release made from another working copy shows up once something fetched, such as synchronize(), getChanges() or core.projects.branches.list(). create() fetches anyway, so the version it writes is computed against the remote as of the call.
All three throw PreconditionFailed rather than guess when:
draftlacks commits of the remote’sdraft. The push would be rejected, and a Release never rebases, so whatprepare()showed is what gets released. Synchronize first.- there is no
mainbranch, locally or on the remote, - the local
mainholds commits the remote’smainlacks, so either could be the released state, draftlacks commits of the remote’smain, which the diff would count as removed content. That happens after a Release reached the remote’smainwithout itsdraft, for example one pushed by hand. Synchronize first, and if the remote’sdraftlacks them too, synchronize the working copy that released them.
prepare() checks against the remote as last fetched and create() against the remote just fetched, so a prepare() after any fetch refuses exactly where create() would.
A Project’s first Release compares against the commit core.projects.create() made on main, so everything added since counts.
prepare(): the diff and the bump
prepare() must be run while the Project is on the draft branch (otherwise it throws PreconditionFailed). It is read-only: it changes nothing, it just reports what a release would do.
It diffs draft against the released main across the Project file, Collections and Components with their field definitions, Assets and Entries, and returns a ReleaseDiff:
const diff = await core.releases.prepare({ projectId: project.id });// {// project, // the current draft ProjectFile// bump, // 'major' | 'minor' | 'patch' | null (null = no changes)// currentVersion, // e.g. '1.0.0'// nextVersion, // e.g. '1.1.0', or null if nothing changed// projectChanges, collectionChanges, fieldChanges,// componentChanges, componentFieldChanges, assetChanges, entryChanges,// }How the bump is decided
Each change is classified as major, minor or patch, and the highest classification across all changes wins. The classification follows the contract a consumer of the generated client and types depends on: anything that can break that contract is major, anything that only adds to it is minor, and cosmetic or content-only edits are patch.
| Bump | Meaning | Examples |
|---|---|---|
| major | Breaks an existing consumer’s assumptions | A Collection, Component, Entry or Asset deleted. A field definition deleted or renamed. A Collection’s plural slug or a Component’s slug renamed. A field’s valueType or fieldType changed. The default language changed. |
| minor | Adds something without breaking | A Collection, Component, Entry or Asset added. A field definition added. A supported language added. |
| patch | Cosmetic or content-only | A field’s label, description or defaultValue changed. An Entry’s values modified. The Project’s name changed. |
If draft has commits ahead of main but the diff finds no classified change, the bump defaults to patch. If there are no changes at all, bump and nextVersion are null. Every change a diff can carry lists the rest.
Every change a diff can carry
Every change prepare() can report is below, with the bump it contributes. The names are the *ChangeTypeSchema enums of src/schema/releaseSchema.ts.
| Array | changeType | Bump | What changed |
|---|---|---|---|
projectChanges | defaultLanguageChanged | major | The Project’s default language |
projectChanges | supportedLanguageRemoved | major | The Project supports one language less, so the generated ProjectLanguage drops it and with it every key of a translatable value |
projectChanges | supportedLanguageAdded | minor | The Project supports one language more, which the generated ProjectLanguage gains |
projectChanges | nameChanged | patch | The Project’s name |
projectChanges | descriptionChanged | patch | The Project’s description |
collectionChanges | deleted | major | The Collection is gone, with every Entry it held |
collectionChanges | slugChanged | major | The Collection’s plural slug, which its generated type names, its generated client key and its Astro collection name are built from, and which addresses it in the API. A changed singular slug is not reported, because no generated name, key or path is built from it |
collectionChanges | added | minor | A Collection the released state does not hold |
componentChanges | deleted | major | The Component is gone |
componentChanges | slugChanged | major | The Component’s slug, which its generated type names are built from, which addresses it in the API, and which every item of it carries as componentSlug in Astro |
componentChanges | added | minor | A Component the released state does not hold |
fieldChanges and componentFieldChanges | deleted | major | The field definition is gone, with its value in every Entry |
fieldChanges and componentFieldChanges | slugChanged | major | The field definition’s slug, which is the key its value arrives under |
fieldChanges and componentFieldChanges | valueTypeChanged | major | The field definition’s valueType |
fieldChanges and componentFieldChanges | fieldTypeChanged | major | The field definition’s fieldType |
fieldChanges and componentFieldChanges | minMaxTightened | major | min rose, max fell, or either gained a bound where there was none, on a text, textarea, number, range, asset or entry field |
fieldChanges and componentFieldChanges | isRequiredToNotRequired | major | A field definition that was required is optional |
fieldChanges and componentFieldChanges | isUniqueToNotUnique | major | A field definition that was unique may repeat a value |
fieldChanges and componentFieldChanges | ofCollectionsChanged | major | An entry field’s ofCollections. A narrowed list strips the Entry references it no longer allows, and an update that would strip one is refused until the loss is accepted, see schema-changes.md |
fieldChanges and componentFieldChanges | ofComponentsChanged | major | A dynamic field’s ofComponents. The generated types restate the list, so narrowing it, widening it and replacing an empty list, which allows every Component, with an explicit one are each reported. The lists are compared sorted, so reordering alone is not. A narrowed list also strips the Component items it no longer allows, and an update that would strip one is refused until the loss is accepted |
fieldChanges and componentFieldChanges | slugFormatChanged | major | A slug field’s separator, lowercase or decamelize, which re-canonicalizes every value it holds |
fieldChanges and componentFieldChanges | added | minor | A field definition the released state does not hold |
fieldChanges and componentFieldChanges | isNotRequiredToRequired | minor | A field definition that was optional is required |
fieldChanges and componentFieldChanges | isNotUniqueToUnique | minor | A field definition that could repeat a value is unique |
fieldChanges and componentFieldChanges | labelChanged | patch | The field definition’s label |
fieldChanges and componentFieldChanges | descriptionChanged | patch | The field definition’s description |
fieldChanges and componentFieldChanges | defaultValueChanged | patch | The field definition’s defaultValue, where both versions carry one |
fieldChanges and componentFieldChanges | inputWidthChanged | patch | The field definition’s inputWidth |
fieldChanges and componentFieldChanges | isDisabledChanged | patch | The field definition’s isDisabled |
fieldChanges and componentFieldChanges | minMaxLoosened | patch | min fell, max rose, or either lost its bound, on the same field types as minMaxTightened |
fieldChanges and componentFieldChanges | ofFieldDefinitionsChanged | patch | A slug field’s ofFieldDefinitions, a generation hint that never rewrites a stored value |
assetChanges | deleted | major | The Asset is gone |
assetChanges | added | minor | An Asset the released state does not hold |
assetChanges | binaryChanged | patch | The Asset’s extension, mimeType or size |
assetChanges | metadataChanged | patch | The Asset’s name or description |
entryChanges | deleted | major | The Entry is gone |
entryChanges | added | minor | An Entry the released state does not hold |
entryChanges | modified | patch | The Entry’s values |
Every row compares the released state against the current draft, never the commits in between, so a rename and its undo inside one draft is no change at all. A Collection or Component added since the last Release is reported as added alone, whatever its slug did afterwards.
A Component’s field definition can be neither unique nor a slug field, so the four rows that need one of those reach fieldChanges alone.
What a major Release asks of a consumer
A major Release says something your build depends on changed, either a name or a shape it is written against, or content it reads. The warning you get is the version number, and a build that provisions the release channel logs the version it landed on and carries on, so read it as a warning rather than a gate:
- Provisioning the
releasechannel, the default, lands on the newest Release whatever its bump, so the next build takes a major one like any other. - Pin the exact version you have, in the integration config or through
--refonelek provision, to decide yourself when to move. Seeprovisioning.md. - Run
elek generate:typesandelek generate:clientagainst the new Release and fix what stops compiling. elekCollections()names each Astro collection after the alias you chose for the Project indefineElekConfig()and the Collection’s plural slug, so a renamed Collection renames the namegetCollection()takes. The alias is not the Project’s name, so renaming the Project changes no collection name, seeusage.md.- A deleted Entry or Asset and a changed default language are major as well, and none of the three changes a generated type or a generated client, so nothing stops compiling. A page built from a deleted Entry is no longer generated at all, so compare the pages the build emitted rather than only what it compiled.
prepare() reports every change behind the bump before the Release is published. It runs on the draft branch, so whoever cuts the Release reads it rather than you. A Collection or Component change carries the id of what changed, so the name is yours to look up, see usage.md. A field change carries its fieldSlug as well.
create(): cutting a release
create() fetches origin if set, computes what prepare() would and refuses to proceed if there is nothing to release (PreconditionFailed). Otherwise it:
- switches to
main, first moving it to the remote’smainwhen that is the released state, - merges
draftintomain, - writes the
nextVersionintoproject.jsonand commits it (commit methodrelease), - creates an annotated git tag carrying
Type: releaseandVersion: <nextVersion>, - switches back to
draftand mergesmainback in (so both branches share the version commit), - pushes
main,draftand the new tag tooriginin one atomic push, if a remote is set.
A Release is the publish moment: the push makes the released content available to consumers that read from the remote, such as CI builds. A Project without a remote releases locally, nothing is pushed.
const result = await core.releases.create({ projectId: project.id });// result.version -> '1.1.0'// result.diff -> the ReleaseDiff that was releasedAfter a full release, the Project’s version field is identical on draft and main.
createPreview(): pre-release snapshots
createPreview() also fetches origin if set, computes what prepare() would and requires changes, but it never touches main. It computes a pre-release version by counting the existing preview tags for the same target version since the last full release, then:
- writes the preview version (for example
1.1.0-preview.3) intoproject.jsonondraftand commits it, - creates an annotated tag carrying
Type: previewandVersion: <previewVersion>, - pushes
draftand the tag tooriginin one atomic push, if a remote is set.
Previews are snapshots of the current draft state for testing or sharing. Only create() promotes content into main.
The atomic push
With a remote, a Release publishes its refs in one git push --atomic. The remote takes every ref or none, so a rejected ref leaves it as it was, and the Release unwinds like any failed one. The exception is a ref the remote hides with git’s receive.hideRefs, which git refuses on its own while the other refs land.
| Case | What Core does |
|---|---|
The remote’s draft advanced since the fetch | The push is rejected as non-fast-forward, PreconditionFailed. Synchronize and release again. |
| The remote declines one ref, for example in a hook | No ref lands, Internal carrying git’s output. |
The remote has no draft | The push creates it. |
| The remote does not support atomic pushes | git refuses before sending anything, PreconditionFailed. |
Core does not fall back to separate pushes on a remote without atomic pushes, because a push that lands main without draft breaks the rule a Release keeps. Releasing to such a remote needs a git host that supports them.
Connected to a new, empty remote, a Project synchronizes before its first Release there. The first push of synchronize() sends main, draft and the tags of every earlier Release, see git-and-sync.md. A Release sends only its own tag, and a preview Release sends draft without main.
When a release fails partway
A release is a transaction you retry, not a state you resume. There is no public call that finishes a half-made release, so both create() and createPreview() undo what they already did before the error reaches you:
- a tag they created is deleted,
- every branch they moved is reset to the commit it was on,
- a local
mainthatcreate()made for the Release is deleted again, - the Project is left checked out on
draft.
A failed push unwinds too, so a release is never left made-but-unpublished. Retry the same call once the remote is reachable and it produces the release the failed attempt would have.
The push is atomic, so a rejected one landed nothing. What recovery cannot reach is a push the remote applied while its answer was lost, for example to a dropped connection. core.releases.list() reads local tags, so compare against the remote when a push failed on the network.
Recovery itself is best effort: a step that fails is logged at warn and the error you get is still the one that caused the failure, never one raised while undoing it. If those warnings appear, the Project needs a manual git reset.
Reading releases
core.releases.list() returns the Releases and preview Releases of a Project, newest first. Each item carries the version, its type (release or preview), the datetime, and the tagId addressing it in git.
const { list, total } = await core.releases.list({ projectId: project.id });// list[0] -> { version: '1.1.0', type: 'release', datetime: '...', tagId: '...' }Underneath, Releases, previews and Core upgrades are git tags, read through core.git.tags:
const { list } = await core.git.tags.list({ path: core.util.pathTo.project(project.id),});Each GitTag carries an id, an author, a datetime, and a discriminated message:
{ type: 'release', version }{ type: 'preview', version }{ type: 'upgrade', coreVersion }(created by the Project upgrade flow, not bycore.releases)
core.git.tags also exposes read({ ... }) and count({ ... }).
Versioning
The Project’s version field follows Semantic Versioning opens in a new tab and is the value a release writes. main holds the released version. draft tracks it after a full release and carries pre-release versions (-preview.N) in between.
See also
git-and-sync.md- themain/draftbranch model and tagsschema-changes.md- how the field-definition changes a release reports actually transform contentconcepts.md- Releases in the overall data model