Skip to content

Technology

Which Gate in Your Draft-to-Publish Workflow Can Refuse to Ship a Page?

A publishing state is a structure, not a setting, and an approve button that is not wired to the decision that ships the page blocks nothing. Four structures, assessed against what each one can actually prevent and what it leaves behind.

Zealsync Insights25 min read
Where a draft lives, and what can stop it

A publishing state is a structure, not a setting

An entry that is not published is somewhere. It sits in a row, or in a file, or on a ref, and that location decides which operations are available to it, who can see it, and what has to happen before a reader can. The word draft describes an intention. The structure underneath describes a set of permissions, a set of reachable URLs, and a set of ways the entry can leave the state it is in. Only the second of those is enforceable, and only the second one survives a hurried afternoon.

This is why a draft review publish workflow cannot be settled by agreeing on a status vocabulary. Draft, in review, approved, scheduled, published: a five-value enum is easy to agree on in a meeting and tells you almost nothing about what happens when somebody with write access decides to skip a step. What matters is whether the transition from the second-to-last state to the last one runs through something capable of declining.

An approve button that is not wired to the operation that ships the page is a record of an opinion, not a control.

The two questions are usually handled separately. Where does an unpublished entry live is treated as a data-modelling question, and who can stop it is treated as a permissions question, and they get answered by different people in different weeks. They cannot be separated, because the structure constrains the gate. A status field can only be guarded by whatever guards writes to that field. A branch can only be guarded by whatever guards the update to the branch it merges into. Choose the structure and you have already narrowed the set of gates available to you, whether or not anybody noticed at the time.

Four structures for an unpublished entry

Four structures come up repeatedly. They are not variations on a theme; they sit in increasing order of separation between the unpublished entry and the thing that gets built. A field on the record. A separate location in the same tree. A separate ref. A separate ref with a proposal attached to it. Each buys a different kind of safety and pays for it somewhere specific, and the payment is the part that tends to go unexamined until it is due.

A status field inside the entry

The entry exists in one place and carries a value saying whether it is live. Publishing means changing the value. It is the cheapest structure to build and the easiest to explain to somebody who is not technical, and it has one genuine virtue the others lack: the entry is the same object throughout its life. There is no reconciliation step, no chance of two divergent copies, and no moment where the version a reviewer read and the version that ships are different files that have to be matched up by hand.

The cost is that the field is the only thing standing between a draft and a live page, so every write path to that field is a publish path. Absent additional machinery, anyone who can edit the entry can publish it, and the additional machinery has to guard a field rather than an event, which is a harder thing to reason about and a harder thing to test. There is also a subtler problem. Because the record has one body, a review is a review of a moving target. Nothing marks the version that was approved unless something separate is built to mark it.

In a static build the field has a pleasant property. If the generator filters unpublished entries, they never become files, so there is no page to leak. But the same property means a reviewer cannot see the entry rendered without a build that includes it, which in practice means a second build with different filtering, which means a second web surface that now has to be secured. The saving on the deployed side reappears as an obligation on the preview side. It has not been removed, only relocated.

A drafts directory

Unpublished entries live at a different path, and publishing is a move. The state is legible from the tree without reading any code, the move shows up in a diff as a rename, and a directory listing answers the question of what is outstanding. For a small team working in one repository this is often the most honest representation of what is actually happening, because the mental model and the file system agree with each other.

Two costs. The first is that a path is frequently part of an entry identity, and sometimes part of its URL. If the identity is the path, the move rewrites it, and anything that referenced the draft by path breaks at the moment of publication. The structure only works cleanly when identity is carried inside the entry and the directory is purely positional, which is a discipline that has to be established early because retrofitting it means rewriting every reference at once.

The second is reach. A drafts directory committed to the deployed branch is deployed. The files are in the tree the build reads. Whether they become pages depends on the generator ignoring that directory, and whether they stay out of everything else depends on every other consumer ignoring it too. The sitemap builder, the feed generator, a client-side search index and any script that walks the content directory are separate code paths, often written at different times by different people, and each one has to be taught the same rule. A consumer that was never taught it is a leak with no error message attached to it.

A branch per entry

