WordPressをAstroとGitHubActionsを使って自動で記事公開

このブログは、シンレンタルサーバー上のWordPress(テーマはCocoon)で4年ほど運用してきました。

ただ、WordPressで記事を管理するのがだんだん面倒になってきたので、Markdownで書いた記事が自動で公開される形に移しました。

面倒だと思いながら4年もそのままにしていたのは、移行そのものが面倒だったからです。

最近はAIに作業をかなり任せられるようになって、移行のハードルがぐっと下がったので、ようやく重い腰を上げました。

この記事では、移行後の構成と、作業中に実際につまずいた点をまとめます。

手順そのものは公式ドキュメントで足りるので、ドキュメントを読んだだけでは気づきにくかったところを中心に書きます。

忙しい人向け

  • 構成は「Markdown → GitHubのPR → GitHubActionsでビルド → rsync over SSHでシンレンタルサーバーへ転送」です

  • WordPressは、URLに対応するファイルがサーバーに無いときだけページを作って返す設定になっていました。

    そのため、新しいサイトのページを1記事ずつサーバーに置けば、その記事だけが新しいサイトに切り替わります。

    うまくいかなければ、置いたファイルを消すだけで元に戻せます

  • シンレンタルサーバーの「国外アクセス制限」は初期状態でONです。

    公式マニュアルには、AWSやAzureなど海外拠点のサーバーからアクセスするならOFFにするよう書かれています。

    GitHubActionsのランナーはAzureかAWSの仮想マシンで動く(GitHubのドキュメント)ので、私は先にOFFにしておきました

  • @astrojs/sitemapが出力するのはsitemap-index.xmlとsitemap-0.xmlで、sitemap.xmlというファイルは作られません(公式ドキュメント)。

    robots.txtがsitemap.xmlを指しているなら、ビルド後に複製するなどの対処が要ります

  • 検索結果に出る記事の説明文(meta description)は、WordPressのREST APIで取れる抜粋(excerpt)とは別物でした。

    Cocoonが独自の入力欄に保存していたので、公開ページのHTMLから取り直しました

移行前の環境と、移ろうと思った理由

移行前は次の環境でした。

  • サーバー: シンレンタルサーバー(旧サーバーパネル)
  • CMS: WordPress+テーマCocoon
  • 記事数: 投稿21本、固定ページ4本
  • 書き方: 手元のMarkdownでドラフトを書き、HTMLに変換してCocoonのエディタに貼り付けていた

移ろうと思った理由は2つです。

  1. 記事をMarkdownのままGitで管理したかった

    下書きは手元のMarkdownなのに、公開するときだけHTMLに変換して貼り付けていました。

    公開後に直すと、手元とWordPressのどちらが正なのか分からなくなります。

  2. エディタやAIツールから記事をファイルとして直接扱いたかった

    記事がリポジトリのファイルになっていれば、書く・直す・構造をチェックする作業を、いつもの開発と同じ道具でできます。

移行後の構成

記事を書く(Markdown)
  → ブランチを切ってPR
  → GitHubActions(CI): 記事の構造チェック → ビルド → 内部リンクチェック
  → mainにマージ
  → GitHubActions(Deploy): ビルド → 生成物の検査 → rsync over SSH
  → シンレンタルサーバーのpublic_htmlに反映

リポジトリはおおむね次の形です。

aiteablog-site/
├── src/
│   ├── content.config.ts       # frontmatterのスキーマ
│   ├── content/blog/           # 記事(ファイル名=URLのスラッグ)
│   ├── content/pages/          # 固定ページ
│   ├── pages/[slug].astro
│   ├── pages/feed/index.xml.ts # RSS
│   └── integrations/           # sitemap.xmlを出すための小さなintegration
├── public/
│   ├── .htaccess
│   └── images/<slug>/
├── scripts/
│   ├── export_from_wp.py       # WordPress REST API → Markdown(移行時だけ)
│   ├── lint_articles.py        # 記事の構造チェック(CI)
│   └── check_links.py          # 内部リンクチェック(CI)
└── .github/workflows/
    ├── ci.yml
    └── deploy.yml

使っているバージョンは、Astro 7系、Node.js 22(GitHubActions上)です。

なぜAstroにしたか

最初は、普段Vueを書いているのでNuxtで作るつもりでした。

でも、ブログ用のフレームワークを探していたら、Astroというものがありました。

色々調べてみた結果、今回の移行にぴったりだと思ったので、Astroにしました。

