🔧 TECH ARTICLE 🔧
〜 T-01 〜

GitHub Actionsを使ったMarkdown→WordPress自動投稿システムの構築

⚙️ はじめに
この記事では、GitHub ActionsとWordPress REST APIを組み合わせて、Markdownファイルを自動的にWordPressサイトに投稿するシステム「wp-story-sync」の構築方法を詳しく解説します。実際のプロジェクトで使用されているコードとワークフローを基に、実践的な実装方法を説明します。
⚙️ システムアーキテクチャ
▸ 全体構成
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   GitHub Repo   │────│  GitHub Actions  │────│  WordPress Site │
│                 │    │                  │    │                 │
│ stories/        │    │ 1. Validation    │    │ REST API v2     │
│ ├─ series1.md   │    │ 2. Dry-run       │    │ + App Password  │
│ ├─ series2.md   │    │ 3. Publish       │    │                 │
│ └─ ...          │    │                  │    │                 │
└─────────────────┘    └──────────────────┘    └─────────────────┘
▸ コンポーネント構成
  1. Configuration Layer (lib/config.py) – 設定管理
  2. WordPress API Layer (lib/wp_client.py) – REST API クライアント
  3. Caching Layer (lib/wp_cache.py) – API レスポンスキャッシュ
  4. Business Logic (lib/story_processor.py) – ストーリー処理ロジック
  5. GitHub Actions Workflows – CI/CDパイプライン
⚙️ 実装詳細
▸ 1. WordPress REST API クライアント
WordPress API との通信を担うクライアントクラスです:
# lib/wp_client.py (抜粋)
class WordPressClient:
    """WordPress REST API client with caching."""

    def __init__(self, config: WordPressConfig, cache: Optional[WordPressCache] = None):
        self.config = config
        self.cache = cache or WordPressCache()
        self.logger = logging.getLogger("wp-client")
        self.session = requests.Session()

    def _request(self, method: str, path: str, **kwargs) -> requests.Response:
        """Make authenticated HTTP request to WordPress API."""
        if self.config.dry_run and method.upper() in ('POST', 'PUT', 'PATCH', 'DELETE'):
            self.logger.info("DRY RUN: %s %s", method.upper(), path)
            # Return mock response for dry run
            response = requests.Response()
            response.status_code = 200
            response._content = b'{"id": 999, "status": "dry-run"}'
            return response

        url = self.config.endpoint(path)
        kwargs.setdefault('auth', self.config.auth_tuple())
        kwargs.setdefault('timeout', self.config.timeout)

        response = self.session.request(method, url, **kwargs)
        return response
主要機能
– WordPress Application Password認証
– ドライランモード(テスト用)
– キャッシュ統合
– エラーハンドリング
▸ 2. ストーリー処理エンジン
Markdownファイルの処理とWordPress投稿データの生成:
# lib/story_processor.py (主要部分)
class StoryProcessor:
    def process_story_file(self, file_path: Path) -> Optional[Dict[str, Any]]:
        """Process a single story markdown file and publish to WordPress."""
        try:
            # Parse frontmatter and content
            with file_path.open('r', encoding='utf-8') as f:
                post = frontmatter.load(f)

            metadata = post.metadata
            content = post.content

            # Convert markdown to HTML
            html_content = markdown.markdown(content, extensions=['extra', 'codehilite'])

            # Prepare WordPress post data
            post_data = self._prepare_post_data(metadata, html_content, file_path)

            # Check if post exists (by slug)
            slug = metadata.get('slug', '')
            existing_posts = self.wp_client.search_posts_by_slug(slug)

            if existing_posts:
                # Update existing post
                post_id = existing_posts[0]['id']
                result = self.wp_client.update_post(post_id, post_data)
            else:
                # Create new post
                result = self.wp_client.create_post(post_data)

            return result
        except Exception as e:
            self.logger.exception("Error processing story file %s: %s", file_path, e)
            return None
処理フロー
1. Frontmatter解析
2. Markdown→HTML変換
3. 投稿データ準備
4. 既存投稿チェック
5. 作成/更新実行
▸ 3. GitHub Actions ワークフロー
本格的な本番環境対応のワークフロー設定:
# .github/workflows/wp-sync.yaml
name: WP Story Sync

on:
  push:
    branches:
      - main
    paths:
      - 'stories/**'
      - 'scripts/**'
      - 'config/**'

  workflow_dispatch:
    inputs:
      mode:
        description: 'Sync mode'
        required: true
        default: 'dry-run'
        type: choice
        options:
          - dry-run
          - publish