In a branch per blog post workflow the unpublished entry lives on its own ref. A branch in git is a movable pointer to a commit, and creating one costs almost nothing. Nothing about creating a branch obliges anybody to merge it, which is exactly the point: the branch is a place to put work that has not been decided on yet.

The structural property that matters is that the unpublished entry is not in the history of the deployed branch at all. This is not a filtering question or a directory-ignore question. The file does not exist in the tree that gets built, so no generator, feed, sitemap or index can accidentally include it. Reach is not something you configure and then audit; it is something the structure removes, and a property you cannot forget to maintain is worth considerably more than one you have to remember.

The costs are divergence and accounting. While a branch sits, trunk moves, and a branch left for three weeks acquires conflicts that have nothing to do with the entry on it. Branches also accumulate quietly. A branch nobody merges leaves no trace on trunk, so an abandoned draft is invisible unless somebody lists refs deliberately. That is tidy and it is also a blind spot: you cannot tell from the published site, or from trunk history, what was proposed and dropped. If knowing what did not ship matters to you, a bare branch is the structure that answers it worst.

There is a coupling constraint too. The branch is the unit of shipping, so two entries that must go live together either share a branch or become two merges performed in a particular order by somebody who remembers the order. When the unit of shipping does not match the unit of decision, the mismatch is paid for in coordination on every release rather than once at design time.

A pull request per entry

A pull request per blog post is the branch plus a proposal object: somewhere to attach discussion, automated checks and a record of approval. It adds three things a bare branch does not have. A stable place to talk about a specific version. A hook that automated checks can hang from. And a record of the decision that outlives the merge, which is the part teams underestimate until the first time somebody asks why an entry reads the way it does.

It inherits the branch reach property in full, and it inherits the divergence cost in full. It adds one of its own: the review artefact is a diff of source. That is adequate for a factual correction and poor for anything about how the page reads. Whether a table wraps at 390px, whether the heading order is logical, whether a pull quote lands where it was meant to, none of that is visible in a diff of markup. Reviewing rendered output requires something that renders it, and that returns you directly to the preview surface and its reachability question.

The important caveat is the one the rest of this piece turns on. The approval recorded against a proposal is only a gate if the operation that merges the proposal consults it. If the merge succeeds regardless, the approval is a comment with a green icon next to it. Nothing about the presence of a review interface tells you which of those two you have, and the interface looks identical either way.

Advisory, procedural, enforced: only one kind of gate can refuse

Sort gates by what happens when somebody ignores them. The taxonomy is short and it is unusually decisive.

  • Advisory. Nothing prevents the action. A checklist in a template, a line in a description, a shared understanding that approvals come first. Ignoring it produces no error and no signal. The whole of the value sits in the culture around it, which is real but is not a control.
  • Procedural. The action is possible, but the path of least resistance runs through the gate. The publish control lives on a different screen, or the deploy runs from a script that asks a question first. Someone hurried or determined routes around it and nothing objects.
  • Enforced. The operation that makes the page live fails when the condition is unmet. The push is rejected, the merge returns an error, the build exits non-zero and no artefact is produced at all.

Only the third kind can refuse. The first two describe what usually happens, which is a different claim and worth less than it sounds when repeated in a planning document. The test is mechanical and it takes about a minute: name the exact operation that makes the page live, then ask what occurs if somebody performs that operation without the approval. If the answer is that it works and would be noticed afterwards, the gate is advisory regardless of what the interface implies.

The mechanism that converts an approval into an enforced gate belongs to a general class: a rule attached to a ref that rejects updates failing stated conditions, such as a required number of approving reviews, required status checks, or a restriction on which accounts may update the ref at all. Branch protection for content approval works because a merge is a ref update and the rule rejects the ref update. That is the entire mechanism, and it is worth understanding as a class rather than as any one platform feature, because the questions you have to ask are the same everywhere. Is the condition evaluated by the thing performing the update, or by something adjacent to it. And can the update be reached by a path the rule does not cover.

Enforcement has real costs and it is dishonest to present it as free. It fails closed, which means it also fails on the day the service evaluating a required check is unavailable, and the correct response then is to wait or to use a documented exception, not to quietly remove the rule and forget to restore it. On a team of two who read each other work anyway, enforcement is mostly ceremony. Here is what would have to be true for the argument in this piece to be wrong: if everybody holding write access is also somebody you would let publish unilaterally, advisory gates are not a weakness, they are correctly sized. Enforcement starts earning its cost the moment that stops being true, and it stops being true the first time a contractor, a client user or an automation holds a credential with write access.

