Publishing to Ghost from GitHub, Part 1: the pipeline

An engineering walkthrough of a containerized GitHub-to-Ghost publisher: revision-bound candidates, deliberate publication, and recovery when a request goes sideways.

Since reviving my site, neumachen.dev, I've wanted to start publishing more of what I write, from technical docs to the sort of caught-in-the-moment B+ prose I'd submit to my high school lit teacher, Mrs. Pearsons.

I initially put up a landing page through Cloudflare's GitHub integration while I looked into how I wanted to publish. There are plenty of hosted options: WordPress.com for a blog or website, and Substack for a publication with an email newsletter. I settled on Ghost CMS.

I briefly considered self-hosting it, then remembered how tedious my home-server rebuild has been. I opted for Ghost(Pro), its paid, managed service, which suits my needs. Ghost has plenty of features, but I'm not going to tour all of them in this article.

My typical writing workflow would look something like this:

From inspiration to publication*

OnwardProcrastinationAnother passPublication
From inspiration to publication, with a few detoursInspiration strikes, then Coffee first? Yes leads to coffee, immediately sitting down with the laptop, writing and committing a draft, editing and committing, opening a pull request and self-review. No coffee leads to a procrastination self-loop, then back to inspiration. Review can loop through revision. After merging, CI can loop through a fix. Passing CI prepares a candidate; a person chooses the revision and starts publication manually. Ghost receives a draft or published post.1. First, get to the keyboard2. Write, edit, repeat3. A little help from CIInspirationstrikesCoffee first?Get coffeeProcrastinateJust one more tab.Sit downwith my laptopWrite the articleCommit the draftEdit the articleCommit the editsOpen PRSelf-review. Yep.Happy?Revise + commitGood catch, me.Mergeto mainCI passes?Fix, review, merge.Deep sigh.Candidate readyNot published.Choose revisionPublish manuallyGhost postDraft orpublished Yes NoOne more tabAh, an idea Not yetAgainNoTry again YesYes

Scroll sideways to follow the flow.

*Estimated completion time: depends how many tabs are open. CI prepares the candidate; publication still starts manually.

This article picks up at the repository: how we built the container, wired it into GitHub Actions, and connected a reviewed article to Ghost. The workflow above supplies the coffee and questionable time management. The pipeline takes over once there is something to commit.

This is an engineering walkthrough for someone familiar with Git and CI. The useful thing to take away is the design: what belongs in the publisher, what belongs in a workflow, how to connect a reviewed commit to a post, and how to tell whether a failed request actually failed to publish anything. The command names and file paths below belong to our implementation; the excerpts are not a complete installation kit.

Part 1 uses GitHub's runners. The companion source and setup notes are a fixed snapshot of the implementation described here. Part 1.5 walks through the setup; Part 2 will move execution onto runners I manage, with room to explore a published container image and reusable workflow. That is why I wanted the publisher in a container from the beginning: the application should carry its runtime and dependencies with it.

There are two workflows. Article CI checks the code and articles, then prepares a candidate from a specific commit. Article publish uses that candidate when I manually start a plan or publication. A merge prepares the next possible publication; it does not put prose on the website by itself.

Put the publisher in a container

The application lives under article/. It is a small Node CLI responsible for reading committed Markdown, rendering it, handling assets, talking to Ghost and verifying what happened. The workflow YAML calls that application rather than implementing those decisions in shell.

The Dockerfile has a shared runtime and two final targets. This excerpt shows the stage structure. The complete file also installs Git, unzip and CA certificates, applies available OS updates, declares the target labels checked by the launcher, and prepares the ownership-test fixtures. The full Dockerfile is in the companion source:

FROM node:22.23.2-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 AS runtime

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
    && npm cache clean --force \
    && rm -rf /usr/local/lib/node_modules/npm /opt/yarn-* \
    && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/yarn /usr/local/bin/yarnpkg