jobs:
  # ============================================================
  # Job 1: Validate and Dry-Run
  # ============================================================
  validate:
    name: Validate Stories
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Validate strict
        run: python3 scripts/validate.py --strict stories/

      - name: Dry-run WordPress sync
        run: python3 scripts/post_story.py --dry-run --gated stories/

  # ============================================================
  # Job 2: Publish to WordPress (Production Only)
  # ============================================================
  publish:
    name: Publish to WordPress
    needs: validate
    runs-on: ubuntu-latest

    # Only run on main branch push, not PRs
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'

    # Requires production environment approval
    environment: production

    env:
      WP_BASE_URL: ${{ secrets.WP_BASE_URL }}
      WP_USERNAME: ${{ secrets.WP_USERNAME }}
      WP_APP_PASSWORD: ${{ secrets.WP_APP_PASSWORD }}
      WP_FORCE_PUBLISH: "1"

    steps:
      - name: Final validation
        run: python3 scripts/validate.py --strict stories/

      - name: Publish to WordPress
        run: python3 scripts/post_story.py --mode publish --gated stories/
💡 ワークフローの特徴💡 :
多段階バリデーション: 厳格なチェック
環境分離: production環境は承認制
安全な自動化: PRでは投稿実行なし
アーティファクト保存: ログと検証結果の保持
▸ 4. Frontmatterスキーマ
統一されたMarkdownフロントマター仕様:
---
title: "記事タイトル"
slug: "unique-post-slug"
categories: ["Tech", "WordPress"]
tags: ["automation", "CI/CD"]
author: "TechWeaver"
status: "publish"  # draft, publish, trash
date: "2025-01-11"
excerpt: "記事の要約"
featured_image: ""
episode_id: "T-01"
series: "Tech"
---
自動分類機能
# 自動的にシリーズ情報を分類
if "stories/solaris" in path_str:
    if "Solaris" not in categories:
        categories.append("Solaris")
    if not metadata.get('author'):
        metadata['author'] = 'StarWeaver'

elif "stories/ragteller" in path_str:
    if "RAGteller" not in categories:
        categories.append("RAGteller")
⚙️ セキュリティ実装
▸ 1. 認証システム
WordPress Application Passwordを使用した安全な認証:
class WordPressConfig:
    def auth_tuple(self) -> tuple[str, str]:
        """Return authentication tuple for requests."""
        return (self.username, self.app_password)

    def endpoint(self, path: str) -> str:
        """Build full API endpoint URL."""
        return f"{self.base_url.rstrip('/')}/wp-json/wp/v2/{path.lstrip('/')}"
⚠️ セキュリティポイント⚠️ :
– Application Password(専用パスワード)
– HTTPS必須
– 環境変数での秘密情報管理
– 最小権限の原則
▸ 2. 入力値検証
厳格なバリデーション機能:
# scripts/validate.py による事前チェック
def validate_frontmatter(metadata: Dict[str, Any]) -> List[str]:
    """Validate frontmatter fields."""
    errors = []

    # Required fields check
    for field in REQUIRED_FIELDS:
        if field not in metadata:
            errors.append(f"Missing required field: {field}")

    # Title validation
    title = metadata.get('title', '')
    if len(title) > 200:
        errors.append("Title too long (max 200 chars)")

    # Slug validation
    slug = metadata.get('slug', '')
    if not re.match(r'^[a-z0-9-]+$', slug):
        errors.append("Invalid slug format")

    return errors
▸ 3. 公開制御
多層防御による安全な公開制御:
# GitHub環境保護ルール
environment: production
# ↓ 手動承認が必要

# ブランチ保護
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

# 内部ガード
WP_FORCE_PUBLISH: "1"
💡 パフォーマンス最適化
▸ 1. インテリジェントキャッシュ
API レスポンスのキャッシュによる高速化:
class WordPressCache:
    """Thread-safe cache for WordPress API responses."""

    def __init__(self, ttl: int = 3600):
        self.ttl = ttl
        self._cache: Dict[str, CacheEntry] = {}
        self._lock = threading.Lock()
        self._stats = {"hits": 0, "misses": 0}

    def get(self, key: str) -> Optional[Any]:
        """Get cached value if not expired."""
        with self._lock:
            entry = self._cache.get(key)
            if entry and not entry.is_expired():
                self._stats["hits"] += 1
                return entry.value

            self._stats["misses"] += 1
            return None
💡 最適化効果💡 :
– カテゴリ/タグID取得の高速化
– API呼び出し回数の削減
– レスポンス時間の向上
▸ 2. バッチ処理
効率的なディレクトリ処理:
def process_directory(self, stories_dir: Path) -> Dict[str, Any]:
    """Process all story files in a directory."""
    markdown_files = list(stories_dir.glob('**/*.md'))

    results = []
    for md_file in markdown_files:
        result = self.process_story_file(md_file)
        results.append({
            "file": str(md_file.relative_to(stories_dir)),
            "status": "success" if result else "failed",
            "post_id": result.get('id') if result else None
        })

    return {
        "processed": len(markdown_files),
        "successful": len([r for r in results if r["status"] == "success"]),
        "results": results
    }