調べてみて分かったのは、次のようなことです。

  • Astroは、公式ドキュメントの「Why Astro?」で、ブログのようなコンテンツ中心のサイトを作るためのフレームワークだと書いています
  • 公式のブログ用テンプレートがあり、記事をMarkdownで置けばブログとして動く形から始められます
  • Next.jsやNuxtはWebアプリも作れる汎用のフレームワークで、ブログ専用の作りではありません

Astroでも@astrojs/vueを入れればVueのコンポーネントを使えるので、Vueの知識が無駄になることもありません。

進め方: WordPressを残したまま1記事ずつ切り替える

当初は「WordPressを丸ごと退避してから静的サイトに切り替える」一発勝負を考えていました。

ところが、サーバーの.htaccessを開いてみると、WordPressのリライトは次のようになっていました。

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]

!-fと!-dは「実ファイルもディレクトリも無いときだけ」という条件です。

つまりpublic_html/<slug>/index.htmlを置けば、その記事だけ静的版が返り、それ以外はWordPressが返します。

いちばん危ないと思っていた切り替えが、記事単位で試して駄目なら消すだけ、という安全な作業になりました。

実際には、次の順で進めました。

  1. **1記事だけ手元からrsyncで置く。

    ** 静的版が返るか、他の記事はWordPressのまま動いているかを確かめる

  2. **GitHubActionsから全ページを転送する。

    ** この間はWordPressと同居させるため、.htaccessと/feed/は転送しない

  3. **WordPressのバックアップを取り、public_htmlの外へ移す。

    ** 公開ディレクトリの中に退避すると、そのままURLで見えてしまうので外に出す

  4. 転送に--deleteを付けて、サーバーをdist/と一致させる

静的版かWordPressかは、ページの本文にwp-contentが含まれるかで見分けました。

WordPressのページはテーマやプラグインの読み込みで必ずwp-contentを含み、静的版には出てきません。

curl -s https://aiteablog.com/git-add-indexfile/ | grep -c wp-content
# 1以上ならWordPress、0なら静的版

最初はx-pingbackヘッダの有無で見分けようとしましたが、WordPressのページでもnginxのキャッシュから返るとこのヘッダが付かず、判別に使えませんでした。

記事の吸い出しでつまずいた点

記事はWordPressのREST APIから取得し、PythonでMarkdownに変換しました。

コードブロックが3種類混ざっていた

WordPressで書いていた頃、コードブロック用のプラグインをその時々で入れていたため、記事によってコードブロックの形式が3つ混ざっていました。

形式 元のコードがある場所 言語指定
Code Block Proのブロック(9記事) コピーボタン用の<textarea> なし
Highlighting Code Blockのブロック(3記事) <pre><code> lang-plain(実質なし)
素の<pre> <pre><code class="language-bash"> あり

Code Block Proのブロックは保存時にハイライト済みで、HTMLが<span>だらけでした(多い記事で5,446個)。

ここから元のコードを組み立て直すのは無理だと思いましたが、コピーボタン用の<textarea>に元のコードがそのまま入っていたので、そこから取り出せました。

言語指定が残っていたのは、素の<pre>の記事だけでした。

Markdownから変換して貼り付けた記事です。

WordPressのブロックエディタで直接書いた記事は、言語指定が失われていました。

コードを入れたプレースホルダがHTMLパーサに壊された

変換では、コードブロックをいったんプレースホルダに置き換えてから、残りのHTMLをパーサに通していました。

最初はプレースホルダの中にコードそのものをJSONで埋め込んでいました。

# 最初の実装(壊れる)
def _fence(code, lang):
    payload = json.dumps({"lang": lang, "code": code})
    return f"\x00CODE{payload}\x00"

すると、コードの中に<h2>を含む記事(Google MapsのInfoWindowにHTMLを渡すコード)で、変換後の本文に覚えのない## の見出しが混ざりました。

パーサがJSONの中の<h2>をタグとして解釈したためです。

プレースホルダには番号だけを入れ、中身は別のリストに持つように直しました。

# 直した実装(番号で参照する)
BLOCKS = []

def _fence(code, lang):
    BLOCKS.append(("code", lang, code))
    return "\x00B%d\x00" % (len(BLOCKS) - 1)

パーサに通すものの中に、タグとして解釈されうる値を入れないのがポイントでした。

meta descriptionはexcerptとは別物だった

最初は記事のdescriptionを、REST APIのexcerpt(抜粋)から取っていました。