COPY src/ src/
ENV NODE_ENV=production HOME=/tmp
USER 1000:1000

FROM runtime AS test
COPY test/ test/

FROM runtime AS production

The base image is pinned by digest, and npm ci installs from the committed lockfile. Copying the package files before the application source lets Docker reuse the dependency layer when only the source changes. The test target adds the test suite; production, the default final target, carries the application and its production dependencies. Both inherit the same runtime. Package managers have finished their job at build time, so they do not travel with it.

The image declares a non-root user with USER 1000:1000. The launcher maps the invoking user's numeric UID and GID so generated files remain writable by the runner. It also makes the root filesystem and source checkout read-only, drops Linux capabilities and prevents privilege escalation. Temporary files and declared output mounts remain writable. Docker's USER documentation and multi-stage build documentation describe the two mechanisms; neither replaces scanning and rebuilding an image when its dependencies need security updates.

A host launcher, tools/article/run.sh, supplies the parts the container cannot bring with it:

Inside the container What the launcher supplies
/app The application and dependencies; the test target also contains tests
/repo The selected repository checkout, read-only
/out A writable mount backed by dist/article on the host
Environment variables Runtime configuration and the credentials needed by that operation

The launcher also handles Git worktrees, whose metadata can live outside the checkout directory. Mounting the article files alone would leave Git unable to resolve the revision we were asking it to publish.

The repository's mise.toml gives these operations consistent names: article:image, article:check, article:test, article:prepare and so on. These are tasks we defined, not built-in mise or Ghost commands. The reference's mise tasks and launcher show the complete commands. For example, mise run article:test goes through the launcher and runs Node's test runner inside the test image. The CI configuration also runs the integration suite against the actual production image, mounting tests read-only for that run. The application source and dependencies remain the ones baked into production.

That check runs against a disposable Ghost with its content in a Docker volume. The test task removes the volume before and after each run. This also avoids leaving Ghost-owned files for a differently numbered Linux runner user to clean up. Same container contract; still a few very real details at the boundary.

This is the contract I want to retain in Part 2. It does not make the runners identical. Their CPU architecture, checkout ownership, storage, network and credentials still matter. We record the actual image ID, platform and Node version so we can say which environment ran a candidate.

The boundary to carry into another project is small: the application owns publication decisions; the launcher supplies its filesystem and runtime configuration; the workflow decides when to invoke it. Moving runners should not require rewriting the code that decides whether a post needs updating.

Give CI an explicit article to work on

Git holds the Markdown, front matter and images. An article becomes eligible for preparation when it appears in editorial/articles/registry.json. The entry for this series looks like this:

{
  "schema": "neumachen-article-registry/1",
  "articles": [
    {
      "id": "github-to-ghost-part-1",
      "path": "editorial/articles/github-to-ghost-part-1"
    }
  ]
}

That explicit enrollment matters because the repository contains other writing. Committing a note should not accidentally nominate it for publication.

Each enrolled directory contains article.md. Its front matter supplies the stable article ID, title, slug, tags and publication status. The ID must match the registry. A minimal example, using a different article ID, looks like this:

---
id: hello-ghost
title: "Hello from GitHub"
slug: hello-from-github
status: draft
tags:
  - Engineering
---

## A paragraph with somewhere to go

This article started as a file in Git. Coffee did not commit it for me.

That example would need its own registry entry with id: hello-ghost and the path to its directory. The stable ID is our pipeline's identity; the slug is the URL-facing name and can change. For a first candidate, keep status: draft explicit. Our publisher defaults an omitted status to published.

There are two separate choices here. The workflow's operation: plan means read Ghost without changing it; operation: publish means perform the write. The article's status: draft or status: published determines what Ghost should receive. Running the publish operation can therefore create a Ghost draft. It does not automatically mean a public article, and this pipeline does not send newsletters.

While writing, I can validate and preview the working files:

mise run article:check -- --working-tree

