> ## Content Index
> Fetch the complete content index at: https://neumachen.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Publishing to Ghost from GitHub, Part 1.5: run it yourself
- URL: https://neumachen.dev/articles/github-to-ghost-publishing-part-1-5/
- Published: 2026-10-08T01:21:46.000Z
- Updated: 2026-10-08T01:21:46.000Z
- Description: Set up the companion repository, connect GitHub Actions to Ghost, and take one Markdown article from local preview to a verified draft and public post.
- Author: Kareem Hepburn
- Tags: Engineering, GitHub Actions, Ghost, Docker, #nc-article-github-to-ghost-part-1-5

After [Part 1](https://neumachen.dev/articles/github-to-ghost-publishing-part-1/), I had another question: could someone actually use it to set this up themselves? I'd explained how we built the pipeline, but I also wanted a guide someone could follow with their own repository and Ghost site. That's what Part 1.5 is for.

We'll use the [Part 1.0.1 companion source](https://github.com/neumachen/ghost-article-pipeline/tree/part-1.0.1?ref=neumachen.dev). It includes the publisher, Dockerfile, tests and both GitHub workflows. We'll build the container from that source, write a short Markdown article, send it to Ghost as a draft, then publish and update it through GitHub.

You should be comfortable with Git and pull requests. The commands below use a macOS or Linux shell; GitHub provides the `ubuntu-24.04` runners. Part 2 will cover running the same publisher on runners I manage.

## Before the first command

You'll need Git, [mise](https://mise.jdx.dev/getting-started.html?ref=neumachen.dev), and a running [Docker installation](https://docs.docker.com/engine/install/?ref=neumachen.dev) with Compose v2\. Check them in your terminal:

```sh
git --version
mise --version
docker info
docker compose version

```

Each command should succeed. If `docker info` cannot reach the daemon, start Docker before continuing. The publisher runs inside its container, so there is no host `npm install` step.

You'll also need an existing Ghost site with access to custom integrations and an empty GitHub repository you can administer. Create the repository without adding a README, license or `.gitignore`; those files come with the source. Use your normal GitHub authentication for Git pushes.

Choose the repository's visibility with your writing in mind. A Ghost draft does not make its source private if you've already pushed it to a public repository. GitHub supports the environment features used here in public repositories on its current plans. Private repositories require GitHub Pro, Team or Enterprise for environment secrets and deployment-branch controls. Check [GitHub's environment availability](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments?ref=neumachen.dev) before committing to that setup.

## Give the publisher a home

Clone the fixed source edition and create your own `main` branch:

```sh
git clone --branch part-1.0.1 --single-branch \
  https://github.com/neumachen/ghost-article-pipeline.git ghost-publishing
cd ghost-publishing
git switch -c main
git remote rename origin reference
git remote add origin https://github.com/YOUR-ACCOUNT/YOUR-REPOSITORY.git
git remote -v

```

Replace `YOUR-ACCOUNT` and `YOUR-REPOSITORY` with your destination. After these commands, `origin` should point to your repository and `reference` to the companion source. Keep the included MIT license.

Cloning a tag produces Git's detached-HEAD notice; creating `main` gives your work a branch. Renaming the remote may also warn about a non-default fetch refspec because this clone follows a tag. Check that the remote names and URLs are correct. Neither message means you've lost the checkout. Git simply has a lot to say before you've written a paragraph.

Read `mise.toml` and `tools/article/run.sh` before trusting the tasks. Then build and check the publisher:

```sh
mise trust
mise run article:image
mise run article:check -- --working-tree

```

The image build should finish successfully. Validation should also succeed, although the registry contains no articles yet.

There are three directories to remember:

| Directory           | What belongs there                                |
| ------------------- | ------------------------------------------------- |
| article/            | Publisher source and its container definition     |
| editorial/articles/ | Your Markdown, article assets and registry        |
| dist/article/       | Generated candidates and previews; ignored by Git |

Push the initial branch:

```sh
git push -u origin main

```

On GitHub, confirm that `main` is the default branch and Actions is enabled. The initial **Article CI** run should pass. It deliberately skips uploading candidates when the registry is empty. No Ghost credential is needed for CI.

## Write something small enough to finish

Create a branch and an article directory:

```sh
git switch -c first-article
mkdir -p editorial/articles/hello-ghost

```

In your editor, create `editorial/articles/hello-ghost/article.md` with this complete content:

```markdown
---
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.

```

The YAML between the `---` lines is the front matter. The `id` gives this article a stable identity; `title` is its headline, and `slug` supplies the URL slug. Keep the ID when you edit the headline or move the source directory.

Set `status: draft` explicitly. In this source edition, omitting status defaults to `published`. That is a fairly consequential line to leave out.

Replace the initially empty `editorial/articles/registry.json` with:

```json
{
  "schema": "neumachen-article-registry/1",
  "articles": [
    {
      "id": "hello-ghost",
      "path": "editorial/articles/hello-ghost"
    }
  ]
}

```

The registry explicitly enrolls articles in the pipeline. Its ID must match the front matter, and its path names the article's directory. When you add another article later, append an entry to this list; don't replace the articles already in it.

Validate and preview the working files:

```sh
mise run article:check -- --working-tree
mise run article:preview -- \
  --article hello-ghost \
  --working-tree \
  --out /out/hello-ghost-preview.html

```

Validation should report `OK hello-ghost` and `status=draft`. Open `dist/article/hello-ghost-preview.html` in your browser. You should see the title, heading and single sentence. `/out` in the command is the container mount that maps to `dist/article` on your machine.

This is a local rendering preview. Nothing has gone to Ghost, and a candidate made from uncommitted working files cannot be published.

## Tie the article to a commit

Commit the two source files and prepare a candidate from that commit:

```sh
git add editorial/articles/hello-ghost/article.md editorial/articles/registry.json
git commit -m "Add first Ghost draft"
mise run article:prepare -- --article hello-ghost --revision HEAD
git rev-parse HEAD

```

Preparation should report `prepared candidates: 1/1`. Your output directory now contains:

```text
dist/article/hello-ghost/
  candidate.json
  manifest.json
  body.html
  preview.html
  assets/

```

Open `candidate.json`. Its `article.id` should be `hello-ghost`, its `status` should be `draft`, and its `source.revision` should match the full commit SHA printed by `git rev-parse HEAD`. It should also contain the title, slug and `Engineering` tag you wrote. The sample has no images, so its asset list is empty.

These files let you inspect what the publisher prepared. Leave them out of Git. GitHub Actions will build its own candidate from the merged revision; the publication workflow does not upload the bundle from your laptop.

## Give the GitHub workflow access to Ghost

In Ghost Admin, open the integrations settings and add a dedicated custom integration. Give it a recognizable name, such as `GitHub article publisher`. It provides an API URL, a Content API key and an Admin API key. We need the **Admin API key** to write posts. Use the API URL displayed with the integration; it may differ from your site's public domain. [Ghost's custom-integration guide](https://ghost.org/integrations/custom-integrations/?ref=neumachen.dev) shows where to find the keys.

In your GitHub repository, open **Settings → Environments**, create an environment named `ghost-production`, and configure:

1. Under **Deployment branches and tags**, choose **Selected branches and tags** and add a **Branch** rule for `main`.
2. Add an **environment variable** named `GHOST_ADMIN_API_URL` with the integration's API URL.
3. Add an **environment secret** named `GHOST_ADMIN_API_KEY` with the integration's Admin API key.

The variable and secret names must match exactly. Put them on this environment, and check that the branch rule is present. The [GitHub environment guide](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments?ref=neumachen.dev) documents these controls.

Keep the key out of the repository, command history and screenshots. It does not need to be exported locally for this guide. The workflow receives it from GitHub for the Ghost operation. GitHub also supplies the workflow's `GITHUB_TOKEN`; you do not create a personal access token for that input.

Both workflows are already in the source: [Article CI](https://github.com/neumachen/ghost-article-pipeline/blob/part-1.0.1/.github/workflows/article-ci.yml?ref=neumachen.dev) checks and prepares the article, while [Article publish](https://github.com/neumachen/ghost-article-pipeline/blob/part-1.0.1/.github/workflows/article-publish.yml?ref=neumachen.dev) is started manually. The publisher's decisions remain in the application. You don't need to reconstruct them in YAML.

## Follow the pull request to the right candidate

Push your article branch:

```sh
git push -u origin first-article

```

Open a pull request from `first-article` into `main`. Review the Markdown and registry diff, wait for Article CI and any other checks your repository requires, then merge.

After the merge, open **Actions → Article CI** and find the successful **push run on main** for the merged revision. Copy its full 40-character commit SHA. This can differ from the SHA you prepared locally, particularly after a squash or merge commit.

In that run's artifacts, download `article-candidates-<SHA>`. Unzip it and inspect the `hello-ghost` candidate and preview. Check the article ID, title, tag, draft status and `source.revision` again. If those don't match what you intend to publish, stop at the candidate and resolve the mismatch.

The passing PR run tells you the proposed change passed its checks. Production accepts the candidate from the successful main push run. A familiar artifact name alone is not enough; the workflow verifies where it came from.

At this point, you've merged an article and retained its candidate. Ghost still hasn't received it.

## Plan the write, then create the draft

Open **Actions → Article publish → Run workflow**. Select branch `main` and fill in:

- `article`: `hello-ghost`
- `revision`: the full SHA from the successful main push run
- `operation`: `plan`
- `confirm`: `no`
- `repair_state`: `false`

Start the workflow and wait for it to finish. GitHub's [manual workflow guide](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow?ref=neumachen.dev) covers the controls; the workflow must exist on the default branch, and you need write access to run it.

Plan mode reads Ghost without changing it. Open the run summary, download its `article-publish-record` artifact, and open `article-record.json` inside it. For this new article, expect `outcome: planned`, a proposed `create`, and `status: draft`. Check the record's mutation counts: no post or tag writes, and no uploaded images. Check the article and revision too. If it proposes updating an existing post, investigate that identity before continuing.

Once the plan matches, start **a new run** of Article publish with the same article, branch and exact revision. Change only:

```text
operation: publish
confirm: yes

```

Leave `repair_state` false. Don't use GitHub's re-run button on the plan and expect it to become a write; start a run with the changed inputs.

After a successful write, the record should show `created`, the Ghost post ID, and successful saved-content and state verification. Open the draft in Ghost Admin and inspect its preview, including the title, body and Engineering tag. Keep the post ID for comparison later. Public-page verification is skipped for a draft because there isn't a public article to check yet.

The two uses of “publish” deserve a pause. The workflow's `operation: publish` means *perform the write*. The article's committed `status: draft` means *write a draft*. A successful run of a workflow called Article publish can therefore leave you with a very successfully unpublished article.

## Make it public in a new revision

Start from the merged state:

```sh
git switch main
git pull --ff-only
git switch -c publish-hello-ghost

```

Change only `status: draft` to `status: published` in `editorial/articles/hello-ghost/article.md`. Validate, preview and commit it:

```sh
mise run article:check -- --working-tree
mise run article:preview -- \
  --article hello-ghost \
  --working-tree \
  --out /out/hello-ghost-preview.html
git add editorial/articles/hello-ghost/article.md
git commit -m "Publish Hello from GitHub"
git push -u origin publish-hello-ghost

```

Review and merge that PR, then wait for the new main push run of Article CI. Inspect its candidate: this time the status must be `published`.

Plan **that new SHA**, expecting an update to the draft's post ID. If it matches, start the publish run for the same SHA with `confirm: yes`. Reusing the earlier draft revision would just ask for the draft again.

The successful record should confirm the update, saved content and state, and public-page verification. Open the actual URL returned by Ghost. Don't build one by putting the slug after your domain; your site's routes may add a prefix such as `/articles/`.

Read the public page. Check the title, sentence and tag, and keep the recorded post ID and publication timestamp for the next step. The workflow does not send a newsletter.

## Give the same post another sentence

Once the publication PR is merged, start another branch from current main:

```sh
git switch main
git pull --ff-only
git switch -c update-hello-ghost

```

Append this paragraph to the article body:

```text
This sentence arrived in a later commit. The post did not need a new identity.

```

Keep its ID and published status. Validate and preview as before, then:

```sh
git add editorial/articles/hello-ghost/article.md
git commit -m "Update Hello from GitHub"
git push -u origin update-hello-ghost

```

Review and merge the PR. Wait for Article CI on main, inspect the new candidate, then plan and publish its exact SHA. Expect `updated`, with the same Ghost post ID and original publication timestamp. Open the returned URL and check that the new sentence is there.

Start one more publish run with those same inputs. With no intervening changes, expect `unchanged`: the same post ID, no new post write and no asset uploads.

## When a step stops

Read the workflow's failure message and retained record before trying again. If it stopped before the publisher ran, there may be no publication record; the failed selection or configuration step is then your starting point.

- **Article missing locally:** check the registry path, matching IDs, and `--working-tree` for uncommitted writing.
- **No Run workflow button:** check Actions access, write permission, and the workflow's presence on the default branch.
- **Candidate missing or refused:** check the exact SHA, successful Article CI push on main, artifact expiry and the selector's error. A PR artifact is not a replacement.
- **Authentication failed:** check the `ghost-production` environment, its `main` branch rule, the URL variable, and the Admin API secret from the same integration.
- **Still a draft:** inspect the committed candidate's status. Promote a draft through a new revision.
- **Ghost-side edit conflict:** bring the intended edit into Git before reconciling state; don't overwrite it by repeatedly dispatching.
- **Exit 5:** the write outcome or saved state is uncertain. Inspect the record and Ghost before deciding how to recover.
- **Exit 6:** the post write was confirmed, but the public-page check failed. Investigate that check and the returned URL before another write.

For an expired candidate, use a successful CI run for the selected main revision or prepare a new reviewed descendant revision. Don't try to reconstruct the artifact by renaming a local ZIP.

The ordinary path keeps `repair_state` false. That option reconciles a state record only when the committed candidate already matches Ghost; it is not a force-overwrite switch. Keep the [companion recovery notes](https://github.com/neumachen/ghost-article-pipeline/blob/part-1.0.1/README.md?ref=neumachen.dev#reading-failures) nearby. Restoring older prose also means creating a new Git revision containing that prose, rather than asking the publisher to move backwards to an ancestor.

## If you want to test the publisher too

The local writing path only needs validation and preview. For a deeper check of the application and its container, the companion includes:

```sh
mise run article:test
mise run article:runtime-check
mise run article:evidence
mise run article:integration

```

The last task starts a disposable Ghost instance, runs the integration suite and removes the stack's data volume afterward. It does not need your site's credentials. Docker Desktop users need its [host-networking option](https://docs.docker.com/engine/network/drivers/host/?ref=neumachen.dev) enabled for this integration task; ordinary checks and previews do not require it. The [companion README](https://github.com/neumachen/ghost-article-pipeline/blob/part-1.0.1/README.md?ref=neumachen.dev#additional-local-checks) also covers port selection and why concurrent integration stacks are unsupported.

## Why I still publish manually

I still like having that last step where I decide whether I actually want to publish an article. The pipeline can finish its checks, and I can still choose to leave it as a draft.

I could make that decision in Ghost Admin. But since I'm already using the pipeline to prepare the draft and send it to Ghost, it felt more natural to keep the human decision in the pipeline too.

## The next article is yours

In Part 2, I'll move this onto my own GitHub runners. For the next article, though, the setup is already there. Add a directory and registry entry with a new stable ID, review the article, and choose when it goes live.