Where the gate sits: before commit, before merge, before deploy, before indexing

There are four positions available in a typical pipeline. Each catches a different class of problem and lets the other classes straight through, and most of the disappointment teams have with their gates comes from putting one in the wrong position and then expecting it to do a job it is not placed to do.

Before commit is local. Hooks, editor checks, validation of frontmatter, a link checker. This is the cheapest possible feedback and the right home for anything mechanical, because it costs the author seconds and costs nobody else anything at all. It is also entirely bypassable by design: client-side hooks are local configuration, and a commit can be produced without them. Treat this position as a convenience for the author and never as a guarantee to anybody else.

Before merge is where an entry enters the deployed line of history. It is the only position with a human decision attached by construction, which makes it the only position that can refuse on the basis of a judgement about the content rather than a property of it. It is also where a required check can genuinely be a condition on the ref update rather than a notification about one. If a team is going to invest in exactly one enforced gate, this is the one, and the reasoning is not preference but position.

Before deploy catches what only exists after rendering: a build that fails, generated markup that violates a contrast or heading-order rule, a missing image, a malformed feed. What it cannot sensibly catch is the judgement that an entry should not go out yet, because by the time the pipeline runs the decision to ship has already been taken. Blocking there leaves a red pipeline on trunk, which is a considerably worse place to surface a content objection than a review that had not yet been merged, and it puts an unrelated queue of changes behind an editorial argument.

Before indexing is not a publishing gate at all, and it is worth saying so plainly because it is regularly used as one. Google Search Central documents the distinction: a URL disallowed in robots.txt may still appear in search results if it is linked from elsewhere, and because the crawler is not permitted to fetch it, it also cannot see a noindex directive placed on the page. Removal is done with a noindex directive in a meta robots tag or an X-Robots-Tag header, on a URL the crawler is allowed to fetch. All of which describes a page that is being served to anybody who asks for it. If the page is reachable it can be linked, and a link is enough for a URL to become known. An indexing control adjusts what search engines do with a published page. It does not make the page unpublished.

Put cheap gates early for mechanical failures, and put the one enforced gate at the merge, because that is the only position where a judgement can still be acted on.

What a reviewer sees, and what a crawler can reach, under each option

These are two questions with the same shape, because both are answered by naming what is rendered and where it lives. A reviewer needs rendered output to review anything about presentation. Rendered output lives on a host. A host is reachable by whatever can reach it. The chain is short, it is the same chain in every structure, and the structures differ only in where along it the exposure sits.

Under a status field, preview requires either a build with the draft filter relaxed or a runtime that reads unpublished records directly. Under a drafts directory it is the same, with the additional wrinkle that the files are already sitting in the deployed tree and the deployed build must be trusted to skip them, along with every secondary consumer of that tree. Under a branch or a proposal, the preview is a build of the branch, and the unpublished content is not on the deployed host at any point; it is on whichever host builds previews. The exposure does not disappear, it moves, and moving it is worth something, because a preview host can be secured with one decision instead of being defended once per consumer.

Whatever serves the preview is a web surface. If it is unauthenticated and its hostnames are predictable, or appear in a public certificate log, it is reachable by anybody who cares to look. Every unpublished entry then has a live URL for the duration of its review. The question is not whether that host currently appears in a search index. The question is whether the content is out, and if it is fetchable by an anonymous request, it is out. That distinction is the one that separates a control from a reassurance.

The available mitigations are not equivalent, and it helps to sort them with the taxonomy above rather than by how reassuring they sound in a summary.

  • Authentication on the preview surface. Enforced. An anonymous fetch fails. This is the only item on the list that is a control rather than a preference.
  • A noindex directive on every preview response. Depends on crawler compliance, and does nothing at all about a person or a script that already has the URL. Correct practice, not containment.
  • A robots.txt disallow on the preview host. Prevents crawling but not necessarily the indexing of a URL linked from elsewhere, and it guarantees the crawler never sees a noindex on those pages. Weaker than it appears, and weaker in a way that is easy to misread as strength.
  • Unguessable hostnames. Obscurity. A useful margin on top of a control and worth nothing underneath one.