mise run article:preview -- \
  --article github-to-ghost-part-1 \
  --working-tree \
  --out /out/github-to-ghost-part-1-preview.html

The preview appears under dist/article on the host. At this stage, I check that validation reports the intended article ID and status=draft, then open the HTML to inspect the actual prose and formatting. A successful command is useful; a successful command that rendered the wrong article is rather less so. Working-tree mode is explicitly for authoring; the publisher refuses candidates made from it. Publication needs the files recorded in a commit, including the registry as it existed at that revision.

That becomes useful after a directory rename. Preparing an earlier revision must find its earlier directory, rather than applying today's registry to yesterday's files.

Build Article CI: check, test, prepare, retain

The automatic workflow lives in .github/workflows/article-ci.yml. Its trigger is deliberately broad:

on:
  push:
    branches: ["**"]
  pull_request:

permissions:
  contents: read

It runs on GitHub's ubuntu-24.04 runner. The first steps check out the commit without persisting Git credentials and install the tools pinned through mise. The core validation and preparation steps are below. The full job also checks the production runtime and Git ownership/history, runs the production image against disposable Ghost, and uploads the resulting candidates. Checkout, tool installation and those additional steps are omitted from this excerpt:

- name: Build the article pipeline image
  run: mise run article:image

- name: Validate the registry and enrolled articles
  run: mise run article:check

- name: Test the article pipeline
  run: mise run article:test

- name: Prepare the enrolled articles
  id: prepare
  run: mise run article:prepare -- --all --revision "$GITHUB_SHA"

Validation checks that the registry and articles make sense. The tests exercise the publisher. Preparation reads the enrolled articles at GITHUB_SHA and writes a reviewable bundle for each one.

A prepared article has this shape:

dist/article/github-to-ghost-part-1/
  candidate.json
  manifest.json
  body.html
  preview.html
  assets/

The candidate records its article ID and revision, the rendered body, metadata and asset digests. Its candidateHash binds the fields that affect publication, including the selected feature image. Before publishing, the application recomputes those hashes and refuses a mismatch.

There are no path filters on Article CI. Even a main-branch commit that only changes something elsewhere in the repository gets a candidate for its exact revision. Otherwise, choosing that revision later could fail simply because the article preparation job never ran.

The last CI step uploads dist/article as an artifact named article-candidates-<commit>. Candidates from pushes to main are retained for 90 days; other candidates are retained for seven. The upload is skipped when the preparation step reports zero enrolled candidates. If candidates were expected but their files are missing, the upload fails.

That small distinction took a correction. An empty registry is a valid state. A missing artifact after preparing an article is a broken build. Treating both as “nothing to upload” would hide the failure we needed to see.

Article CI gets no production Ghost credentials and makes no requests to the production site. Its integration tests create throwaway credentials for a disposable Ghost. It can therefore check a pull request without being able to publish its contents to my site. A branch or pull request artifact is useful for review, but our production workflow accepts only a candidate produced by a successful Article CI push run on main.

Make the rendered article worth trusting

Preparing HTML was also where a deceptively simple assumption broke: send the rendered Markdown to Ghost, fetch it back, and compare the HTML.

Ghost rewrote it.

Ghost documents its HTML-to-Lexical conversion as lossy and provides HTML cards for content that needs preservation. We use that boundary when wrapping raw HTML and structures that ordinary conversion can damage. Ghost's source-HTML documentation explains the API behavior; our disposable-Ghost tests checked our actual output.

Byte equality treated ordinary transformations as new changes. Checking only a few headings went too far in the other direction and could miss changed prose. The content checks therefore follow structure, text, links, images and formatting. The saved Ghost representation gets a separate fingerprint for later comparisons.

The representative test article became deliberately awkward: curly quotes, non-ASCII words, nested formatting, linked and inline images, a table, raw HTML and code whose indentation matters.

