全体アーキテクチャ
5つのスクリプトがどのように連携し、CI/CDパイプラインに組み込まれるかを図で確認します。
図1 — 5つの Pythonツールと CI/CD パイプラインの関係
スクリプト 目的 CI統合
① check_links.py
Markdownリンクの切れを検出
PR時に必須実行
② validate.py
必須セクションの有無を確認
PR時に必須実行
③ generate.py
テンプレートから手順書を生成
手動実行(新規作成時)
④ collect_near_miss.py
ヒヤリハット記録を集計・レポート
週次スケジュール実行
⑤ diff_report.py
バージョン間の差分を可視化
マージ後に自動実行
手順書内のMarkdownリンク([テキスト](URL)形式)を抽出し、
外部URLは HTTP GETリクエストで疎通確認、
内部ファイルパスはファイルの存在確認を行います。
SCRIPT 01
#!/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/'))
前記事「手順書の8つの要素」で定義した必須セクションが存在するかを確認します。
セクション名は ## セクション名 の形式で検出します。
SCRIPT 02
#!/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/'))
手順書名・対象読者・作成者を引数で受け取り、
ヒヤリハット記録欄付きの手順書テンプレートMarkdownを生成します。
新規手順書作成の際に使用し、毎回テンプレートを書き直す手間を省きます。
SCRIPT 03
#!/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
各手順書のヒヤリハット記録欄をCSV形式で一元管理し、
「件数の多い手順書」「未対応件数」「最近30日の件数」などを集計してレポートを出力します。
SCRIPT 04
#!/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,バックアップ先のディスク残量が手順書未記載,未対応,佐藤
手順書の2バージョン間でどのセクションが変更されたかを
unified diff 形式で出力します。
「前回のレビューからどこが変わったか」を素早く把握できます。
SCRIPT 05
#!/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として起票します。
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時の自動チェック)から始め、チームに定着したら③(テンプレート生成)→④(ヒヤリハット集計)と順番に追加するのが現実的です。