Content Releases
Group documents into a release and publish or unpublish them together, at a time you choose.
A release is a set of documents that go live at the same instant. A product launch touching a landing page, three blog posts and a pricing single becomes one thing you schedule, rather than five things someone has to publish in the right order at 9am.
Each member of a release is set to either publish or unpublish, so the same release can put new pages live and retire old ones in the same moment.
Availability
| Built in | No plugin required |
| Admin | A Releases section with a calendar view, and an "Add to release" action on documents |
| Permission | Scheduling requires publish access. A key scoped only to publishing releases can schedule without holding a general read grant |
What it is not
This is scheduled publishing, not an approval workflow. There are no review stages, transitions or assignees. If you need "editor submits, manager approves, then it goes live", that is not what a release does.
The shape of a release
| Step | What happens |
|---|---|
| Create | An empty release with a name |
| Add members | Each member names a document and an action, publish or unpublish |
| Schedule | The release is given a time. Blocking problems are checked first |
| Apply | On a background-jobs pass after that time, every member's action is applied |
| Publish now | A draft or scheduled release is run at once instead of waiting for its time |
| Cancel | An unapplied release can be cancelled |
A document is checked when it is added, the same way over REST and from the Direct API. It
must exist, and the person adding it must be allowed to read it. Its collection or Single
must have drafts, since a release moves documents between draft and published. A Single is
recorded by its own stored row, whatever id the caller passed. A trusted Direct API call
(overrideAccess, the default) skips only the read permission. The document still has to
exist and have drafts.
Single-document releases
A release has a kind, set when it is made and never changed:
release, the default, holds any number of documents;singleis one document scheduled on its own, and holds exactly that one.
const release = await nextly.releases.create({
title: "Launch post",
kind: "single",
});The database holds a single-document release to one document, so the rule holds even when two
requests add documents to it at once. Adding a second document is refused with a conflict that says to
put several documents in an ordinary release instead. A single-document release can't be
scheduled before its document is added. Once it's scheduled, its document can't be taken out:
cancel the release instead. Releases made before kinds existed read as
release; nextly migrate adds the column without rewriting them. The kind option is
experimental.
Publish now
Publish now is experimental: its name and answer may change before releases are finished.
Publish now runs a draft or scheduled release straight away. It makes the same checks as scheduling, so a release that would stop part-way is refused before any document is written. Then it moves the release to the current time, unless its time has already come, and starts the job runner's releases run at once, the same run a scheduled time gets: every document is written as the person who added it, and the release goes out together.
| REST | POST /api/releases/:id/publish |
| Direct API | nextly.releases.publishNow({ id }) |
| Permission | The same as scheduling: publishing releases |
The answer is the release as it then stands. Usually that is published, or blocked if a
document stopped it part-way. It is still scheduled when a very large release outlasts the
request, when a releases run was already in progress, or when the run could not be started:
the release is then due, and goes out with that run or the job runner's next pass.
Like any releases run, it sends out every release whose time has come, not only this one, so a release that was already overdue goes out with it.
Releases that ran are final
A published release is the record of what went live, and a cancelled one the record of a launch called off. Neither changes again: no documents are added or removed, it is not scheduled again, and Publish now refuses it. To try again, create a new release.
A stopped release has not run. Remove or fix the documents that stopped it, then schedule it again. Publish now refuses it until then, so the fix is checked before anything goes out.
Listing releases
Tabs, search and paging are experimental: their names and shapes may change before the releases list is finished.
The list is paged on the server, so its total and every page cover every release on the site, however many there are. It is read in one of four tabs, and each release is in exactly one of them:
| Tab | Holds | Order |
|---|---|---|
upcoming | Scheduled releases that are not overdue | Soonest first |
attention | Stopped (blocked) releases, then overdue ones | Longest waiting first |
drafts | Releases not yet scheduled | Newest first |
done | Published and cancelled releases | Most recently finished first |
A release is overdue when it is still scheduled more than 10 minutes after its time. A release a few minutes past its time is still upcoming: the job runner applies it on its next pass. One still waiting after 10 minutes is one the runner is not reaching, usually because no scheduler calls it (see below).
Search matches text in the title or the description, ignoring case. It reads every release
in the tab it searches, since no index can serve a match in the middle of a text; that is
fast for the hundreds of releases a site keeps, and grows with the size of the history. The
done tab is ordered by when each release finished, which no index serves either, so it too
sorts every finished release it matches before taking a page. What you type is
matched as written, so 50% finds "50% off" and not "50 years". On SQLite, case is ignored for the letters A to Z only. On MySQL, search follows the
collation of the releases table: the default utf8mb4 collations ignore case, a
case-sensitive one does not.
Ties are broken by id, so a release appears on exactly one page even when several share a
time. Releases scheduled with no tab and no order come soonest first; other listings
without a tab come latest first.
Over REST
GET /api/releases takes these query parameters:
| Parameter | Meaning |
|---|---|
tab | upcoming, attention, drafts or done. Not combined with state |
reason | With tab=attention: stopped or overdue, to read one part of that tab |
state | One lifecycle state, when you need it rather than a tab |
kind | release to leave out single-document releases, or single for only them |
search | Text in the title or description, up to 200 characters |
order | soonest or latest. Only with no tab or with upcoming |
page | The page, from 1 |
limit | Rows per page, 50 by default and at most 200 |
scheduledAfter, scheduledBefore | A window on the scheduled time, as ISO 8601 instants |
The answer is the standard list envelope. meta.total is the number of releases matching,
across all pages. The page and the total are read one after the other, as on every paged list. A
release that changes tab between the two reads (a scheduled release the drain publishes, say) can
leave the page and the total one apart for that one response. The next request reads both again.
{
"items": [
{
"id": "…",
"title": "Spring launch",
"state": "scheduled",
"can": {
"schedule": true,
"cancel": true,
"addMember": true,
"removeMember": true
}
}
],
"meta": {
"total": 42,
"page": 1,
"limit": 20,
"totalPages": 3,
"hasNext": true,
"hasPrev": false
}
}To count a tab without reading its releases, ask for one row and read meta.total:
GET /api/releases?tab=attention&limit=1.
From the Direct API
nextly.releases.find() takes the same filters and answers with the same { items, meta }
envelope as every other Direct API list:
import { getNextly } from "nextly";
import config from "../../nextly.config";
const nextly = await getNextly({ config });
const { items, meta } = await nextly.releases.find({
tab: "upcoming",
search: "spring",
page: 1,
limit: 20,
});
// meta.total counts every matching release, across all pages
const needsAttention = await nextly.releases.count({ tab: "attention" });
const overdue = await nextly.releases.count({
tab: "attention",
reason: "overdue",
});find() is paged like every list: without a limit it returns the first 50 releases. To
read every release, ask for each page in turn, up to meta.totalPages (at most 200 per
page).
count() takes the same tab, state, kind, window and search as find(), and gives the same
number as its meta.total without reading the releases.
Filtering by containing (one document's releases) still works, and still comes soonest
first, in the order the releases will apply to that document. tab, search and order
are refused alongside it.
A release's documents (GET /api/releases/:id/members, nextly.releases.listMembers())
come back in the order they were added. Asking for the documents or the blocking reasons of a release that does not exist
answers 404, not an empty list.
Scheduling stores a time; a job runner applies it
Scheduling records when a release is due. The work is done by a releases:drain sweep that
runs as part of the background jobs pass, which happens only when something
calls the jobs route.
Running the queue is where that is set up: the path, the shared secret the caller needs, and the two ways a scheduler ends up silently doing nothing. Wire it there once and every scheduled release depends on it.
With no scheduler configured, a release is never applied. Its due time passes and the sweep that would carry it out never runs. A scheduler configured but refused, by the wrong path or a missing secret, leaves you in the same place.
What that does not mean is that the content change is invisible. A read consults the same due-release rule the sweep does, so a scheduled draft can be revealed, and a takedown hidden, before anything has run. On a multi-instance deployment an instance other than the one that scheduled it can be up to 30 seconds behind, so treat this as "within seconds of the instant" rather than exactly at it.
As apply-due-releases.ts puts it, the read path can only make a due release LOOK applied.
The sweep is what applies it for real, writing as the member's own author, and it is the
only thing that advances the release's own state.
What a reader sees and what the stored rows hold are therefore two different
questions while a release is waiting for the sweep, and the second one has
more cases than the first: overlapping releases, a member whose author is
gone, and a pass that applied some members before failing on another. If you
integrate against the database directly rather than through Nextly, read
apply-due-releases.ts before relying on release state.
A pass is bounded, which is why a due release is not always applied by the next tick: it claims at most 10 queued jobs and stops starting new ones after 20 seconds. The bounds and what they mean for a serverless timeout are on the jobs page.
The sweep can also defer work past its deadline while still reporting success. So a release is applied on the first pass that actually reaches and completes it, which is not always the first pass after its due time. With a backlog, or several expensive releases due together, expect it a pass or two later. Treat the scheduled time as "not before", not as "at".
Blocking reasons
Before a release can be scheduled, it is checked for problems that would make it fail half-way — a member pointing at a document that no longer exists, for example. Those are reported as blocking reasons so they can be fixed while there is still time, rather than surfacing as a partial publish at 9am.
An empty release has no blockers; there is nothing to go wrong.
One release covers every language
A member is the whole document. Adding a post to a release schedules that post in every language it has.
Per-locale scheduling is not supported: adding a member that names a single locale is refused, and the admin only offers the action from a document's default locale. Each language does have its own published state — see Localization — but that is publishing, not scheduling.
Related
- Localization — per-language publishing
- Draft and published status
- Versions — what a publish changed