It includes an HTML example showing an image tag. At one point, the asset scanner treated the image path inside that code example as a file to upload.

Excellent. The documentation was trying to execute itself.

That defect belonged in the renderer's tests before we trusted the candidate artifact. A successful upload to GitHub would only prove that we had retained the wrong output efficiently.

Build the manual publication workflow

The second file, .github/workflows/article-publish.yml, uses workflow_dispatch. GitHub exposes that trigger through its Actions interface, CLI and API; the workflow must exist on the default branch to receive it. GitHub's manual-workflow guide covers the controls.

Our workflow accepts five inputs:

Input Purpose
article The enrolled article ID
revision A full commit SHA on main; blank selects the run's main revision
operation plan or publish, defaulting to plan
confirm Must be yes for a publish operation
repair_state A guarded state-repair option, normally false

The workflow runs from main and has two jobs. Separating them makes it clear which work can happen before any Ghost credential is available.

Select and verify the candidate

The select job checks the article ID, resolves the revision and confirms that the commit belongs to main's history. It then finds the corresponding candidate through the GitHub API.

Finding a familiar artifact name is insufficient. The selector verifies the repository, producing workflow, event, branch, successful run, exact commit, expiry and artifact digest. It also inspects the candidate's own hashes and article/revision binding.

The result passed to the next job includes the artifact's ID and digest. The next job fetches that specific artifact and checks it again; it does not search for whichever artifact happens to have the same name by then.

Both publication jobs use fetch-depth: 0. They need enough Git history to recognize the requested revision and determine whether an update would move backwards from the last published revision.

This is also where the runner intruded on our tidy container story. A checkout owned by the runner can appear untrusted to Git running as another user inside Docker. The launcher now maps the runner's user IDs, and scoped checkout trust still handles foreign-owned checkouts and worktrees. We test that case without trusting every directory on the machine.

Then we found a less philosophical mistake: the workflow had a GitHub token, but the launcher was not forwarding it into the container.

The container was consistently missing the same thing.

Give only the Ghost step its credential

The publish job depends on select and runs in the ghost-production environment. At activation, that environment was restricted to main. Its URL is an environment variable; the Admin API key is an environment secret. The Ghost step receives them through this mapping:

env:
  GHOST_ADMIN_API_URL: ${{ vars.GHOST_ADMIN_API_URL }}
  GHOST_ADMIN_API_KEY: ${{ secrets.GHOST_ADMIN_API_KEY }}

The credentials come from a custom integration in Ghost Admin. The publisher needs the integration's Admin API key and API URL. In GitHub, we store the URL as GHOST_ADMIN_API_URL under the environment's variables and the key as GHOST_ADMIN_API_KEY under its secrets. The environment name must match the workflow. GitHub's environment guide covers creating it and restricting its deployment branches; feature availability depends on the repository visibility and GitHub plan.

The key is supplied at runtime, never copied into the image. The earlier candidate-selection job has GitHub read permissions but no Ghost credential.

That gives us a useful separation: checking prose and producing an artifact doesn't require permission to change the publication. The secret enters only when a selected candidate is ready to be compared with, or written to, Ghost.

After fetching and inspecting the verified candidate, the job invokes the publisher. With the optional repair argument omitted, the command is:

mise run article:publish -- \
  --candidate "$RUNNER_TEMP/candidate/$ARTICLE" \
  --article "$ARTICLE" \
  --revision "$COMMIT" \
  --mode "$OPERATION" \
  --record "$RUNNER_TEMP/article-record.json"

Those variables come from the validated workflow inputs and selected candidate. The launcher makes the runner's temporary paths available inside the container.

In plan mode, the application reads Ghost and reports the decision it would take. It does not upload an image, create a tag or change a post. Publish mode performs the mutation and verifies it, after the workflow checks the explicit confirmation.

The workflow also uses a dedicated ghost-production-article concurrency group with cancel-in-progress: false. A newly started run should not interrupt an active publication halfway through. That serializes this workflow's publication work; it does not lock a person out of editing in Ghost.

