GitHub ActionsでBacklogドキュメントを自動同期してみた

GitHub ActionsでBacklogドキュメントを自動同期してみた

2026.02.02

はじめに

皆様こんにちは、あかいけです。
先日、Backlogのドキュメントに追加/削除APIが追加されましたね。

https://backlog.com/ja/blog/backlog-update-document-202601/

このアップデートの活用方法の一つとして、GitHub Actionsでリポジトリ内のドキュメントをBacklogに自動同期してみました。
社内で共有したい資料をリポジトリで管理しつつ、Backlogにも自動で反映したい時などに使えるんじゃないかと思います。

本記事のコードは以下テンプレートリポジトリに格納しているので、ご自由にご利用ください。

https://github.com/Lamaglama39/backlog-document-sync

概要

今回作った仕組みの全体像はこんな感じです。

  • リポジトリの./doc/ディレクトリにドキュメントファイルを配置
  • mainブランチへのpush時に、変更のあったファイルだけをBacklogドキュメントに同期
  • ファイルの追加/更新/削除のすべてに対応
  • 手動実行にも対応し、全ファイルを一括同期可能

ファイル構成

.github/workflows/sync-backlog-documents.yml  # ワークフロー定義
scripts/sync-backlog-documents.py              # 同期スクリプト(Python)
requirements.txt                               # Python依存パッケージ
doc/                                           # 同期対象ドキュメント

Backlog APIについて

まず前提として、Backlogのドキュメント関連APIを確認しておきます。

操作 メソッド エンドポイント
ドキュメント一覧の取得 GET /api/v2/documents
ドキュメントの追加 POST /api/v2/documents
ドキュメント情報の取得 GET /api/v2/documents/:documentId
ドキュメントツリーの取得 GET /api/v2/documents/tree
ドキュメント添付ファイルの取得 GET /api/v2/documents/:documentId/attachments
ドキュメントの削除 DELETE /api/v2/documents/:documentId

ここで注意が必要なのが、ドキュメントの更新APIが存在しないという点です。(2026/2/2時点)
そのため、既存ドキュメントを更新したい場合は「削除してから再作成」という方式を取る必要があります。

https://developer.nulab.com/ja/docs/backlog/

認証方式

Backlog APIの認証にはAPIキーを使用します。
リクエストのクエリパラメータにapiKeyを付与するだけなので、実装はシンプルです。

https://{スペースID}.backlog.jp/api/v2/documents?apiKey={APIキー}

APIキーなどの機密情報はGitHubリポジトリのSecretsに格納し、ワークフローから環境変数として参照します。

シークレット名 内容
BACKLOG_API_KEY BacklogのAPIキー
BACKLOG_SPACE_ID Backlogスペース名
BACKLOG_PROJECT_ID BacklogプロジェクトID(数値)
BACKLOG_DOMAIN Backlogドメイン(backlog.jp等)

実装

ワークフローの定義

ワークフローの全体像です。

sync-backlog-documents.yml
name: Sync Documents to Backlog

on:
  push:
    paths:
      - "doc/**"
    branches:
      - main
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: backlog-document-sync
  cancel-in-progress: true

