I tried automatic synchronization of Backlog documents with GitHub Actions
This page has been translated by machine translation. View original
Introduction
Hello everyone, I'm Akaike.
Recently, document add/delete APIs were added to Backlog's documentation.
As one way to utilize this update, I tried automatically synchronizing documents from a repository to Backlog using GitHub Actions.
I think this could be useful when you want to manage documents in a repository while automatically reflecting them in Backlog for internal sharing.
The code for this article is stored in the template repository below, so please feel free to use it.
Overview
Here's an overview of the system I created:
- Document files are placed in the
./doc/directory of the repository - When pushed to the
mainbranch, only files with changes are synchronized to Backlog documents - Supports all file operations: add/update/delete
- Also supports manual execution for bulk synchronization of all files
File Structure
.github/workflows/sync-backlog-documents.yml # Workflow definition
scripts/sync-backlog-documents.py # Synchronization script (Python)
requirements.txt # Python dependencies
doc/ # Target documents for synchronization
About Backlog API
First, let's review the Backlog document-related APIs.
| Operation | Method | Endpoint |
|---|---|---|
| Get document list | GET | /api/v2/documents |
| Add document | POST | /api/v2/documents |
| Get document information | GET | /api/v2/documents/:documentId |
| Get document tree | GET | /api/v2/documents/tree |
| Get document attachments | GET | /api/v2/documents/:documentId/attachments |
| Delete document | DELETE | /api/v2/documents/:documentId |
It's important to note that there is no API to update documents (as of 2026/2/2).
Therefore, to update an existing document, we need to "delete and recreate" it.
Authentication Method
Backlog API uses an API key for authentication.
Implementation is simple as you just add the apiKey query parameter to the request.
https://{spaceId}.backlog.jp/api/v2/documents?apiKey={apiKey}
Sensitive information such as API keys are stored in GitHub repository Secrets and referenced as environment variables from the workflow.
| Secret Name | Content |
|---|---|
BACKLOG_API_KEY |
Backlog API key |
BACKLOG_SPACE_ID |
Backlog space name |
BACKLOG_PROJECT_ID |
Backlog project ID (numeric) |
BACKLOG_DOMAIN |
Backlog domain (backlog.jp etc.) |
Implementation
Workflow Definition
Here's the overall workflow:
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
Let me explain some key points:
Detecting Changed Files
Detecting file changes in the doc/ directory is a crucial part of this system.
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 2
First, I specify fetch-depth: 2 to retrieve the previous commit as well.
With the default fetch-depth: 1 (shallow clone), the github.event.before commit wouldn't exist, causing git diff to fail.
Initially, I didn't specify fetch-depth and encountered a fatal: bad object error.
Next, I run git diff --name-status once to classify files by status (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): Synchronization targetsD(Deleted): Targets for document deletion on Backlog
I've included a fallback comparison with HEAD~1 in case github.event.before can't be retrieved.
While using tj-actions/changed-files is common for getting changed files, it was affected by a supply chain attack (CVE-2025-30066) in March 2025.
For our purpose, git diff is sufficient, so I adopted a method with no external dependencies.
Target Files by Trigger
By excluding unchanged files from synchronization, we prevent unnecessary document recreation (= ID changes):
| Trigger | Target Files |
|---|---|
push (changes in doc/) |
Only changed files |
workflow_dispatch (manual) |
All files in doc/ |
Synchronization Script (Python)
The synchronization process is implemented with Python + requests.
I chose Python over shell scripts with curl because JSON processing and error handling would be cumbersome in shell.
"""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()
Synchronization Behavior
The actual synchronization process operates as follows:
| Operation | Behavior |
|---|---|
| File addition | Creates a new document in Backlog |
| File update | Deletes the existing document and recreates it |
| File deletion | Deletes the corresponding document in Backlog |
The execution log looks like this.
In the example below, deletion, update, and new addition are each processed:
=== 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 ===
Important Notes
Since there's no document update endpoint in the Backlog API, we use a delete-then-create approach when a document with the same name exists.
As a result, the document ID changes, so be careful about link breakage if you're sharing Backlog document URLs externally.
Conclusion
So that's how I set up automatic synchronization of repository documents to Backlog using GitHub Actions.
I was surprised to find that the Backlog API doesn't have a document update API, but I hope they'll add one soon.
Until then, I'll continue using this approach.
I hope this is helpful for those who want to manage documents in a repository while also reflecting them in Backlog.