GitHub ActionsでBacklogドキュメントを自動同期してみた
はじめに
皆様こんにちは、あかいけです。
先日、Backlogのドキュメントに追加/削除APIが追加されましたね。
このアップデートの活用方法の一つとして、GitHub Actionsでリポジトリ内のドキュメントをBacklogに自動同期してみました。
社内で共有したい資料をリポジトリで管理しつつ、Backlogにも自動で反映したい時などに使えるんじゃないかと思います。
本記事のコードは以下テンプレートリポジトリに格納しているので、ご自由にご利用ください。
概要
今回作った仕組みの全体像はこんな感じです。
- リポジトリの
./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時点)
そのため、既存ドキュメントを更新したい場合は「削除してから再作成」という方式を取る必要があります。
認証方式
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等) |
実装
ワークフローの定義
ワークフローの全体像です。
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で十分なので、外部依存のない方式を採用しました。
トリガーごとの対象ファイル
変更のないファイルは同期対象外とすることで、不要なドキュメントの再作成(= IDの変更)を防いでいます。
| トリガー | 対象ファイル |
|---|---|
push(doc/配下の変更時) |
変更されたファイルのみ |
workflow_dispatch(手動) |
doc/配下の全ファイル |
同期スクリプト(Python)
同期処理はPython + requestsで実装しました。
シェルスクリプトでcurlを使う方式だとJSON処理やエラーハンドリングが煩雑になるため、Pythonを選択しています。
"""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にも反映したい、という場面で参考になれば幸いです。






