全体アーキテクチャ

5つのスクリプトがどのように連携し、CI/CDパイプラインに組み込まれるかを図で確認します。

Python による手順書自動化ワークフロー
図1 — 5つの Pythonツールと CI/CD パイプラインの関係
スクリプト目的CI統合
① check_links.py Markdownリンクの切れを検出 PR時に必須実行
② validate.py 必須セクションの有無を確認 PR時に必須実行
③ generate.py テンプレートから手順書を生成 手動実行(新規作成時)
④ collect_near_miss.py ヒヤリハット記録を集計・レポート 週次スケジュール実行
⑤ diff_report.py バージョン間の差分を可視化 マージ後に自動実行

① リンク切れチェック(check_links.py)

手順書内のMarkdownリンク([テキスト](URL)形式)を抽出し、 外部URLは HTTP GETリクエストで疎通確認、 内部ファイルパスはファイルの存在確認を行います。

SCRIPT 01
docs/scripts/check_links.py
#!/usr/bin/env python3 """ 手順書のリンク切れチェッカー Usage: python check_links.py docs/operations/ """ import re import sys import urllib.request from pathlib import Path from urllib.error import URLError, HTTPError LINK_PATTERN = re.compile(r'\[([^\]]+)\]\(([^)]+)\)') def check_url(url: str, timeout: int = 5) -> tuple[bool, str]: """外部URLの疎通確認""" try: req = urllib.request.Request(url, method='HEAD', headers={'User-Agent': 'DocLinkChecker/1.0'}) with urllib.request.urlopen(req, timeout=timeout) as resp: return True, f"HTTP {resp.status}" except HTTPError as e: return False, f"HTTP {e.code}" except URLError as e: return False, str(e.reason) except Exception as e: return False, str(e) def check_file(path: str, source_file: Path) -> tuple[bool, str]: """内部ファイルパスの存在確認""" # アンカー付きリンク(#section)は除去 file_path = path.split('#')[0] if not file_path: return True, "anchor only" target = (source_file.parent / file_path).resolve() if target.exists(): return True, "exists" return False, f"not found: {target}" def check_markdown(md_file: Path) -> list[dict]: """Markdownファイル内のリンクをすべてチェック""" errors = [] content = md_file.read_text(encoding='utf-8') for match in LINK_PATTERN.finditer(content): text, href = match.group(1), match.group(2) if href.startswith('http://') or href.startswith('https://'): ok, msg = check_url(href) else: ok, msg = check_file(href, md_file) if not ok: line_num = content[:match.start()].count('\n') + 1 errors.append({ 'file': str(md_file), 'line': line_num, 'text': text, 'href': href, 'error': msg, }) return errors def main(docs_dir: str) -> int: base = Path(docs_dir) all_errors = [] for md_file in sorted(base.rglob('*.md')): errs = check_markdown(md_file) all_errors.extend(errs) if all_errors: print(f"❌ {len(all_errors)} broken link(s) found:\n") for e in all_errors: print(f" {e['file']}:{e['line']}") print(f" [{e['text']}]({e['href']})") print(f" Error: {e['error']}\n") return 1 print(f"✅ All links OK ({base})") return 0 if __name__ == '__main__': sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else 'docs/'))

② 必須セクション検証(validate.py)

前記事「手順書の8つの要素」で定義した必須セクションが存在するかを確認します。 セクション名は ## セクション名 の形式で検出します。