⚙️ 運用とモニタリング
▸ 1. ログ管理
構造化ログによる運用監視:
# lib/utils.py
def setup_logging(config: LoggingConfig) -> None:
    """Setup structured logging with file output."""

    # Create logs directory
    config.log_dir.mkdir(exist_ok=True)

    # Configure root logger
    logging.basicConfig(
        level=getattr(logging, config.level),
        format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
        handlers=[
            logging.FileHandler(config.log_dir / 'wp-sync.log'),
            logging.StreamHandler()
        ]
    )
ログ項目
– 処理ファイル名
– WordPress投稿ID
– エラー詳細
– パフォーマンス情報
▸ 2. アーティファクト管理
GitHub Actionsでの証跡管理:
- name: Upload validation artifacts
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: validation-logs
    path: |
      .logs/
      stories/.validated

- name: Upload publish logs
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: publish-logs
    path: .logs/
▸ 3. エラーハンドリング
堅牢なエラー処理機能:
class ResponseDebugger:
    """Debug and save HTTP responses."""

    def save_exception(self, context: str, exception: Exception) -> None:
        """Save exception details for debugging."""
        error_file = self.log_dir / f"error_{context}_{self._timestamp()}.json"

        error_data = {
            "timestamp": datetime.now().isoformat(),
            "context": context,
            "exception_type": type(exception).__name__,
            "exception_message": str(exception),
            "traceback": traceback.format_exc()
        }

        with error_file.open('w') as f:
            json.dump(error_data, f, indent=2)
⚙️ 実践的な使用例
▸ 1. 新規ストーリー投稿
# 1. ストーリーファイル作成
cat > stories/tech/new-story.md << 'EOF'
---
title: "新しい技術記事"
slug: "new-tech-story"
categories: ["Tech"]
tags: ["tutorial"]
status: "publish"
---

# 新しい技術記事

本文内容...
EOF

# 2. Git操作
git add stories/tech/new-story.md
git commit -m "add: 新しい技術記事を追加"
git push origin main

# 3. 自動実行
# GitHub Actionsが自動で:
# - バリデーション実行
# - ドライラン確認
# - WordPress投稿
▸ 2. ローカル開発・テスト
# 環境設定
export WP_BASE_URL="https://your-site.com"
export WP_USERNAME="your-username"
export WP_APP_PASSWORD="xxxx xxxx xxxx xxxx"

# バリデーション実行
python scripts/validate.py --strict stories/

# ドライラン実行
python scripts/post_story.py --dry-run stories/

# 実際の投稿(要注意)
python scripts/post_story.py --mode publish stories/
▸ 3. トラブルシューティング
# デバッグモード実行
python scripts/post_story.py --debug --dry-run stories/

# ログ確認
cat .logs/wp-sync.log

# APIレスポンス確認
ls .logs/
# response_categories_20250111_143022.json
# response_posts_20250111_143025.json
⚙️ 今後の拡張可能性
▸ 1. メディア自動アップロード
# 将来実装予定
def upload_featured_image(self, image_path: Path) -> Optional[int]:
    """Upload image and return media ID."""
    with image_path.open('rb') as f:
        files = {'file': f}
        response = self._request('POST', 'media', files=files)
        return response.json().get('id')
▸ 2. マルチサイト対応
# config/sites.yaml (計画)
sites:
  main:
    base_url: "https://main-site.com"
    username: "main-user"

  staging:
    base_url: "https://staging.com"
    username: "stage-user"
▸ 3. Webhookトリガー
# webhookによるリアルタイム同期
@app.route('/webhook', methods=['POST'])
def handle_webhook():
    """Handle GitHub webhook for real-time sync."""
    payload = request.get_json()
    if payload.get('ref') == 'refs/heads/main':
        trigger_sync_job()
⚙️ まとめ
この記事では、GitHub ActionsとWordPress REST APIを使った自動投稿システムの詳細な実装方法を解説しました。
💡 主要なポイント💡 :
  1. モジュラー設計: 保守性の高いアーキテクチャ
  2. セキュリティ重視: Application Password + 環境保護
  3. 運用考慮: ログ・モニタリング・エラーハンドリング
  4. パフォーマンス: キャッシュ・バッチ処理
  5. 拡張性: 将来機能への対応設計
このシステムにより、技術ブログやドキュメントサイトの更新を完全自動化でき、開発者は執筆に集中できる環境を構築できます。実際のプロダクションで稼働している実装例として、参考にしていただければと思います。
⚙️ 参考リンク

この記事は wp-story-sync プロジェクトの実装を基にしています。最新のコードとドキュメントは GitHub リポジトリでご確認ください。
⚙️ Tech Series – T-01 🔧
Engineering the future…