On the deployed side, a static site draft posts workflow deserves one specific audit that is almost always overdue: check that the sitemap generator, the feed and any search index apply the same draft filter as the page generator, and check it by reading the code rather than by asking whoever wrote it. Divergence here produces a distinctive failure. A feed that lists an entry the page generator skipped hands readers a 404 and hands crawlers a URL, and the entry title sits in both places even though the page itself never existed.

Scheduled and embargoed entries as a fifth state

Scheduled is not published and it is not draft. It is approved and waiting, and it breaks each of the four structures in a different place, which is why it is usually bolted on afterwards and usually ends up the weakest part of the arrangement.

A status field absorbs scheduling naturally when something evaluates a datetime at request time, because a server rendering per request can simply compare two values and decide. In a static build there is no request time. The page exists or it does not, as of the last build, so scheduling means a build that runs at the appointed moment, and the guarantee is precisely as strong as the thing that triggers that build and no stronger. Under a branch or a proposal, scheduling means either a merge that happens later, which needs somebody or something to perform it at the time, or a merge now with the entry withheld by some other mechanism, which reintroduces the status field alongside the branch and leaves you maintaining both structures at once.

An embargo, meaning an entry that must not be visible to anyone outside before a given moment, makes the reachability question from the previous section decisive rather than merely untidy. An embargoed entry rendered on an unauthenticated preview host is visible to anybody who can reach that host, and nothing about the embargo date is enforced by the host. There is no arrangement in which content is simultaneously rendered on a public host and embargoed. Either the preview surface is authenticated or the embargo is an intention with a date attached to it.

Scheduling also collides with approval in a way that is easy to miss. If approval was given on a Tuesday and publication is set for Friday, the approval attaches to a version, not to an entry. If the entry changes on Thursday, the approval is stale, and whether anything notices depends entirely on the structure. A status field does not notice; the field still reads approved. A proposal-based structure can be configured to dismiss existing approvals when new commits arrive, which reopens the gate automatically, and that configuration is the difference between a gate and a souvenir. What a schedule does and does not promise is worth taking apart on its own, and it is, in what scheduling actually guarantees.

Bypass paths, and the gate that has no second approver

A gate is only as good as the set of paths it covers, and that set has to be enumerated rather than assumed. The enumeration is not long, and it is uncomfortable to write down, which is a reliable sign that it is worth writing down.

  • A direct write to the deployed ref by an account exempt from the rule. Exemptions are usually granted for a defensible reason on a particular day and then never reviewed again.
  • An automation holding a token with write access. A rule requiring review from a person may or may not treat a machine credential as a person, and the answer is a configuration detail rather than a principle you can rely on.
  • A manual deploy. A pipeline that can be triggered by hand against an arbitrary ref turns a protected trunk into a suggestion, because the protected ref was never the thing that produced the artefact in the first place.
  • A force update to a protected ref, where it is permitted. Moving a branch pointer to a commit that is not a descendant of the current one discards commits from that branch. It can remove a withdrawn entry from history, and it removes the evidence of the removal at the same time.
  • The out-of-band edit. Content changed directly on the host, in a database or through a control panel, without passing through the source of truth at all. No structure in the repository can see this, and no gate in the repository can refuse it.

Then there is the gate with no second approver. A rule requiring one approval, where the author is permitted to approve their own work, requires nothing at all; it converts an intention into two additional clicks. A rule requiring an approval from somebody other than the author requires that a second person exists, holds enough context to have an opinion, and is available on the day. On a single-author team, or a team where one person owns a subject entirely, that person does not exist, and no amount of configuration produces them. This is a staffing constraint wearing a tooling costume, and it is usually discussed as though it were the other way around.

The honest options are a real second approver, or an explicit acknowledgement that the gate is procedural. Choosing the second is perfectly defensible; presenting it as the first is not. There is also a genuine counter-argument worth stating against my own position. If the risk being managed is mistakes rather than intent, a self-approved gate still helps. Opening a proposal and reading your own diff with the specific intention of finding fault catches a surprising amount, because the change of posture from writing to reviewing is doing most of the work and the second person was never the active ingredient. What it cannot do is stop a decision, because the person taking the decision is the person reviewing it. Decide which of the two you are buying, and do not let the interface decide on your behalf.

