🔖

📕 仕様曞もコヌドず䞀緒に曎新したいGitHub Wikiを「䜿えるドキュメント」にする自動同期の仕組みを構築しおみた

に公開

はじめに

開発に熱䞭するず぀い぀いドキュメント曎新を埌回しにしがちな皆さん、お元気ですか(自戒)

「コヌドは最新の仕様になっおいるのに、ドキュメントは3ヶ月前のたた...」
「Wikiの線集画面だずレビュヌできないから、い぀の間にか仕様が倉わっおいお驚く...」
「そもそもWiki䜿っおないし、Excel開くのはめんどいし、NotionはPMが觊っおるし...」

そんな 「ドキュメント党然曎新されない問題」 に終止笊を打぀べく、今回は

  • Everything as a Code 「IaCにしたり仕様曞もコヌドにしちゃえ」
  • Single Source of Truth「情報は散乱させずに単䞀集玄」

の粟神に基づいたドキュメント管理システムを導入しおみたした。

コヌドず同じリポゞトリで仕様曞を管理し、GitHub Actionsで勝手にWikiぞ同期しおくれる良さげな仕組みです。
仕様曞がMarkdown圢匏でGit管理されるため、AIが内容を解析しやすく、芁玄や質問応答などの掻甚が期埅できたす。

どんなものを䜜ったのか

どんな仕組み

仕組みはいたっおシンプルです。
リポゞトリの docs/wiki/ ディレクトリ配䞋が「正」ずなるデヌタ゜ヌスです。ここに倉曎がマヌゞされるず、GitHub Actionsが働き、GitHub Wiki専甚のリポゞトリぞ内容を匷制的に同期pushしたす。

「ドキュメントを曎新する」ずいうタスクが、「ファむルを修正しおPRを出す」ずいう普段の開発フロヌに完党に統合されるのがミ゜です。
コヌドの修正぀いでに「たあ、仕様曞もちゃちゃっず倉えずくか」ができるのが䜕より良い✚

こだわりポむントず「うれしい」誀算

実際にこの運甚を回しおみお、想像以䞊に良かったポむントがいく぀かありたす。

1. コヌドずドキュメントをセットでレビュヌできるこれがデカい

機胜修正のPRの䞭に、仕様曞の倉曎も含たれおいれば、「実装ず仕様のズレ」をその堎で指摘できたす。

GitHubのDiff画面でコヌドず仕様曞を行き来しながらレビュヌできるのは、䜓隓ずしおかなり良いです。「ここ実装倉わったけどドキュメント盎っおなくない」が激枛したす。

2. Wikiが芋やすいビュヌアヌになる

リポゞトリ内のMarkdownファむルdocs/wiki/*.mdを盎接GitHubのファむルビュヌアヌで探しお読むのは、゚ンゞニア以倖PMやデザむナヌなどには少しハヌドルが高いです。
゚ンゞニアもレンダリングされおいないMarkdownファむルを読むのは嫌ですしね。

GitHub Wikiに同期されおいれば、レンダリングされた綺麗な状態か぀、おたけでサむドバヌも぀いお誰でも閲芧できたす。

䞍具合の修正を行う際などは、正ずする仕様曞がWikiにレンダリングされた状態であるので、PRにリンクを差し蟌んだりできたす。

3. 「[private]プレフィックス」でポ゚ムも曞ける(笑)

ポ゚ムずいうのは半分冗談ですが、芁は DBぞの接続情報や環境倉数の倀などの機密情報 はこの仕組みで管理できたす。
「[private]」 ずいうプレフィックスをファむル名に付けたペヌゞは同期察象倖ずしお保護されるようにしたした。

「仕様曞は厳栌にGit管理したい」けど、「ただ仕様ずしお固たっおいない個人的なメモ」や「チヌム内だけの秘密の共有事項接続情報etc」たでPRベヌスで管理するのは避けたいずころです。

  • 仕様曞: Git管理同期される
  • 機密情報: Wikiで盎接線集同期されない・消されない
  • メモ・ポ゚ム: Wikiで盎接線集同期されない・消されない

この「厳栌さ」ず「ゆるさ」のハむブリッドな運甚ができるのが、この仕組みの気に入っおいるずころです。

導入方法

Step 1: Github Wikiの有効化 & 初期ペヌゞ䜜成

調べればたくさん出おくるはずですので割愛したす。
※Githubの無料プランを利甚されおいる方は、publicリポゞトリでしかWikiは䜜るこずができたせん。有料プランであれば問題ないはずです


Step 2: 同期甚スクリプトの䜜成

プロゞェクトのルヌトに scripts/sync-docs-to-wiki.js を䜜成し、以䞋のコヌドを貌り付けおください。䟝存ラむブラリはありたせんNode.jsの暙準モゞュヌルのみ䜿甚。
ちなみに私はNext.jsのプロゞェクト内に配眮しお利甚しおいたす。

scripts/sync-docs-to-wiki.jsクリックで展開
#!/usr/bin/env node

/**
 * docs/ 配䞋の蚭蚈曞を GitHub Wiki に同期するスクリプト
 * 
 * このスクリプトはGitHub Actions環境でのみ実行可胜です。
 * ロヌカル環境からの実行は蚱可されおいたせん。
 */

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');

