Zenn の記事を private/public repository で同期する GitHub Actions

7 min読了の目安(約6500字TECH技術記事

Zenn における GitHub 連携

Zenn は GitHub 連携して記事管理できる便利な機能があります。
GitHubリポジトリでZennのコンテンツを管理する

ただし、同期できるリポジトリは1つのみのため

  • 「有料な本は Private リポジトリにしたい」
  • 「無料の記事は Public リポジトリにしたい(PR 受け付けたい)」

そんな場合にどっちしか取ることができません。

そこで Private リポジトリの無料公開記事を Public リポジトリ同期する GitHub Action を書いたので紹介します。

構成

Zenn と同期するのは Private リポジトリです。
Private リポジトリで執筆します。
Private リポジトリの同期ブランチが更新(push)されると Priate リポジトリで同期アクションが実行されます。
(PR のことも考えて逆方向も対応してます)

構成

以下で詳しく説明します。

無料記事を Public リポジトリに同期する

実際のアクションを見たほうが早いと思うので、
YAML にコメント補足するスタイルで説明します。
実際の設定はこちらから確認できます。

https://github.com/srz-zumix/Zenn-public

GitHub Action

https://github.com/srz-zumix/Zenn-public/blob/master/.github/workflows/sync.yml
sync.yml
name: 'Run sync'
on:
  # 動作確認用に使ってたトリガー
  # pull_request:
  # Private リポジトリ に更新があった場合にトリガーされる
  repository_dispatch:
    types: [sync]

jobs:
  sync-from-private:
    runs-on: ubuntu-latest
    env:
      PYTHON_VERSION: 3.8
      # Private リポジトリのユーザーとリポジトリ名
      FROM_REPO_USER: "srz-zumix"
      FROM_REPO_NAME: "Zenn"
    steps:
      # 自分をチェックアウト
      - uses: actions/checkout@v1
      # Private リポジトリをチェックアウト
      - uses: actions/checkout@v1
        with:
          repository: ${{ env.FROM_REPO_USER }}/${{ env.FROM_REPO_NAME }}
          # アクセストークンは `repo` を含んだものを Secret にセットする
          token: ${{ secrets.GITHUBPAT }}
          ref: master # 同期するブランチ
          path: ./From
      # PR 用のブランチ名作成と Private リポジトリのハッシュを取得する
      - name: create branch name
        id: create_branch_name
        run: |
          cd ../From
          HASH=$(git rev-parse HEAD)
          echo "##[set-output name=hash;]$(echo ${HASH})"
          # echo "##[set-output name=branch;]$(echo sync/${HASH})"
          # トリガーされる度に PR 作られるよりも、PR は1つで更新される方がいいと思ったので名前固定
          echo "##[set-output name=branch;]$(echo sync/latest)"
      # 同期用スクリプトの実行環境セットアップ
      - uses: actions/setup-python@v2
        with:
          python-version: ${{ env.PYTHON_VERSION }}
      - name: Set Python environment variable
        run: echo "LD_LIBRARY_PATH=${{ env.pythonLocation }}/lib" >> $GITHUB_ENV
      # 同期
      - name: sync
        # Private -> Public への同期は Private 側の状態を優先するので先に全部消す
        # check-publish.py が公開設定のファイルをリストアップするので、それをコピーする
        run: |
          rm -rf ./articles/*.md
          python check-publish.py ../From/articles | xargs -I {} cp -f {} ./articles/
      # PR アクション使って PR 作成
      # これ便利!!
      - name: Create Pull Request
        uses: peter-evans/create-pull-request@v3
        with:
          base: master
          branch: ${{ steps.create_branch_name.outputs.branch }}
          title: "Sync from private ${{ steps.create_branch_name.outputs.hash }}"
          body: "sync by https://github.com/${{ env.FROM_REPO_USER }}/${{ env.FROM_REPO_NAME }}/commit/${{ steps.create_branch_name.outputs.hash }}"
          commit-message: "sync by ${{ steps.create_branch_name.outputs.hash }}"
          delete-branch: true
          labels: "sync"
          reviewers: ${{ env.GITHUB_ACTOR }}

check-publish.py

check-publish.pypublished: true な Markdown を検出するスクリプトです。

https://github.com/srz-zumix/Zenn-public/blob/master/check-publish.py
check-publish.py
check-publish.py
#!/usr/bin/env python

import os
import sys
import re

def check(path):
    if os.path.splitext(path)[1] != '.md':
        return False
    with open(path) as f:
        head = f.readline()
        if head.strip() != '---':
            return False
        while True:
            text = f.readline().strip()
            if text == '---':
                break
            if re.match(r'^published:\strue$', text):
                return True
    return False


def check_and_print(path):
    if check(path):
        print(path)


def check_dir(dir):
    for f in os.listdir(dir):
        if f.startswith('.'):
            continue
        path = os.path.join(dir, f)
        if os.path.isdir(path):
            if f in ['articles', 'books']:
                check_dir(path)
        elif os.path.isfile(path):
            check_and_print(path)


def main():
    for path in sys.argv[1:]:
        if os.path.isdir(path):
            check_dir(path)
        elif os.path.isfile(path):
            check_and_print(path)

if __name__ == '__main__':
    main()

Private リポジトリの push イベントで Public リポジトリのアクションをトリガーする

こちらのアクションは Private リポジトリの方に設定します

Github Actions で他のリポジトリからの変更通知を受け取ってPRを作成する Workflow」を参考に作成しました。
(※ GITHUB_ で始まる Secret は予約名として使えなくなったっぽいので気をつけてください)

(Public リポジトリの方にも同様の dispatch.yml があるので参考にしてください。)

https://github.com/srz-zumix/Zenn-public/blob/master/.github/workflows/dispatch.yml
dispatch.yml
name: 'Dispatch sync'
on:
  push:
    branches:
      - master
    # articles または books ディレクトリ以下で更新があった場合のみ
    paths:
      - 'articles/**'
      - 'books/**'

jobs:
  sync-trigger:
    runs-on: ubuntu-latest
    steps:
      # Public リポジトリにイベントを送る
      # (peter-evans さんお世話になっております)
      - name: dispatch sync
        uses: peter-evans/repository-dispatch@v1
        with:
          repository: srz-zumix/Zenn-public
          token: ${{ secrets.GITHUBPAT }}
          event-type: sync

Public リポジトリの更新を Private リポジトリに同期する

今度は逆に Public -> Private への同期を設定します。
Public リポジトリで PR などに対応した場合を想定しています。

check-publish.py は全く同じものを使用します。
dispatch.ymlsync.yml もほぼ同じものを使います。
Private リポジトリなので実物を見ることができませんが、差分だけ説明します。

Private -> Public の場合は、Public 側のファイルをすべて削除して Private を優先していました。
Public -> Private の場合も、Private を優先したいので今度はファイル削除をしません。
(Private 側には未公開のファイルがあるため)

sync.yml
      - name: sync
        run: |
-         rm -rf ./articles/*.md
+         # rm -rf ./articles/*.md
          python check-publish.py ../From/articles | xargs -I {} cp -f {} ./articles/

あとは、リポジトリの向き先が Public <-> Private で入れ替わるだけです。

sync.yml
      FROM_REPO_USER: "srz-zumix"
-     FROM_REPO_NAME: "Zenn"
+     FROM_REPO_NAME: "Zenn-public"
dispatch.yml
      - name: dispatch sync
        uses: peter-evans/repository-dispatch@v1
        with:
-         repository: srz-zumix/Zenn-public
+         repository: srz-zumix/Zenn
          token: ${{ secrets.GITHUBPAT }}

結果

https://github.com/srz-zumix/Zenn-public/pull/12

PR結果

今後

現時点で対応してるのは articles のみなので本を書いたら books にも対応したいと思います。