ところが、公開ページの<head>に出ているmeta descriptionと比べると中身が違いました。

内容
excerpt 本文の冒頭を自動で切り出したもの
実際のmeta description 記事ごとに手で書いたもの

Cocoonはmeta descriptionを独自のカスタムフィールドに持っていて、REST APIの標準のフィールドには出てきません。

公開ページのHTMLから取り直しました。

m = re.search(r'<meta name="description" content="([^"]*)"', page)

気づかずに進めていたら、21本ぶんの手書きのdescriptionを失うところでした。

ブログカードが多い記事ほど文字数が減った

変換が正しいかを確かめるため、旧サイトと変換後で本文の文字数を比べました。

すると、ブログカードが多い記事ほど変換後の文字数が減っていました。

ブログカードの数 変換後 ÷ 変換前
0(6本) 0.93〜1.01
2 0.85〜0.89
4 0.73〜0.89
12 0.80

ブログカードが無い記事はほぼ一致していたので、変換自体は合っていました。

減った分は、ブログカードが表示していたリンク先サイトの説明文です。

自分の書いた文章ではないので、落ちて正解でした。

Astroの設定でつまずいた点

Content CollectionsのAPIが古い書き方のまま

計画段階で用意していたコード例は古いバージョンの書き方で、Astro 7ではそのまま動きませんでした。

古い書き方 今の書き方
定義ファイル src/content/config.ts src/content.config.ts
コレクションの定義 defineCollection({ schema }) glob()などのloaderを指定する
zのimport元 astro:content astro/zod
レンダリング post.render() render(post)

検索で見つかる記事は古い書き方のものが多いので、公式ドキュメントの今の版で確かめるのが確実でした。

今の定義はこうなっています。

import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    date: z.coerce.date(),
    // 以下略
  }),
});

URLの末尾スラッシュを揃える

WordPress時代のURLはhttps://aiteablog.com/<slug>/(末尾スラッシュあり)でした。

ここが変わると全記事のURLが変わってしまうので、Astroの出力を合わせました。

// astro.config.mjs
export default defineConfig({
  site: 'https://aiteablog.com',
  trailingSlash: 'always',
  build: { format: 'directory' }, // dist/<slug>/index.htmlとして出力する
});

@astrojs/sitemapはsitemap.xmlを出さない

公式ドキュメント(@astrojs/sitemap)のとおり、出力されるのはsitemap-index.xmlとsitemap-0.xmlで、sitemap.xmlというファイルは作られません。

filenameBaseで接頭辞(初期値はsitemap)は変えられますが、後ろに-indexや-0が付くので、sitemap.xmlという名前にはなりません。

一方、WordPress時代のrobots.txtには次のように書いてありました。

Sitemap: https://aiteablog.com/sitemap.xml

このまま切り替えると、サイトマップの参照先が404になります。

ビルドは成功するのでエラーにはならず、気づくのはSearch Consoleでサイトマップが読めなくなってからです。

ビルド後にsitemap-index.xmlをsitemap.xmlとして複製するintegrationを書いて対処しました。

import { copyFile } from 'node:fs/promises';

export default function sitemapAlias() {
  return {
    name: 'sitemap-alias',
    hooks: {
      'astro:build:done': async ({ dir }) => {
        await copyFile(new URL('sitemap-index.xml', dir), new URL('sitemap.xml', dir));
      },
    },
  };
}

/feed/は.htaccessで受ける

WordPressは/feed/でRSSを返していました。

Astroではsrc/pages/feed/index.xml.tsから/feed/index.xmlを出力し、.htaccessで/feed/へのアクセスにindex.xmlを返すようにしました。

DirectoryIndex index.html index.xml

/feed/のディレクトリにはindex.htmlが無いので、index.xmlが返ります。

他のページには影響しません。

サーバーとGitHubActionsでつまずいた点

サーバー側の設定は、シンレンタルサーバー公式マニュアルの「SSH設定」を見ながら進めました。

画面の名称やボタン名は、このマニュアルと旧サーバーパネルの表示に合わせています。

秘密鍵のパーミッションや接続コマンドの例、ssh-keyscanでのknown_hostsの取得は、マニュアルには載っていないので、一般的なSSHの手順で補いました。

国外アクセス制限は初期状態でON

シンレンタルサーバーには、国外からのSSH接続を制限する「国外アクセス制限」があり、初期状態でONになっています。

公式マニュアルのSSH設定には、AWSやAzureなど海外拠点のサーバーからアクセスする場合はOFFにするよう書かれています。