Finally, the job attempts to write a summary and retain the run record even if an earlier step fails. If the publisher reached a write, the operator needs more than a red cross next to the workflow name.

Make publication safe to repeat

The YAML gets the right candidate to the application. The application still has to decide whether to create a post, update it, leave it alone or refuse. This is where much of the implementation work went.

Keep identity stable when names change

A source directory, headline or slug can change without creating a different article. In the disposable Ghost tests, duplicate slugs could receive suffixes, so looking up a matching slug was not a safe adoption rule.

The publisher uses the stable article ID, carried by a hidden internal tag, to find its managed post. It refuses ambiguous matches. The tag also holds a compact publication-state record, which survives a different checkout or runner.

Our fake service initially accepted a tag description sent inline with a post. Real Ghost did not establish that state as expected. The corrected path creates or finds the identity tag, writes state through the tags API and reads it back.

A cooperative test double is lovely until it starts agreeing with your bugs.

Distinguish a lost reply from a failed write

If Ghost saves a post and the connection drops before the reply arrives, the program has an error while Ghost may already have the intended content.

The publisher reads back by managed identity and checks the intended body and owned fields before deciding whether the write succeeded. If it cannot establish the outcome, it reports uncertainty instead of guessing that another write would be harmless.

The run record distinguishes partial effects too: an image uploaded before another failed, a post saved before its state record failed, or a confirmed post write followed by a failed public-page check. That last case has its own exit code, 6. It means the post write was confirmed and verification still needs attention, rather than inviting a blind repeat.

Reuse images with evidence

Ghost can suffix uploaded filenames, and image storage can change the bytes it serves. The publisher keeps an internal receipt tying the source digest to the returned URL and the digest of the served bytes. A later run verifies that receipt before reusing the upload.

Real-Ghost tests covered unchanged-asset reuse, replacement of only a changed asset, feature images and an unchanged repeat publication. A controlled test covered different source and stored bytes; that is simulation evidence, not a claim that we exercised every real Ghost image transformation.

An uncertain upload without a confirmed receipt still needs investigation. We did not solve that by giving the retry button a more confident label.

Detect edits and recover through Git

Ghost uses updated_at for collision detection, and its API documentation recommends fetching the latest post before updating it. Tag and author relations are replaced rather than merged, which also affects how we preserve unrelated metadata. Ghost's update contract sets those expectations.

Our tests found that timestamps alone were insufficient, so the publisher also compares the live body and owned fields with its recorded state. An unexpected Ghost edit stops an ordinary update.

Recovery keeps Git authoritative. To restore earlier prose, I restore it in a new Git revision and prepare that candidate; the stale-revision guard stays in place. To keep an edit made in Ghost, I first bring it into Git. Only when the resulting candidate matches the live post can repair_state reconcile the record without rewriting the post or uploading its images.

Both procedures were demonstrated against disposable Ghost. The implementation has no general force-overwrite switch.

Follow one article from a pull request to the first plan

For the first run, we enrolled github-to-ghost-part-1 as a draft, pushed the pipeline branch and opened a pull request. Article CI validated it, tested the application and retained its candidate. The existing Theme CI ran separately.

After I merged the pull request, the push to main ran CI again. This distinction matters: the publication workflow needed the candidate for the merged commit, produced by main's successful run. The earlier branch artifact was review material.

For that first plan, the merged revision was the following. This is a record of our run, not a revision to enter in another repository:

51bf82c747757c08e254ee663d47593a8d0b91ef

We then started Article publish from main with these inputs:

article: github-to-ghost-part-1
revision: 51bf82c747757c08e254ee663d47593a8d0b91ef
operation: plan
confirm: "no"
repair_state: false

The hosted workflow selected and verified main's candidate, passed it to the protected job, authenticated to the production Ghost API and proposed creating a draft. These are selected fields from the retained run record:

{
  "mode": "plan",
  "status": "draft",
  "live_changed": "no",
  "mutations": {
    "post": "none",
    "assets_uploaded": 0,
    "tags": "none"
  },
  "outcome": "planned",
  "exit_code": 0
}

That established the hosted path through candidate production, artifact verification, credential delivery and production API reads. It made no post, asset or tag changes.

A write uses operation: publish and confirm: "yes" for the chosen candidate. With status: draft, it creates or updates a Ghost draft. We subsequently used that path to create the draft and update it through the same workflow. The latest draft update before this revision reported outcome: updated, kept the same Ghost post ID, and passed the saved-content and state-record checks. Its published_at was still null. Writing a draft and publishing a public article are different outcomes, even though both use the publish operation.

The activation run used an earlier, shorter version of this manuscript. The expanded article you are reading came afterward. Changing the working copy does not change the already-prepared candidate.

For an operator, the useful checkpoints are:

  1. After merge, find the successful Article CI push run on main and copy its full commit SHA. Its artifact should contain the intended article ID, revision, status and rendered content.
  2. In Actions → Article publish → Run workflow, select main, the article ID and that SHA. Start with operation: plan, confirm: no, and repair_state: false. Download article-publish-record and check the proposed action, article status and zero mutations.
  3. For the write, keep the same article and SHA; change the operation to publish and confirmation to yes. For a draft, check the saved-content result and open the preview in Ghost Admin. A draft deliberately skips the public-page check.
  4. Repeating the same candidate should report unchanged, retain the post ID, and perform no new post write or upload. A red workflow badge alone does not tell you whether retrying is appropriate; read the mutation record.
  5. To go public, commit status: published, review and merge it, wait for that new revision's CI, then plan and publish that revision. The earlier draft candidate cannot become public merely by clicking its publish button again.

What the checks actually established

The reference implementation passed 337 application tests, including 24 scenarios against disposable Ghost 6.64.0, locally and in the hosted integration step. The separate unit-test step passed 313 and skipped those 24 integration scenarios; the integration step then passed all 337 with no skips. Containerized browser checks also rendered the representative article and this manuscript at desktop and mobile widths.

These checks answer different questions:

Check What it established
Local containers and disposable Ghost Application behavior, failure/recovery cases and representative rendering
Article CI on GitHub Hosted checkout, non-root runtime, unit and disposable-Ghost integration checks, and candidate upload
The first production plan Candidate selection and production Ghost reads, with zero writes
Production draft writes and browser checks The existing draft was updated and its preview rendered correctly

The first plan completed on October 6, 2026. Draft writes and preview checks came afterward. The failure and recovery exercises used disposable Ghost; we did not deliberately interrupt production writes to repeat them there. Keeping those distinctions matters more than making every box green.

A note on building it with AI

I initially asked for one bounded implementation handoff, followed by a conformance review. Useful code came back, along with a correction process that eventually produced rather a lot of reports about the next correction process. I asked Codex to finish the remaining work directly.

The useful account is what we could observe: a failure against real Ghost, the assumption behind it, the correction and the check that demonstrated it. The build journal is an edited account of that work, not a transcript of an AI's private reasoning.

The Part 1 source snapshot keeps this explanation tied to a specific edition. Its README carries the setup commands; Part 1.5 walks through them in more detail. Later source releases won't silently change the edition this article describes.

Part 2 will move execution onto runners I manage. The container and CLI contract can come with it; the runner's isolation, access and maintenance will need their own work. A published publisher image would live in a container registry; the runner executes it and sends article content to Ghost.

For this article, the route is through the GitHub repository: commit the final version, review and merge it, let CI prepare the candidate, then choose that revision and start publication to Ghost. This is the process the article itself will go through to reach the site.

If you're reading this on neumachen.dev, all the way down here, it made it through. The pipeline worked.

Now I just have to write the next one.