jobs:
  sync:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 2

      - name: Set up Python
        uses: actions/setup-python@v6
        with:
          python-version: "3.14"
          cache: "pip"

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

      - name: Detect changed files
        id: changes
        run: |
          if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
            echo "mode=all" >> "$GITHUB_OUTPUT"
          else
            base="${{ github.event.before }}"
            head="${{ github.sha }}"
            diff_output=$(git diff --name-status "$base" "$head" -- doc/ 2>/dev/null || git diff --name-status HEAD~1 HEAD -- doc/)
            changed=$(echo "${diff_output}" | grep -E '^[ACM]\s' | awk '{print $2}' | tr '\n' ',')
            echo "files=${changed%,}" >> "$GITHUB_OUTPUT"
            deleted=$(echo "${diff_output}" | grep -E '^D\s' | awk '{print $2}' | tr '\n' ',')
            echo "deleted=${deleted%,}" >> "$GITHUB_OUTPUT"
            echo "mode=diff" >> "$GITHUB_OUTPUT"
          fi

      - name: Sync documents to Backlog
        env:
          BACKLOG_API_KEY: ${{ secrets.BACKLOG_API_KEY }}
          BACKLOG_SPACE_ID: ${{ secrets.BACKLOG_SPACE_ID }}
          BACKLOG_PROJECT_ID: ${{ secrets.BACKLOG_PROJECT_ID }}
          BACKLOG_DOMAIN: ${{ secrets.BACKLOG_DOMAIN }}
          CHANGED_FILES: ${{ steps.changes.outputs.files }}
          DELETED_FILES: ${{ steps.changes.outputs.deleted }}
          SYNC_MODE: ${{ steps.changes.outputs.mode }}
        run: python scripts/sync-backlog-documents.py

いくつかポイントを説明します。

変更ファイルの差分検出

doc/配下のファイル変更を差分で検出する部分は、今回の仕組みの肝です。

- name: Checkout repository
  uses: actions/checkout@v6
  with:
    fetch-depth: 2

まずfetch-depth: 2を指定して前回コミットも取得しています。
デフォルトのfetch-depth: 1(shallow clone)だと、github.event.beforeのコミットが存在せずgit diffが失敗してしまいます。
実際に最初はfetch-depthを指定しておらず、fatal: bad objectエラーになりました。

次にgit diff --name-statusを1回だけ実行し、ステータス(A/C/M/D)でファイルを分類しています。

diff_output=$(git diff --name-status "$base" "$head" -- doc/ 2>/dev/null || git diff --name-status HEAD~1 HEAD -- doc/)
changed=$(echo "${diff_output}" | grep -E '^[ACM]\s' | awk '{print $2}' | tr '\n' ',')
deleted=$(echo "${diff_output}" | grep -E '^D\s' | awk '{print $2}' | tr '\n' ',')
  • A(Added), C(Copied), M(Modified): 同期対象
  • D(Deleted): Backlog側のドキュメント削除対象

github.event.beforeが取得できない場合のフォールバックとしてHEAD~1との比較も入れています。

なお、変更ファイル取得にtj-actions/changed-filesを使う方法も一般的ですが、2025年3月にサプライチェーン攻撃(CVE-2025-30066)を受けています。
今回の用途であればgit diffで十分なので、外部依存のない方式を採用しました。

https://www.sysdig.com/jp/blog/detecting-and-mitigating-the-tj-actions-changed-files-supply-chain-attack-cve-2025-30066

トリガーごとの対象ファイル

変更のないファイルは同期対象外とすることで、不要なドキュメントの再作成(= IDの変更)を防いでいます。

トリガー 対象ファイル
push(doc/配下の変更時) 変更されたファイルのみ
workflow_dispatch(手動) doc/配下の全ファイル

同期スクリプト(Python)

同期処理はPython + requestsで実装しました。
シェルスクリプトでcurlを使う方式だとJSON処理やエラーハンドリングが煩雑になるため、Pythonを選択しています。

sync-backlog-documents.py
"""Sync documents from ./doc/ directory to Backlog documents."""

import os
import sys
from pathlib import Path

import requests

def parse_file_list(env_var: str) -> list[str]:
    """Parse a comma-separated file list from an environment variable."""
    value = os.environ.get(env_var, "")
    if not value:
        return []
    return [f.strip() for f in value.split(",") if f.strip()]