GitHubActionsのGitHubホステッドランナーは、GitHubのドキュメント(GitHub-hosted runners)によると、Microsoft AzureかAmazon Web Services(AWS)の仮想マシンで動いています。

どの国のデータセンターで動くかまでは書かれていませんが、マニュアルの「AWSやAzureなど」に当たるので、ONのままではActionsから繋がらないと考えました。

私はこの記述にマニュアルを読んでいる段階で気づいたので、SSHの設定をするときにOFFにしておきました。

そのため、ONのままで本当に繋がらないかは試していません。

ただ、この説明はSSH設定のページの後半に書かれていて、デプロイの設定だけを追っていると読み飛ばしやすいと思います。

この設定が原因なら、手元(国内)からは繋がり、Actionsからだけ繋がらないという差が出るはずです。

OFFにするとSSHのポートに国外からも届くようになります。

このサーバーのSSHは公開鍵認証だけでパスワードでは入れないこと、ポートが22番ではなく10022番であること、デプロイ専用の鍵を作り漏れたときはその鍵だけ止めればよいことから、OFFにすると判断しました。

公式マニュアルは、国外からのアクセスが要らないならONのまま使うことを勧めているので、必要なときだけOFFにするのがよいと思います。

公開鍵の登録が先、SSHのONは後

マニュアルには、SSHで接続するにはサーバーに公開鍵を登録しておく必要があると書かれています。

シンレンタルサーバーのSSHは公開鍵認証にしか対応していないためです。

先に公開鍵を登録し、そのあとでSSHを有効にする順にすると迷いません。

鍵はデプロイ専用に作り、普段使いの鍵は流用しませんでした。

ssh-keygen -t ed25519 -C "deploy" -f ~/.ssh/<デプロイ用の鍵の名前> -N ""

公開鍵はサーバーパネルで生成する方法もありますが、秘密鍵がサーバーを経由しないように、手元で作った公開鍵を登録する方法にしました。

公開ページのHTMLだけでは棚卸しできなかった

移行で何を置き換える必要があるかは、最初は公開ページのHTMLを読んで洗い出しました。

読み込んでいるJSやCSSのパス(wp-content/plugins/...)から、使っているプラグインがわかります。

ところが、SSHでサーバーに入ってpublic_htmlと.htaccessを見ると、HTMLには一度も出てこないものが残っていました。

  • Wordfence(セキュリティプラグイン)のwordfence-waf.phpと、それを読み込む.user.ini
  • SiteGuard WP Pluginが.htaccessに書いたルール(ログインURLの変更など)
  • シンレンタルサーバーのnginxの全ページキャッシュを有効にする設定(.htaccess)

セキュリティやキャッシュの仕組みは、表示に何も出さないので、公開ページをいくら読んでも見つかりません。

移行の棚卸しは、公開ページとサーバーの中身の両方から行う必要がありました。

nginxのキャッシュで古いページが返ることがある

.htaccessには、シンレンタルサーバーのnginxで全ページをキャッシュする設定が入っていました。

SetEnvIf Request_URI ".*" Ngx_Cache_AllCacheMode

キャッシュが効いていると、rsyncで新しいHTMLを置いても、しばらく古いページが返ります。

実際、全ページを転送した直後のトップページは、静的版のindex.htmlを置いたのにWordPressのページが返りました。

キャッシュが切れたあとは静的版になりました。

デプロイは成功しているのに表示が変わらないので、キャッシュを知らないとビルドや転送を疑ってしまいそうです。

静的なHTMLはnginxがそのまま返せるので、キャッシュを挟む利点は小さいと考え、新しい.htaccessにはこの行を引き継いでいません。

デプロイのワークフローで気をつけたこと

デプロイ先の情報はGitHubのSecretsに入れています。

入れているもの 中身
SSHの秘密鍵 デプロイ専用に作った鍵
サーバーのホスト鍵 ssh-keyscanで取得したもの
SSHのユーザー名 サーバーID
SSHのホスト名 サーバーパネルに表示されるもの
転送先のパス public_htmlの絶対パス

下のコード例のSecretの名前は、説明用に付けたものです。

known_hostsはSecretsに固定する

参考にしたさくらのレンタルサーバーでの事例ではStrictHostKeyChecking=accept-newを使っていました。

これは初めて繋ぐサーバーのホスト鍵を確かめずに受け入れる設定で、接続先が偽物でも気づけません。