What the record shows afterwards

Once a page is live, the record is what remains, and it is the only thing that can answer who changed what and when a decision was taken. Different structures leave very different amounts behind. A status field, in the absence of a separate audit log, leaves the current value and nothing else; the previous value and the identity of whoever set it exist only if something was deliberately built to record them. Git-backed structures leave a commit history by construction, which is a considerable advantage, and which says a good deal less than most people assume it says.

Author and committer are not the same claim

A git commit object carries two separate identity fields, each with a name, an email address and a timestamp: author and committer. They are distinct by design. The author records who wrote the change; the committer records who created this particular commit object. In the ordinary case of one person making a change and committing it, both fields hold the same values, which is why the distinction is easy to work alongside for years without noticing.

They diverge whenever a commit object is created from a change that already existed. Rebasing writes new commit objects and sets the committer to whoever ran the rebase while preserving the original author. Cherry-picking behaves the same way. Amending updates the committer. Applying a patch that somebody sent you records them as author and you as committer, which is the case the two fields were designed for in the first place.

The consequence is structural rather than incidental. Any process that produces commits on somebody else behalf records itself in one field and the person in the other, and which field holds which depends on how that process was built. It is not inferable from the outside by looking at the result. A reader inspecting history to answer the question of who made this change therefore has to know which field they are reading, and the default output of a log command shows the author rather than the committer. Two commits that appear identical in a default log can carry entirely different committers, and only an explicit request for the committer field will surface it. Any conclusion drawn about attribution without checking both fields is a guess dressed up as a finding.

What a signature actually asserts

A commit can be cryptographically signed. The signature is made over the commit object, which includes the tree it points at, its parents, and both identity fields. Verification therefore establishes two things: that the object was signed by the holder of a particular private key, and that the object has not been altered since it was signed. Both are useful and both are narrower than the way signing is usually described.

It does not assert that the content is correct. It does not assert that any review took place. It does not assert that the signer wrote the change, read it, or agreed with it. A signed commit whose author field names somebody else is an entirely ordinary object, and the signature covers the fact that the field contains that value, not the truth of the value. Signature verification answers a tampering question and a key-holder question, and it answers both well. It does not answer an approval question, and a record that needs to show approval has to record approval as its own assertion, made at the gate, by whatever permitted or refused the update.

What a squash merge collapses

A merge commit has two parents, and both lines of history remain reachable through it. A squash merge produces a single commit with one parent, containing the combined change, and the individual commits from the branch do not appear in the resulting history. The branch itself may still exist afterwards; if it is deleted and nothing else references those commits, they become unreachable and are eventually collected.

The trade is genuine in both directions and neither side of it is obviously right. Squashing produces a trunk where one commit corresponds to one entry, which is legible at a glance and trivially revertible, and for a content repository that is a real convenience rather than a cosmetic one. It costs the intermediate steps: the sequence in which the piece was edited, the commit where a claim was removed, and which of two collaborators wrote which section. Whether that loss matters depends entirely on what you might need to reconstruct later. If the question you expect to ask is which change altered the site, squashing makes the answer easier to find. If the question is when this sentence first appeared and who put it there, squashing has taken the answer out of trunk, and it survives only wherever the proposal record survives.

Which places a great deal of weight on the proposal record. After a squash it is frequently the only remaining connection between a merged commit and the discussion that produced it, including the approval that let it through the gate. If that record lives in a system separate from the repository, then the repository on its own no longer answers the question, and the completeness of your history has quietly become a property of two systems staying together rather than one system holding everything. That is a reasonable arrangement to choose deliberately. It is an unreasonable one to end up in without noticing.

None of this settles which structure you should adopt, and it is not intended to. It settles the order of the decision: name the operation that makes a page live, decide what must be able to refuse it, and only then choose the structure and the tooling capable of carrying that gate. Tools are picked after that question rather than in place of it, and a git-backed content publishing product such as Acrosite, built by Zealsync, is one of the places an answer can land rather than a substitute for having worked one out.

Pass it on

Share this signal

Send it to someone working through the same question.