SCRIPT 02
docs/scripts/validate.py
#!/usr/bin/env python3 """ 手順書の必須セクション検証ツール Usage: python validate.py docs/operations/ """ import re import sys from pathlib import Path # 手順書に必須のセクション見出し(H2またはH3) REQUIRED_SECTIONS = [ '目的', '対象読者', '前提条件', '手順', '確認ポイント', 'ロールバック', 'ヒヤリハット', '更新履歴', ] HEADING_PATTERN = re.compile(r'^#{1,3}\s+(.+)$', re.MULTILINE) def validate_file(md_file: Path) -> list[str]: """手順書ファイルの必須セクションを検証""" content = md_file.read_text(encoding='utf-8') headings = [m.group(1).strip() for m in HEADING_PATTERN.finditer(content)] missing = [] for required in REQUIRED_SECTIONS: if not any(required in h for h in headings): missing.append(required) return missing def main(docs_dir: str) -> int: base = Path(docs_dir) all_missing = {} for md_file in sorted(base.rglob('*.md')): # テンプレートファイルはスキップ if 'template' in md_file.name.lower(): continue missing = validate_file(md_file) if missing: all_missing[str(md_file)] = missing if all_missing: print(f"❌ Missing required sections in {len(all_missing)} file(s):\n") for fname, sections in all_missing.items(): print(f" {fname}") for s in sections: print(f" - {s} が見つかりません") print() return 1 print(f"✅ All required sections present ({base})") return 0 if __name__ == '__main__': sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else 'docs/'))

③ テンプレート生成(generate.py)

手順書名・対象読者・作成者を引数で受け取り、 ヒヤリハット記録欄付きの手順書テンプレートMarkdownを生成します。 新規手順書作成の際に使用し、毎回テンプレートを書き直す手間を省きます。

SCRIPT 03
docs/scripts/generate.py
#!/usr/bin/env python3 """ 手順書テンプレート生成ツール Usage: python generate.py --name "デプロイ手順" --author 田中 --audience "バックエンドエンジニア" """ import argparse from datetime import date from pathlib import Path TEMPLATE = """\ # {name} > **バージョン**: 1.0.0 | **作成日**: {today} | **作成者**: {author} ## 目的 - 対象作業: {name} - なぜこの手順書が存在するか: (記載してください) - 実行後の期待状態: (記載してください) ## 対象読者 - 対象: {audience} - 必要な前提知識: (記載してください) ## 前提条件 - [ ] 権限: (必要な権限を記載) - [ ] ツール: (必要なツールとバージョンを記載) - [ ] 事前作業: (事前に完了が必要な作業を記載) - [ ] 承認: (必要な承認者・チケット番号を記載) ## 手順ステップ ### ステップ1: (ステップ名を記載) ```bash # コマンドを記載 ``` **期待する出力**: ``` (期待する出力を記載) ``` **所要時間目安**: (XX分) ## 確認ポイント ### CP-1: (確認内容を記載) ```bash # 確認コマンドを記載 ``` **期待する結果**: (期待する状態を記載) ## ロールバック手順 **ロールバックの判断基準**: (いつロールバックを実行するかを記載) **ロールバック所要時間**: (XX分) ### ロールバック手順1: (内容を記載) ```bash # ロールバックコマンドを記載 ``` ## ヒヤリハット記録 | 日付 | ステップ | 気づいた内容 | 対応 | 担当 | |------|----------|-------------|------|------| | | | | | | ## 更新履歴 | バージョン | 日付 | 更新者 | 変更内容 | |-----------|------|-------|---------| | 1.0.0 | {today} | {author} | 初版作成 | """ def main() -> None: parser = argparse.ArgumentParser(description='手順書テンプレート生成') parser.add_argument('--name', required=True, help='手順書名') parser.add_argument('--author', default='担当者未設定', help='作成者名') parser.add_argument('--audience', default='運用チームメンバー', help='対象読者') parser.add_argument('--output', default=None, help='出力ファイルパス(省略時は標準出力)') args = parser.parse_args() content = TEMPLATE.format( name=args.name, today=date.today().isoformat(), author=args.author, audience=args.audience, ) if args.output: out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(content, encoding='utf-8') print(f"✅ Generated: {out_path}") else: print(content) if __name__ == '__main__': main()

💡 使用例

python docs/scripts/generate.py --name "DBマイグレーション手順" --author 田中 --output docs/operations/db-migration.md

