Back to Blog

Language: English

Adding a Publication Gate to the Portfolio Site

Merging to main fired distribution to Qiita and Zenn directly. This post covers gating publication behind a long-lived blog branch with a single PR, a status comment that sorts published posts from drafts, and a permanent draft canary that detects leaks.

The blog runs on Astro, and posts are markdown under src/content/blog/ja/. The frontmatter draft field decides publication. The catch sits elsewhere: pushing to main fires distribution to external platforms. The moment you merge to main is the moment you publish. Working on a branch stays safe, but nothing between authoring and merging forced a review. This post covers the changes the portfolio site gained as that checkpoint.

What Fires the Distribution Workflow

Distribution lives in the existing blog-distribution.yml. It fires when the push target is main and the changed paths hit src/content/blog/ja/** and friends. The workflow gathers the changed Japanese posts, posts them to Qiita through the API, writes Zenn-ready files into articles/, and commits those files. Running this workflow is publication itself.

One PR, Kept Alive

A PR per post scatters publication decisions across as many PRs. I set up one long-lived blog branch instead and keep a single PR to main alive. Nobody recreates the PR; its body keeps getting updated instead.

On every push to blog, the workflow checks whether an open PR exists:

open_count=$(gh pr list --repo "$GITHUB_REPOSITORY" --head blog --base main --state open --json number --jq "length")

if [ "$open_count" -ge 1 ]; then
  echo "An open pull request from blog to main already exists."
  exit 0
fi

An existing PR means the workflow does nothing. Otherwise it counts commits differing from main: zero skips, one or more creates the PR. Later pushes pile commits onto that PR.

The Status Comment

A following job reports the state. First git diff lists changes under the post directories, then the job reads each post’s frontmatter from both base and head revisions. From that it builds a comment in this shape:

## Blog status

### このマージで新たに公開される

| | 記事 |
|---|---|
| ● | 公開される変更 |

### 公開 (`draft: false`)

| | 記事 |
|---|---|
| ✚ | 追加された公開記事 |
| ● | 更新された公開記事 |

### 非公開 (`draft: true`)

| | 記事 |
|---|---|
| ✚ | 追加された下書き |
| ● | 更新された下書き |
| ✖ | 削除された下書き |

✚ 追加 / ● 変更 / ✖ 削除

A test pins this whole format.

The “newly published by this merge” section collects only changes where draft: true at base turned into draft: false at head. It sits first, as its own section, because those entries are what this merge distributes, and the gate should check them first. Deleted posts classify by their state at base, meaning main’s side. Deleting a published post lands in the published section; deleting a draft lands in the draft section.

The comment carries no diff line counts. Three symbols suffice: add, change, delete. Files changed on the same screen already show line counts, so the same information should not live in two places.

Updating the comment means deleting the old comment and posting a new one. An HTML comment marker embedded in the body identifies our comment. The workflow deletes every marked comment, then posts the fresh one. Editing a comment leaves its timeline position untouched, so delete-and-repost keeps the latest state at the bottom of the timeline.

With zero post changes, comment generation returns null and the posting step never runs.

Where draft Gets Evaluated

My first idea for hiding unpublished posts was a static flag resolved at build time. That plan extended the mechanism from Keep OFF-Flag Code Out of Your Build Artifacts.

A flag means something only while published and unpublished posts share one tree. Remove the coexistence and nothing needs hiding anymore. The static-flag approach keeps one failure mode alive: evaluate the flag wrong and unpublished posts surface in output. It also requires managing values per environment and watching for values lingering in artifacts. Dropping posts at input makes both failure modes impossible by construction. Comparing these, I settled on the latter direction.

The implementation parks draft evaluation at the input boundary as well. On the site side, one spot loads all posts and filters:

const publicEntries = sortBlogEntries(
  entries.filter((entry) => !entry.data.draft)
);

Only posts surviving this point come back from the getter functions below. Unpublished posts never reach page-generation input, so no path can carry them into the artifact. Distribution mirrors this: the sync script drops any post whose translation group has a draft on either the en or ja side.

A Permanent Unpublished Post

On top of structural exclusion sits leak detection. A standing unpublished post sits in the posts directory. Its frontmatter holds draft: true; its body is a single line starting with CANARY-MARKER:. The check extracts three strings from it: the title, the routeSlug, and the CANARY-MARKER: line.

Another workflow runs the check. Pushes to main and blog, plus PRs targeting main, trigger it, and after a production build it sweeps the entire dist. Any one of the three strings appearing anywhere fails the check. Artifacts sometimes emit strings as \uXXXX escapes, so the check matches against escape-decoded strings too.

Building this surfaced one requirement: the check has to confirm that the canary itself remained unpublished. Flip the canary’s draft to false and it detects nothing from then on. Detection machinery must detect its own disabling.

The function extracting the check strings opens with a frontmatter inspection:

if (data.draft !== true) {
  throw new Error(
    `${CANARY_RELATIVE_PATH} must keep "draft: true" so it can detect leaks.`
  );
}

If any of the title, routeSlug, or CANARY-MARKER: line cannot be extracted, the same entry point throws. Disabling the canary surfaces as the canary check itself failing.

Day-to-day operation means reading one section at the top of the PR comment: newly published by this merge. Merging while only the draft section has rows sends nothing outward, because the distribution script skips drafts. Checking before merge came down to reading a single PR comment.