手元でssh-keyscanしたホスト鍵をSecretsに入れておき、それを使うようにしました。

サーバーのホスト鍵が変わったら、デプロイが失敗して気づけます。

ssh-keyscan -p 10022 <ホスト名>
- name: SSH 鍵と known_hosts を用意
  run: |
    mkdir -p ~/.ssh
    printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/deploy_key
    chmod 600 ~/.ssh/deploy_key
    printf '%s\n' "${{ secrets.KNOWN_HOSTS }}" > ~/.ssh/known_hosts
    chmod 644 ~/.ssh/known_hosts

空のdistを送らない

--delete付きで転送するので、何かの理由で中身の無いdist/を送ると、本番のページが消えます。

転送の前に、トップページがあることと、ページ数が想定より少なくないことを確かめています。

- name: 生成物の健全性チェック
  run: |
    test -f dist/index.html || { echo "dist/index.html が無い"; exit 1; }
    count=$(find dist -name index.html | wc -l)
    test "$count" -ge 20 || { echo "ページ数が想定より少ない($count < 20)"; exit 1; }

WordPressが戻っていたら転送を止める

--deleteはdist/に無いものをサーバーから消します。

もし復旧作業などでpublic_htmlにWordPressを戻していたら、丸ごと消えてしまいます。

転送の前にwp-config.phpがあるかを確かめ、あれば止めるようにしました。

確かめられなかったとき(SSHの失敗など)も止める側に倒しています。

- name: WordPress が残っていないか確認
  run: |
    set +e
    ssh -p 10022 -i ~/.ssh/deploy_key \
      "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \
      "test -e '${{ secrets.DEPLOY_PATH }}/wp-config.php'"
    rc=$?
    set -e
    case $rc in
      1) echo "転送先に WordPress は無い" ;;
      0) echo "::error::wp-config.php があるため転送を中止する"; exit 1 ;;
      *) echo "::error::転送先を確認できなかった(終了コード $rc)"; exit 1 ;;
    esac

Secretsが無いうちは転送だけ飛ばす

リポジトリを作った直後はSecretsが無く、そのままではデプロイのジョブが毎回失敗します。

ジョブのifではsecretsを参照できないので、ステップでSecretsの有無を判定し、無ければビルドまでで終えるようにしました。

手動実行のときは既定でdry-run(転送内容の確認だけ)にしてあり、ログのdeletingの行で、サーバーから消えるファイルを転送前に確かめられます。

移行前後の比較

同じ記事(git addしてもindexファイルしかコミットされない原因と対処法)のページで比べました。

移行前(WordPress+Cocoon) 移行直後(Astro、デザイン前) 現在(2026年10月23日)
HTMLのサイズ 776.2KB 11.4KB 28.3KB
読み込む外部JS 20本(うち自サイトのもの16本) 2本 2本
jQuery あり なし なし

現在の2本は、Googleアナリティクス(GA4)とAdSenseのスクリプトです。

移行前のHTMLが776KBもあったのは、CocoonがCSSをHTMLに埋め込んで出力していたためです。

外部のCSSファイルは1本もありませんでした。

不要になったJSには、jQuery、画像の遅延読み込み、ライトボックス、シンタックスハイライト、目次のプラグインなどがありました。

シンタックスハイライトはAstroに組み込まれているShikiがビルド時に処理するので、ブラウザ側のJSは要りません。

画像の遅延読み込みはブラウザ標準のloading="lazy"で足ります。

ビルドは、移行直後の26ページで705ミリ秒、今日手元で実行した33ページで1.09秒でした。

まとめ

  • WordPressの.htaccessのリライトは「実ファイルが無いときだけ」なので、静的ファイルを置いて1記事ずつ確かめながら切り替えられました

  • 記事の吸い出しでは、コードブロックの形式の違い、meta descriptionの在り処、ブログカードの説明文に気をつける必要がありました

  • sitemap.xmlと/feed/は、何もしないとURLが変わってしまう箇所でした。

    ビルドが成功しても気づけないので、切り替え前に確かめておくと安心です

  • シンレンタルサーバーからGitHubActionsでデプロイするなら、国外アクセス制限をOFFにし、known_hostsを固定し、--deleteの前に転送先とdist/を検査しておくと、事故を防げます

GitHubActionsでのデプロイは、GitHubActionsとClaspでGASを自動デプロイする方法や、GitHubActionsでECRにDockerImageをPushすると403になる原因と対処法でも書いています。

参考リンク