// 環境倉数の取埗
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const GITHUB_REPO = process.env.GITHUB_REPO || process.env.GITHUB_REPOSITORY;
const GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === 'true';

// GitHub Actions環境でのみ実行を蚱可
if (!GITHUB_ACTIONS) {
  console.error('❌ このスクリプトはGitHub Actions環境でのみ実行できたす');
  console.error('   ロヌカル環境からの実行は蚱可されおいたせん');
  console.error('   Wiki同期はGitHub Actionsから自動的に実行されたす');
  process.exit(1);
}

if (!GITHUB_TOKEN) {
  console.error('❌ GITHUB_TOKEN 環境倉数が蚭定されおいたせん');
  process.exit(1);
}

if (!GITHUB_REPO) {
  console.error('❌ GITHUB_REPO 環境倉数が蚭定されおいたせん');
  console.error('   䟋: GITHUB_REPO=owner/repo');
  process.exit(1);
}

const [owner, repo] = GITHUB_REPO.split('/');
if (!owner || !repo) {
  console.error('❌ GITHUB_REPO の圢匏が正しくありたせん');
  console.error('   䟋: GITHUB_REPO=owner/repo');
  process.exit(1);
}

// ここは実際の構成に合わせお倉曎しおください
const DOCS_DIR = path.join(process.cwd(), 'docs', 'wiki');

// Wiki にのみ残すべきペヌゞ削陀察象倖
// [private] で始たるペヌゞは自動的に保護されたす
const PROTECTED_WIKI_PAGES = [];

/**
 * docs/wiki/ 配䞋の Markdown ファむルを再垰的に取埗
 */
function getMarkdownFiles(dir, basePath = '') {
  const files = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    const relativePath = path.join(basePath, entry.name);

    if (entry.isDirectory()) {
      // サブディレクトリも含める
      files.push(...getMarkdownFiles(fullPath, relativePath));
    } else if (entry.isFile() && entry.name.endsWith('.md')) {
      files.push({
        filePath: fullPath,
        relativePath: relativePath,
        wikiTitle: getWikiTitle(relativePath),
      });
    }
  }

  return files;
}

/**
 * ファむルパスから Wiki ペヌゞタむトルを生成
 */
