I tried automatic synchronization of Backlog documents with GitHub Actions

I tried automatic synchronization of Backlog documents with GitHub Actions

2026.02.02

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.

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

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.

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

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 main branch, 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.

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

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:

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

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 targets
  • D (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.

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

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-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()

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.

Share this article

FacebookHatena blogX

Related articles