④ ヒヤリハット集計(collect_near_miss.py)

各手順書のヒヤリハット記録欄をCSV形式で一元管理し、 「件数の多い手順書」「未対応件数」「最近30日の件数」などを集計してレポートを出力します。

SCRIPT 04
docs/scripts/collect_near_miss.py
#!/usr/bin/env python3 """ ヒヤリハット記録集計ツール CSV形式のログを読み込み、集計レポートを出力する Usage: python collect_near_miss.py docs/near-miss/log.csv """ import csv import sys from datetime import date, timedelta from collections import defaultdict from pathlib import Path def load_csv(csv_file: Path) -> list[dict]: """CSVファイルを読み込む(ヘッダー: date,procedure,content,status,owner)""" records = [] with open(csv_file, encoding='utf-8', newline='') as f: reader = csv.DictReader(f) for row in reader: records.append(row) return records def generate_report(records: list[dict]) -> str: """集計レポートを生成する""" total = len(records) unresolved = [r for r in records if r.get('status', '').strip() in ('', '未対応')] # 手順書別集計 by_procedure = defaultdict(int) for r in records: by_procedure[r.get('procedure', '不明')] += 1 # 直近30日 cutoff = date.today() - timedelta(days=30) recent = [r for r in records if r.get('date', '') >= cutoff.isoformat()] lines = [ "=" * 60, "ヒヤリハット集計レポート", f"集計日: {date.today().isoformat()}", "=" * 60, "", f"【総件数】 {total} 件", f"【未対応】 {len(unresolved)} 件", f"【直近30日】 {len(recent)} 件", "", "【手順書別件数】", ] for proc, cnt in sorted(by_procedure.items(), key=lambda x: -x[1]): lines.append(f" {cnt:3d}件 {proc}") if unresolved: lines += [ "", "【未対応リスト】", ] for r in unresolved: lines.append( f" {r.get('date','')} | {r.get('procedure','')} | " f"{r.get('content','')[:40]} | 担当: {r.get('owner','')}" ) lines += ["", "=" * 60] return "\n".join(lines) def main(csv_file: str) -> int: path = Path(csv_file) if not path.exists(): print(f"❌ File not found: {path}", file=sys.stderr) return 1 records = load_csv(path) print(generate_report(records)) return 0 if __name__ == '__main__': sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else 'docs/near-miss/log.csv'))

ヒヤリハット記録CSVの形式例:

date,procedure,content,status,owner 2026-05-10,deploy.md,psqlの接続タイムアウトが発生,未対応,鈴木 2026-06-01,db-migration.md,DB名の命名規則が手順書と異なっていた,対応済,田中 2026-06-15,backup.md,バックアップ先のディスク残量が手順書未記載,未対応,佐藤

⑤ 差分レポート(diff_report.py)

手順書の2バージョン間でどのセクションが変更されたかを unified diff 形式で出力します。 「前回のレビューからどこが変わったか」を素早く把握できます。