class BacklogClient:
    """Client for Backlog Document API."""

    def __init__(self, space_id: str, domain: str, api_key: str, project_id: int):
        self.base_url = f"https://{space_id}.{domain}/api/v2"
        self.api_key = api_key
        self.project_id = project_id

    def _params(self, **kwargs) -> dict:
        """Build query parameters with API key."""
        return {"apiKey": self.api_key, **kwargs}

    def get_documents(self, offset: int = 0, count: int = 100) -> list[dict]:
        """Fetch document list for the project."""
        resp = requests.get(
            f"{self.base_url}/documents",
            params=self._params(**{"projectId[]": self.project_id, "offset": offset, "count": count}),
        )
        resp.raise_for_status()
        return resp.json()

    def fetch_document_map(self) -> dict[str, dict]:
        """Fetch all documents and return a title-to-document mapping."""
        doc_map: dict[str, dict] = {}
        offset = 0
        count = 100
        while True:
            docs = self.get_documents(offset=offset, count=count)
            for doc in docs:
                doc_map[doc["title"]] = doc
            if len(docs) < count:
                break
            offset += count
        return doc_map

    def add_document(self, title: str, content: str) -> dict:
        """Create a new document."""
        resp = requests.post(
            f"{self.base_url}/documents",
            params=self._params(),
            data={
                "projectId": self.project_id,
                "title": title,
                "content": content,
                "addLast": "true",
            },
        )
        resp.raise_for_status()
        return resp.json()

    def delete_document(self, document_id: str) -> dict:
        """Delete a document by ID."""
        resp = requests.delete(
            f"{self.base_url}/documents/{document_id}",
            params=self._params(),
        )
        resp.raise_for_status()
        return resp.json()

def resolve_target_files(doc_dir: Path) -> list[Path]:
    """Determine which files to sync based on environment variables.

    If SYNC_MODE=diff and CHANGED_FILES is set, only those files are targeted.
    Otherwise, all files in doc_dir are targeted.
    """
    sync_mode = os.environ.get("SYNC_MODE", "all")

    if sync_mode == "diff":
        files = [Path(f) for f in parse_file_list("CHANGED_FILES")]
        # 存在しないファイル(削除されたファイル)は除外
        files = [f for f in files if f.is_file()]
        print(f"Mode: diff ({len(files)} changed files)")
    else:
        files = sorted(f for f in doc_dir.iterdir() if f.is_file())
        print(f"Mode: all ({len(files)} files)")

    return files

def resolve_deleted_titles() -> list[str]:
    """Determine which document titles should be deleted.

    Returns:
        List of titles (stem of deleted file paths) to remove from Backlog.
    """
    return [Path(f).stem for f in parse_file_list("DELETED_FILES")]

def delete_removed_documents(client: BacklogClient, doc_map: dict[str, dict]) -> tuple[int, int]:
    """Delete Backlog documents corresponding to deleted files.

    Returns:
        Tuple of (success_count, fail_count).
    """
    titles = resolve_deleted_titles()
    if not titles:
        return 0, 0

    print(f"Deleting {len(titles)} removed document(s)...")
    success = 0
    fail = 0

    for title in titles:
        try:
            existing = doc_map.get(title)
            if existing:
                client.delete_document(existing["id"])
                print(f"  Deleted: \"{title}\" (ID: {existing['id']})")
                success += 1
            else:
                print(f"  Not found on Backlog, skipping: \"{title}\"")
        except requests.HTTPError as e:
            print(f"  ERROR deleting \"{title}\": {e}")
            fail += 1

    return success, fail

def sync_documents(client: BacklogClient, doc_dir: Path, doc_map: dict[str, dict]) -> tuple[int, int]:
    """Sync target files to Backlog documents.

    Returns:
        Tuple of (success_count, fail_count).
    """
    success = 0
    fail = 0

    files = resolve_target_files(doc_dir)
    if not files:
        print("No files found in document directory.")
        return 0, 0

    for i, file_path in enumerate(files, start=1):
        title = file_path.stem
        content = file_path.read_text(encoding="utf-8")
        print(f"[{i}/{len(files)}] {file_path.name} -> \"{title}\"")

        try:
            # 既存ドキュメントがあれば削除
            existing = doc_map.get(title)
            if existing:
                print(f"  Deleting existing document (ID: {existing['id']})")
                client.delete_document(existing["id"])

            # 新規追加
            result = client.add_document(title, content)
            print(f"  Created document (ID: {result['id']})")
            success += 1

        except requests.HTTPError as e:
            print(f"  ERROR: {e}")
            fail += 1

    return success, fail

