2026年5月31日

2026年5月31日

GitHubのMarkdownをWordPressへ自動同期する方法(GitHub Actions+REST API)

GitHubリポジトリに置いたMarkdownファイルを、pushのたびにWordPressの投稿として自動で作成・更新するには、GitHub ActionsでMarkdownをHTMLに変換し、WordPressのREST APIへアプリケーションパスワードで送る構成が扱いやすい方法です。この記事では、リポジトリの構成、変換・送信スクリプト、ワークフロー定義、つまずきやすい点(画像・削除・ブロックエディター)までを順に説明します。

手元のPCやサーバー上で1回ずつMarkdownを投稿するCLIツールを作りたい場合は、WP-CLIのカスタムコマンドで実装する別記事の方法が向いています。本記事は「Gitを正本にして、pushをきっかけに自動反映する」ことに絞っています。

全体の構成と、この方式を選ぶ理由

処理の流れは次のとおりです。

  1. 執筆者がMarkdownファイル(先頭にタイトルやスラッグを書いたフロントマター付き)をGitHubにpushする
  2. GitHub Actionsが起動し、今回のpushで追加・変更されたMarkdownだけを取り出す
  3. PythonスクリプトがMarkdownをHTMLに変換し、スラッグで既存投稿を探す
  4. 見つかれば更新、無ければ新規作成としてREST APIに送る

GitHubのWebhookをWordPress側で受けてリポジトリを取りに行く方式もありますが、その場合はWordPressにGitHubのトークンを置き、受信エンドポイントで署名検証やタイムアウト対策を自前で作り込む必要があります。Actionsから送る方式なら、WordPress側はコアのREST APIをそのまま使え、認証情報はGitHubのSecretsで管理できます。失敗したときもActionsのログで原因を追いやすいのが利点です。

既製のプラグインでGitHubのMarkdownを投稿として取り込めるものもあります(例:Git it Write)。コードを書かずに済ませたい場合は先に検討してください。ただし対応する記法や更新頻度はプラグインごとに異なるため、導入前に説明ページで最新の対応状況を確認してください(2026年10月現在)。

事前準備:WordPress側

同期専用ユーザーとアプリケーションパスワード

同期用に専用のユーザーを作り、権限は「編集者」程度にとどめます。管理者アカウントのパスワードを使い回すのは避けてください。ユーザーのプロフィール画面にある「アプリケーションパスワード」で新しいパスワードを発行し、表示された値を控えます。この値はログインパスワードとは別物で、あとから個別に失効させられます。

アプリケーションパスワードはHTTPSでのみ利用できるのが原則です。また、セキュリティ系プラグインやサーバーのWAFがREST APIや Authorization ヘッダーを止めていると401エラーになります。設定を変える前に、まず次のコマンドで認証が通るかを確認しておくと切り分けが楽になります。

curl -u "sync-bot:your-app-password" https://example.com/wp-json/wp/v2/users/me?context=edit

ユーザー情報のJSONが返れば準備完了です。

HTMLのフィルタリングを理解しておく

REST APIで送った本文は、ユーザーの権限に応じてWordPressのHTMLフィルター(kses)を通ります。unfiltered_html 権限を持たないユーザーの場合、<script> や一部の属性は保存時に取り除かれます。これは安全のための動作なので、外す方向ではなく「Markdown側にスクリプトを書かない」運用で対応するのが無難です。マルチサイトでは管理者でもこの権限を持たない点に注意してください。

事前準備:リポジトリ側

ディレクトリとフロントマター

例として、次のような構成にします。

.
├── content/
│   ├── first-post.md
│   └── second-post.md
├── scripts/
│   └── sync.py
└── .github/workflows/sync-wordpress.yml

各Markdownファイルの先頭に、YAMLのフロントマターを書きます。

---
title: はじめての同期テスト
slug: first-sync-test
status: draft
excerpt: GitHubから自動で作成された投稿です。
---

## 見出し

本文を **Markdown** で書きます。

slug を既存投稿との照合キーに使うため、一度決めたら変えない運用にします。status を省略したときは下書きで作成されるようにしておくと、誤って公開される事故を防げます。

GitHub Secretsの登録