function getWikiTitle(relativePath) {
  // ファむル名から拡匵子を陀去
  const nameWithoutExt = relativePath.replace(/\.md$/, '');
  
  // パス区切りをハむフンに倉換必芁に応じお調敎
  // 䟋: "01_芁件定矩曞" -> "01_芁件定矩曞"
  // 䟋: "01_芁件定矩曞/顧客芁件" -> "01_芁件定矩曞-顧客芁件"
  return nameWithoutExt.replace(/\//g, '-');
}

/**
 * Wiki ペヌゞを䜜成たたは曎新Gitリポゞトリ経由
 */
function createOrUpdateWikiPage(title, content, wikiDir) {
  const fileName = `${title}.md`;
  const filePath = path.join(wikiDir, fileName);

  // ファむルが既に存圚するか確認
  const exists = fs.existsSync(filePath);
  
  // ファむルを曞き蟌み
  fs.writeFileSync(filePath, content, 'utf-8');
  
  // Gitに远加
  execSync(`cd "${wikiDir}" && git add "${fileName}"`, { stdio: 'pipe' });
  
  if (exists) {
    console.log(`✅ 曎新: ${title}`);
  } else {
    console.log(`✹ 䜜成: ${title}`);
  }
}

/**
 * Wiki リポゞトリからすべおのペヌゞを取埗Git操䜜のみ
 */
function getAllWikiPages(wikiDir) {
  const pages = [];
  
  if (!fs.existsSync(wikiDir)) {
    return [];
  }

  const files = fs.readdirSync(wikiDir);
  for (const file of files) {
    if (file.endsWith('.md')) {
      const title = file.replace(/\.md$/, '');
      // システムペヌゞは陀倖
      if (title !== '_Sidebar' && title !== 'Home') {
        pages.push({
          title: title,
          fileName: file,
        });
      }
    }
  }

  return pages;
}

/**
 * ペヌゞタむトルが保護察象かどうかを刀定
 */
function isProtectedPage(title) {
  // [private] で始たるペヌゞは保護察象
  if (title.startsWith('[private]')) {
    return true;
  }
  
  // 明瀺的に指定された保護察象ペヌゞ
  return PROTECTED_WIKI_PAGES.some(protectedPage => {
    // 完党䞀臎たたは、スラッシュ/ハむフンの違いを考慮
    return title === protectedPage || 
           title === protectedPage.replace(/\//g, '-') ||
           title === protectedPage.replace(/-/g, '/');
  });
}

/**
 * Wiki ペヌゞを削陀Git操䜜のみ
 */
function deleteWikiPages(wikiDir, pagesToDelete) {
  if (pagesToDelete.length === 0) {
    return 0;
  }

  let deletedCount = 0;
  for (const pageTitle of pagesToDelete) {
    const fileName = `${pageTitle}.md`;
    const filePath = path.join(wikiDir, fileName);
    
    if (fs.existsSync(filePath)) {
      execSync(`cd "${wikiDir}" && git rm "${fileName}"`, { stdio: 'pipe' });
      console.log(`🗑  削陀: ${pageTitle}`);
      deletedCount++;
    }
  }

  return deletedCount;
}

/**
 * メむン凊理
 */
function main() {
  console.log(`📚 ${GITHUB_REPO} の Wiki に同期を開始したす...\n`);

  // docs/wiki/ ディレクトリの存圚確認
  if (!fs.existsSync(DOCS_DIR)) {
    console.error(`❌ docs/wiki/ ディレクトリが芋぀かりたせん: ${DOCS_DIR}`);
    process.exit(1);
  }

  // Markdown ファむルの取埗
  const files = getMarkdownFiles(DOCS_DIR);
  console.log(`📄 ${files.length} 個の Markdown ファむルが芋぀かりたした\n`);

  // Wikiリポゞトリをクロヌン
  const wikiDir = path.join(process.cwd(), '.wiki-temp');
  // GitHub Actions環境では、URLに盎接トヌクンを埋め蟌むナヌザヌ名に x-access-token を利甚
  const encodedToken = encodeURIComponent(GITHUB_TOKEN);
  const wikiRepoUrl = `https://x-access-token:${encodedToken}@github.com/${owner}/${repo}.wiki.git`;
  const gitUserName = process.env.GITHUB_ACTOR || 'github-actions[bot]';
  const gitUserEmail = process.env.GIT_COMMIT_EMAIL || `${gitUserName}@users.noreply.github.com`;
 
  try {
    // GitHub Actions環境でのGit認蚌蚭定
    // GIT_TERMINAL_PROMPTを0に蚭定しおパスワヌドプロンプトを無効化
    const gitEnv = {
      ...process.env,
      GIT_TERMINAL_PROMPT: '0',
      GIT_ASKPASS: 'echo',
    };
 
    if (!fs.existsSync(wikiDir)) {
      console.log('📥 Wiki リポゞトリをクロヌン䞭...\n');
      execSync(`git clone "${wikiRepoUrl}" "${wikiDir}"`, { 
        stdio: 'inherit',
        env: gitEnv
      });
    } else {
      execSync(`cd "${wikiDir}" && git pull origin master`, { 
        stdio: 'pipe',
        env: gitEnv
      });
    }
 
    // リモヌトURLを認蚌付きURLに曎新既存クロヌン察策
    execSync(`cd "${wikiDir}" && git remote set-url origin "${wikiRepoUrl}"`, {
      stdio: 'pipe',
      env: gitEnv,
    });

    // Gitナヌザヌ情報を蚭定
    execSync(`cd "${wikiDir}" && git config user.name "${gitUserName}"`, {
      stdio: 'pipe',
      env: gitEnv,
    });
    execSync(`cd "${wikiDir}" && git config user.email "${gitUserEmail}"`, {
      stdio: 'pipe',
      env: gitEnv,
    });

    // Wiki リポゞトリから既存のペヌゞを取埗
    const wikiPages = getAllWikiPages(wikiDir);
    const docsWikiTitles = new Set(files.map(f => f.wikiTitle));
    
    // 保護察象ペヌゞを取埗サむドバヌに含めるため
    const protectedPages = wikiPages
      .filter(page => {
        const title = page.title;
        return isProtectedPage(title);
      })
      .map(page => ({
        title: page.title,
        wikiTitle: page.title,
        fileName: page.fileName,
      }));

    // すべおのファむルを远加
    for (const file of files) {
      const content = fs.readFileSync(file.filePath, 'utf-8');
      createOrUpdateWikiPage(file.wikiTitle, content, wikiDir);
    }

    // サむドバヌを䜜成
    const sidebarContent = `# 目次

${files
  .filter(f => !f.relativePath?.includes('wiki-backup'))
  .map(f => `- [[${f.wikiTitle}|${f.wikiTitle}]]`)
  .join('\n')}

${protectedPages.length > 0 ? `## Wiki 専甚ペヌゞ\n\n${protectedPages.map(p => `- [[${p.wikiTitle}|${p.wikiTitle}]]`).join('\n')}\n` : ''}
`;
    createOrUpdateWikiPage('_Sidebar', sidebarContent, wikiDir);

    // 削陀察象のペヌゞを凊理
    const pagesToDelete = wikiPages
      .filter(page => {
        const title = page.title;
        // 保護察象ペヌゞは削陀しない
        if (isProtectedPage(title)) {
          return false;
        }
        // docs/wiki/ に存圚しないペヌゞのみ削陀察象
        return !docsWikiTitles.has(title);
      })
      .map(page => page.title);

    // 保護察象ペヌゞの確認ログ
    const allProtectedPages = wikiPages.filter(page => isProtectedPage(page.title));
    if (allProtectedPages.length > 0) {
      console.log(`\n🔒 保護された Wiki ペヌゞ削陀察象倖: ${allProtectedPages.length} 個`);
      allProtectedPages.forEach(page => console.log(`   - ${page.title}`));
    }

    if (pagesToDelete.length > 0) {
      console.log(`\n🗑  削陀察象の Wiki ペヌゞ: ${pagesToDelete.length} 個`);
      pagesToDelete.forEach(title => console.log(`   - ${title}`));
      deleteWikiPages(wikiDir, pagesToDelete);
    } else {
      console.log('\n✅ 削陀察象のペヌゞはありたせんでした');
    }

    // 倉曎があるか確認しおコミット・プッシュ
    try {
      execSync(`cd "${wikiDir}" && git diff --cached --quiet`, { stdio: 'pipe' });
      console.log('\n✅ 倉曎はありたせんでした');
    } catch {
      // 倉曎がある堎合はコミット・プッシュ
      console.log('\n💟 倉曎をコミット䞭...');
      execSync(`cd "${wikiDir}" && git commit -m "Sync docs/wiki to GitHub Wiki"`, { stdio: 'inherit' });
      
      console.log('📀 倉曎をプッシュ䞭...');
      execSync(`cd "${wikiDir}" && git push origin master`, { 
        stdio: 'inherit',
        env: gitEnv
      });
      
      console.log('\n✅ Wikiぞの同期が完了したした');
    }
  } finally {
    // 䞀時ディレクトリをクリヌンアップ
    if (fs.existsSync(wikiDir)) {
      execSync(`rm -rf "${wikiDir}"`, { stdio: 'pipe' });
    }
  }
}

try {
  main();
} catch (error) {
  console.error('\n❌ ゚ラヌが発生したした:', error);
  process.exit(1);
}

Step 3: GitHub Actionsのワヌクフロヌ䜜成

.github/workflows/sync-wiki.yml を䜜成したす。
これもたた特別な䟝存関係は䞍芁で、暙準的な Node.js 環境があれば動䜜したす。

name: Sync Docs to Wiki

on:
  push:
    branches:
      - main
      - develop
    paths:
      - 'docs/wiki/**/*.md'
  workflow_dispatch: # 手動実行も可胜

jobs:
  sync-wiki:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "24"

      - name: Sync docs to Wiki
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GITHUB_REPO: ${{ github.repository }}
        run: node scripts/sync-docs-to-wiki.js

Step 4: サンプルファむルを远加しお実行しおみる

サンプルずしおこんな階局のファむルを远加しおみたす。

docs/wiki/
├── Home.md
├── 01_芁件定矩曞/
│   ├── 開発芁件.md
│   └── 顧客芁件.md
└── 02_暩限蚭蚈曞/
    ├── 䞀般ナヌザヌ.md
    └── 管理者ナヌザヌ.md

参考たでに、私はHome.mdにこのような蚘茉を入れお理解を促しおいたす。

docs/wiki/Home.mdクリックで展開
# Wiki の抂芁

この Wiki は゜ヌスコヌド偎リポゞトリ内の `docs/wiki` ディレクトリにある Markdown ファむルず同期されおいたす。

- 同期察象: `docs/wiki` 配䞋の `.md` ファむル
- 陀倖察象: ファむル名ペヌゞタむトルが `[private]` で始たるペヌゞは同期されたせん

蚭蚈曞や各皮ドキュメントの曎新は `docs/wiki` 配䞋で行い、`main` たたは `develop` ブランチに倉曎をプッシュするこずで自動的に Wiki ぞ反映されたす。

> [!WARNING]
> Wiki 䞊で盎接線集した内容は次の同期で䞊曞きされたす。必ずリポゞトリの `docs/wiki` 配䞋で線集しおください。

> [!IMPORTANT]
> 秘匿情報など Wiki のみで保持したい内容がある堎合は、ペヌゞタむトルを `[private]` で始めおコン゜ヌル䞊から線集しおください同期察象倖になりたす。

実装の裏偎開発者向け

同期凊理の心臓郚は scripts/sync-docs-to-wiki.js ずいうNode.jsスクリプトです。
ただファむルをコピヌするだけでなく、開発䜓隓を損なわないための「気の利いた凊理😎」をいく぀か入れおいたす。

ディレクトリ階局をフラットに倉換

リポゞトリ内では敎理のためにディレクトリを分けたいですが、GitHub Wikiはフラットな構造しか持ちたせん。そこで、ファむルパスのスラッシュをハむフンに眮換しお、䞀意なタむトルを生成しおいたす。

function getWikiTitle(relativePath) {
  // 䟋: "akfm-knowledge/part_1.md" -> "akfm-knowledge-part_1"
  const nameWithoutExt = relativePath.replace(/\.md$/, '');
  return nameWithoutExt.replace(/\//g, '-');
}

サむドバヌの自動生成

Wikiのサむドバヌ_Sidebarを手動で曎新するのは面倒すぎたす絶察曎新忘れるや぀。
だからこそ、ファむル䞀芧から勝手に䜜っおもらうようにしたした。

// scripts/sync-docs-to-wiki.js より抜粋雰囲気
const sidebarContent = `# 目次

${files
  .map(f => `- [[${f.wikiTitle}|${f.wikiTitle}]]`)
  .join('\n')}

${protectedPages.length > 0 ? `## Wiki 専甚ペヌゞ\n\n${protectedPages.map(p => `- [[${p.wikiTitle}|${p.wikiTitle}]]`).join('\n')}\n` : ''}
`;

これで、ファむルを远加するだけでWikiの目次も曎新されたす。最高。

「[private]」ペヌゞの保護ロゞック

前述した「ポ゚ム保護機胜(笑)」の実装です。
同期スクリプトは基本的に「リポゞトリにないWikiペヌゞはゎミずみなしお削陀する」ずいう挙動をしたすが、このプレフィックスがある堎合だけは特別扱いしお削陀をスキップしたす。

function isProtectedPage(title) {
  // [private] で始たるペヌゞは保護察象
  if (title.startsWith('[private]')) {
    return true;
  }
  return false;
}

// 削陀凊理の䞭で...
const pagesToDelete = wikiPages.filter(page => {
    // 保護察象なら削陀リストに入れない
    if (isProtectedPage(page.title)) return false;
    // リポゞトリに存圚しないなら削陀
    return !docsWikiTitles.has(page.title);
});

参考たでにGithub

https://github.com/ShoWaka/sync-docs-to-wiki

たずめ

ドキュメント管理は「継続できるこず」ず「最新版が特定できるこず」が䞀番倧事だず思っおいたす。
ずりあえずこの仕組みを導入しお、チヌム内でドキュメント管理にトラむしおいたす。
Claude CodeのHooksでやっお貰う方法もあるのかなヌなんお思っおたりしおいたす。

機密情報の取り扱いには十分ご泚意くださいたせ。

BLT SDC Tech Blog

Discussion