def main():
    # 環境変数から設定を取得
    required_vars = ["BACKLOG_API_KEY", "BACKLOG_SPACE_ID", "BACKLOG_PROJECT_ID"]
    missing = [v for v in required_vars if not os.environ.get(v)]
    if missing:
        print(f"ERROR: Missing required environment variables: {', '.join(missing)}")
        sys.exit(1)

    api_key = os.environ["BACKLOG_API_KEY"]
    space_id = os.environ["BACKLOG_SPACE_ID"]
    project_id = int(os.environ["BACKLOG_PROJECT_ID"])
    domain = os.environ.get("BACKLOG_DOMAIN", "backlog.jp")
    doc_dir = Path(os.environ.get("DOC_DIR", "./doc"))

    print("=== Backlog Document Sync ===")
    print(f"Space: {space_id}.{domain}")
    print(f"Project ID: {project_id}")
    print(f"Document directory: {doc_dir}")
    print()

    if not doc_dir.is_dir():
        print(f"ERROR: Document directory '{doc_dir}' does not exist.")
        sys.exit(1)

    client = BacklogClient(space_id, domain, api_key, project_id)

    # ドキュメント一覧を一括取得してマップ化
    doc_map = client.fetch_document_map()

    # 削除されたファイルに対応するドキュメントを削除
    del_success, del_fail = delete_removed_documents(client, doc_map)

    # 変更・追加されたファイルを同期
    success, fail = sync_documents(client, doc_dir, doc_map)

    print()
    print(f"=== Sync Complete: Upserted={success}, Deleted={del_success}, Failed={fail + del_fail} ===")

    if fail + del_fail > 0:
        sys.exit(1)

if __name__ == "__main__":
    main()

同期の挙動

実際の同期処理は以下のように動作します。

操作 挙動
ファイル追加 Backlogにドキュメントを新規作成
ファイル更新 既存ドキュメントを削除して再作成
ファイル削除 Backlog上の対応するドキュメントを削除

実行ログはこんな感じです。
以下では削除・更新・新規追加がそれぞれ処理されています。

=== Backlog Document Sync ===
Space: ***.***
Project ID: ***
Document directory: doc

Deleting 1 removed document(s)...
  Deleted: "delete-file" (ID: 019c1e07966d7020b62358fe59777df2)
Mode: diff (2 changed files)
[1/2] modify-file.md -> "modify-file"
  Deleting existing document (ID: 019c1e6cf8967fc49532f9fa8df7feca)
  Created document (ID: 019c1e6db4b87f2aa4b226abbeb7990b)
[2/2] create-file.md -> "create-file"
  Created document (ID: 019c1e6db76d7606b76f5653961fb3d1)

=== Sync Complete: Upserted=2, Deleted=1, Failed=0 ===

注意事項

Backlog APIにドキュメント更新エンドポイントが存在しないため、同名のドキュメントが存在する場合は削除→再作成という方式を取っています。
その結果ドキュメントIDが変わるため、BacklogドキュメントのURLを外部で共有している場合はリンク切れに注意が必要です。

さいごに

以上、GitHub ActionsでリポジトリのドキュメントをBacklogに自動同期してみました。

Backlog APIにドキュメント更新APIがないのは想定外でしたが、
そのうちドキュメント更新APIが追加されることに期待しつつ、それまではこの方式で運用していこうと思います。

リポジトリでドキュメントを管理しつつBacklogにも反映したい、という場面で参考になれば幸いです。

この記事をシェアする

FacebookHatena blogX

関連記事