リポジトリの Settings から Secrets を開き、WP_URL(例:https://example.com)、WP_USER、WP_APP_PASSWORD の3つを登録します。ワークフローのファイルやスクリプトに直接書くと、リポジトリの閲覧者全員に見えてしまいます。公開リポジトリの場合、フォークからのプルリクエストではSecretsが渡らない仕様になっているため、同期はmainブランチへのpushに限定します。

変換・送信スクリプト

scripts/sync.py は、引数で受け取ったMarkdownファイルを1件ずつ処理します。

import os
import sys

import frontmatter  # python-frontmatter
import markdown
import requests

WP_URL = os.environ["WP_URL"].rstrip("/")
AUTH = (os.environ["WP_USER"], os.environ["WP_APP_PASSWORD"])
API = f"{WP_URL}/wp-json/wp/v2/posts"
STATUSES = "publish,future,draft,pending,private"


def find_post_id(slug):
    r = requests.get(
        API,
        params={"slug": slug, "status": STATUSES, "context": "edit"},
        auth=AUTH,
        timeout=30,
    )
    r.raise_for_status()
    items = r.json()
    return items[0]["id"] if items else None


def sync_file(path):
    doc = frontmatter.load(path)
    meta = doc.metadata
    slug = meta.get("slug")
    if not slug or not meta.get("title"):
        print(f"skip {path}: title と slug が必要です")
        return False

    html = markdown.markdown(doc.content, extensions=["fenced_code", "tables"])
    payload = {
        "title": meta["title"],
        "slug": slug,
        "content": html,
        "status": meta.get("status", "draft"),
        "excerpt": meta.get("excerpt", ""),
    }

    post_id = find_post_id(slug)
    url = f"{API}/{post_id}" if post_id else API
    r = requests.post(url, json=payload, auth=AUTH, timeout=60)
    if r.status_code >= 400:
        print(f"error {path}: {r.status_code} {r.text[:300]}")
        return False
    action = "updated" if post_id else "created"
    print(f"{action} {path} -> id={r.json()['id']}")
    return True


if __name__ == "__main__":
    results = [sync_file(p) for p in sys.argv[1:]]
    sys.exit(0 if all(results) else 1)

いくつか補足します。

  • 既存投稿の検索では status に複数の状態を指定しています。指定しないと公開済みの投稿しか返らず、下書きを毎回新規作成してしまいます
  • 失敗したファイルがあると終了コード1で終わるため、Actionsの画面で失敗として表示されます。エラー本文は先頭だけを出力し、認証情報はログに出しません
  • markdown ライブラリの拡張は、コードブロック(fenced_code)と表(tables)だけを有効にしています。必要に応じて追加してください

ワークフロー定義

.github/workflows/sync-wordpress.yml に次の内容を置きます。アクションのメジャーバージョンは更新されていくため、各アクションの公式リポジトリで最新を確認してから使ってください(2026年10月現在)。

name: Sync Markdown to WordPress

on:
  push:
    branches: [main]
    paths:
      - "content/**"

concurrency:
  group: wp-sync
  cancel-in-progress: false

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0

      - uses: actions/setup-python@v6
        with:
          python-version: "3.12"

      - run: pip install markdown python-frontmatter requests

      - name: Sync changed Markdown files
        env:
          WP_URL: ${{ secrets.WP_URL }}
          WP_USER: ${{ secrets.WP_USER }}
          WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }}
          BEFORE: ${{ github.event.before }}
          AFTER: ${{ github.sha }}
        run: |
          if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
            FILES=$(git ls-files 'content/*.md')
          else
            FILES=$(git diff --name-only --diff-filter=AM "$BEFORE" "$AFTER" -- 'content/*.md')
          fi
          if [ -z "$FILES" ]; then
            echo "変更されたMarkdownはありません"
            exit 0
          fi
          python scripts/sync.py $FILES

fetch-depth: 0 は差分を取るために履歴全体を取得する指定です。省略すると直前のコミットが手元に無く、git diff が失敗します。ブランチを新しく作った最初のpushでは github.event.before がゼロ埋めの値になるため、その場合だけ全ファイルを対象にしています。concurrency は、連続してpushしたときに同期処理が並行して走り、同じ投稿が二重に作られるのを防ぐための設定です。

まずはステージング環境のURLを WP_URL に入れて動作を確かめ、問題がなければ本番のURLに切り替えます。本番に向ける前には、データベースのバックアップを取っておいてください。

つまずきやすい点と対処

画像が表示されない

Markdown内の ./images/foo.png のような相対パスは、WordPress上では解決できません。対処は2通りです。画像をあらかじめメディアライブラリにアップロードしてそのURLを書くか、スクリプトで画像をREST APIの /wp/v2/media に送り、返ってきたURLに置き換える処理を追加します。後者は重複アップロードを避ける工夫(ファイル名やハッシュでの照合)も必要になるため、まずは前者で始めるのが手軽です。

ファイルを削除・リネームしても投稿が残る

上のワークフローは追加(A)と変更(M)だけを対象にしているため、削除やリネームは反映されません。自動で投稿を消すと取り返しがつかないので、削除は手動でゴミ箱に移す運用をおすすめします。リネームした場合もスラッグが同じなら同じ投稿が更新されますが、スラッグまで変えると別の投稿が新しく作られます。

ブロックエディターで「クラシック」ブロックになる

REST APIで送ったHTMLは、ブロックの区切りコメントを含まないため、エディターで開くと1つのクラシックブロックとして表示されます。表示上の問題はありませんが、エディターで編集すると次の同期で上書きされる点に注意してください。正本はGitHub側と決め、WordPressの画面では編集しない運用にすると混乱がありません。

401や403が返る

401は認証情報の誤りか、サーバーが Authorization ヘッダーを渡していないケースが多いです。前述のcurlで確認し、通らない場合はサーバーの設定やセキュリティプラグインのREST API制限を見直します。403は同期ユーザーの権限不足(他人の投稿の編集や公開の権限が無い等)が典型です。

まとめ

  • GitHubを正本にして、pushをきっかけにGitHub ActionsからREST APIへ送る構成なら、WordPress側にGitHubのトークンを置かずに自動同期できます
  • 認証は専用ユーザーのアプリケーションパスワードで行い、値はGitHub Secretsに保存します
  • スラッグを照合キーにして、既存投稿があれば更新、無ければ下書きで作成します
  • 変更されたファイルだけを処理し、削除は自動化せず手動で扱うのが安全です
  • 最初はステージングで試し、本番に向ける前にバックアップを取ります

お気軽にご相談ください

お見積りへ お問い合わせへ
▲