SCRIPT 05
docs/scripts/diff_report.py
#!/usr/bin/env python3 """ 手順書差分レポートツール 2つのファイルまたはGitリビジョン間の差分をセクション単位で可視化する Usage: python diff_report.py docs/operations/deploy.md HEAD~1 """ import subprocess import sys import difflib from pathlib import Path def get_git_content(file_path: str, revision: str) -> str: """Gitの特定リビジョンのファイル内容を取得""" result = subprocess.run( ['git', 'show', f'{revision}:{file_path}'], capture_output=True, text=True, encoding='utf-8' ) if result.returncode != 0: raise ValueError(f"Git error: {result.stderr}") return result.stdout def split_sections(content: str) -> dict[str, list[str]]: """Markdownをセクション単位に分割""" sections: dict[str, list[str]] = {} current_section = '__header__' current_lines: list[str] = [] for line in content.splitlines(keepends=True): if line.startswith('## '): if current_lines: sections[current_section] = current_lines current_section = line.strip() current_lines = [line] else: current_lines.append(line) if current_lines: sections[current_section] = current_lines return sections def diff_report(file_path: str, old_rev: str = 'HEAD~1', new_rev: str = 'HEAD') -> str: """セクション単位の差分レポートを生成""" try: old_content = get_git_content(file_path, old_rev) new_content = get_git_content(file_path, new_rev) except ValueError as e: return f"Error: {e}" old_sections = split_sections(old_content) new_sections = split_sections(new_content) all_keys = sorted(set(old_sections) | set(new_sections)) report_lines = [ f"差分レポート: {file_path}", f"比較: {old_rev} → {new_rev}", "=" * 60, "", ] changed = 0 for key in all_keys: old_lines = old_sections.get(key, []) new_lines = new_sections.get(key, []) if old_lines == new_lines: continue changed += 1 diff = list(difflib.unified_diff( old_lines, new_lines, fromfile=f'{old_rev}/{key}', tofile=f'{new_rev}/{key}', lineterm='', )) report_lines.append(f"[変更] {key}") report_lines.extend(diff) report_lines.append("") if changed == 0: report_lines.append("変更なし") else: report_lines.insert(3, f"変更されたセクション: {changed} 件") return "\n".join(report_lines) def main() -> int: if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} [old_rev] [new_rev]") return 1 file_path = sys.argv[1] old_rev = sys.argv[2] if len(sys.argv) > 2 else 'HEAD~1' new_rev = sys.argv[3] if len(sys.argv) > 3 else 'HEAD' print(diff_report(file_path, old_rev, new_rev)) return 0 if __name__ == '__main__': sys.exit(main())

GitHub Actions への組み込み

①リンクチェックと②セクション検証をPull Requestのたびに自動実行します。 ④ヒヤリハット集計は毎週月曜日に自動実行してIssueとして起票します。

.github/workflows/docs-quality.yml
name: Docs Quality Check on: pull_request: paths: ['docs/**/*.md'] schedule: # 毎週月曜 9:00 JST(= UTC 0:00)にヒヤリハット集計 - cron: '0 0 * * 1' jobs: # PR時: リンクチェック + セクション検証 validate: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: { python-version: '3.12' } - name: 必須セクション検証 run: python docs/scripts/validate.py docs/operations/ - name: リンク切れチェック run: python docs/scripts/check_links.py docs/operations/ # 週次: ヒヤリハット集計 → Issue 起票 near_miss_report: if: github.event_name == 'schedule' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: { python-version: '3.12' } - name: ヒヤリハット集計 id: report run: | python docs/scripts/collect_near_miss.py docs/near-miss/log.csv > report.txt echo "body=$(cat report.txt)" >> $GITHUB_OUTPUT - name: Issue 起票 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh issue create \ --title "週次ヒヤリハット集計レポート $(date +%Y-%m-%d)" \ --body "$(cat report.txt)" \ --label "near-miss,docs"

まとめ

5つのPythonスクリプトと GitHub Actions の組み合わせにより、 手順書の品質チェックを自動化できます。

スクリプト自動化の効果
① リンク切れチェック 参照先が消えても即座に検出。手順書の信頼性が維持される
② セクション検証 必須要素の欠落をレビュー前に発見。レビューの負担が軽減
③ テンプレート生成 新規手順書が8要素を含んだ状態で始まる。フォーマットの揺れがなくなる
④ ヒヤリハット集計 未対応のヒヤリハットが見える化される。放置を防止
⑤ 差分レポート 「前回から何が変わったか」が即座にわかる。レビューの効率が上がる

段階的に導入する

5つのスクリプトをすべて一度に導入する必要はありません。最も効果が高い①②(PR時の自動チェック)から始め、チームに定着したら③(テンプレート生成)→④(ヒヤリハット集計)と順番に追加するのが現実的です。