はじめに
GMOコネクトの石井です。
Confluenceで設計書を管理していると、こんな悩みが出てきます。
- 変更履歴がConfluenceのページ履歴頼みで、差分が追いにくい
- 複数人で同時に編集すると競合する
- レビューの仕組みがない(誰がいつ何を変えたのかわかりにくい)
じゃあGitで管理しよう、となるのですが、今度は別の問題が出ます。Gitで管理しているMarkdownをConfluenceに反映する作業が手動になるんですよね。ページを開いて、タイトルを設定して、親ページを選んで、Markdownを貼って、draw.ioを添付して…。それが数十ページ。しかも更新のたびに。
「Gitで書いてConfluenceに同期する」を自動化して、約6時間→約7分に減らした話をします。
先にまとめ
| 項目 | Before(手動) | After(自動化) |
|---|---|---|
| 概要ページ(20件) | 1ページ5分 × 20 = 100分 | 約2分 |
| 処理設計ページ(90件) | 1ページ3分 × 90 = 270分 | 約5分 |
| draw.io添付(9件) | 手動で添付 | 自動 |
| 合計 | 約6時間 | 約7分 |
やったことはシンプルで、以下の3つです。
- Markdownファイルの先頭にHTMLコメントでConfluence用メタデータを埋め込む
- シェルスクリプトで mark + Confluence REST API を使って一括アップロード
- draw.ioマクロの挿入とファイル添付も自動化
全体アーキテクチャ
GitHub上のMarkdown
│
├── 概要/ (20ファイル)
│ └── drawio/ (9ファイル)
└── 処理設計/ (16フォルダ, 90ファイル)
│
▼
┌─────────────────────────┐
│ add_confluence_meta.sh │ ← H1見出しからメタコメントを自動生成
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ upload_confluence.sh │ ← markコマンド + REST API
│ ┌───────────────────┐ │
│ │ drawioマクロ挿入 │ │ ← 一時ファイルに書き出し、元MDは汚さない
│ │ markでアップロード│ │
│ │ REST APIで添付 │ │
│ └───────────────────┘ │
└─────────────────────────┘
│
▼
Confluence (Space: MYPROJECT)
└── 親ページ
├── ap_001:概要ページ (drawio添付済み)
│ ├── 初期処理
│ ├── 入力画面表示
│ └── 入力画面サブミット
└── ap_002:概要ページ
└── ...
2つのスクリプトで役割を分けています。add_confluence_meta.sh がMarkdownにメタデータを付与し、upload_confluence.sh がアップロードとdraw.io添付を担当します。
markツールについて
Confluenceへのアップロードには mark を使っています。MarkdownファイルをConfluenceのページとして直接アップロードできるCLIツールです。
brew install kovetskiy/mark/mark
markはファイル先頭のHTMLコメントでアップロード先を決めます。ここに乗っかる形でメタデータ管理を設計しました。
HTMLコメントによるメタデータ埋め込み
なぜHTMLコメントか
メタデータの埋め込み方法として、YAMLフロントマター(---で囲む形式)も検討しましたが、HTMLコメント形式にしました。
- markの仕様に合致: markが読み取るメタデータ形式がHTMLコメント
- Markdownの可読性を維持: GitHubやエディタでプレビューしても表示されない
- 既存のドキュメントに影響しない: ファイル先頭に2行追加するだけ
<!-- Space: MYPROJECT -->
<!-- Title: ap_001:ユーザー入力フォーム -->
# ap_001:ユーザー入力フォーム
### 概要
| 項目 | 内容 |
| --- | --- |
| ワークフローID | ap_001 |
...
Space でConfluenceのスペースキーを、Title でページタイトルを指定します。
add_confluence_meta.sh:メタコメントの自動挿入
概要ページは手動でメタコメントを付けましたが、処理設計は90ファイルもあるので自動化しました。
H1見出しをタイトルに流用する
各MarkdownファイルにはすでにH1見出し(# タイトル)が存在します。これをそのまま <!-- Title: --> に使うことで、タイトルの二重管理を避けました。
# H1タイトルを取得
title=$(grep -m1 '^# ' "$f" | sed 's/^# //')
冪等性の確保
スクリプトを何度実行しても同じ結果になるよう、すでにメタコメントが存在するファイルはスキップします。
# すでにメタコメントがある場合はスキップ
if head -1 "$f" | grep -q '<!-- Space:'; then
echo " [スキップ] $(basename "$f") (メタコメント済み)"
continue
fi
これにより、新しくファイルが追加されたときだけ処理が走ります。チーム内で「とりあえず実行しておけば大丈夫」と言える安心感は大きいです。
ドライランモード
実際にファイルを変更する前に、何が変わるかを確認できます。
# 確認のみ(ファイルは変更しない)
./add_confluence_meta.sh --dry-run 処理設計/ap_001_ユーザー入力フォーム
# 実際に挿入
./add_confluence_meta.sh 処理設計/ap_001_ユーザー入力フォーム
出力例:
[追加] ap_001_ユーザー入力フォーム_処理設計_初期処理.md
Title: ap_001:ユーザー入力フォーム:初期処理
[追加] ap_001_ユーザー入力フォーム_処理設計_入力画面表示.md
Title: ap_001:ユーザー入力フォーム:入力画面表示
[スキップ] ap_001_ユーザー入力フォーム_処理設計_入力画面サブミット.md (メタコメント済み)
完了: 追加=2 スキップ=1 警告=0
draw.ioマクロの動的挿入
設計書にはdraw.ioで作成した画面遷移図が含まれています。Confluenceでdraw.ioを表示するには、Confluence Storage Format(XHTML)のマクロを挿入し、さらに .drawio ファイルをページに添付する必要があります。
一時ファイル方式で元Markdownを守る
最初に考えたのは、Markdownファイルに直接マクロを書き込む方法でした。しかし、これには2つの問題がありました。
- Markdownの可読性が崩れる: Confluence固有のXMLマクロがMarkdownに混入する
- git diffがノイジーになる: アップロードのたびに差分が発生する
そこで、一時ファイルを使う方式にしました。
# 一時ファイルにdraw.ioマクロを埋め込む
local tmp="${TMPDIR_WORK}/${basename}.md"
local macro='<ac:structured-macro ac:name="drawio" ac:schema-version="1">'
macro+='<ac:parameter ac:name="diagramName">'"${drawio_filename}"'</ac:parameter>'
macro+='</ac:structured-macro>'
perl -0777 -pe \
"s|(### 画面遷移図\n)([^\n#][^\n]*\n)?|\$1\n${macro}\n\n|" \
"$src" > "$tmp"
### 画面遷移図 セクションの直後にマクロを挿入しています。perlのスラープモード(-0777)を使って、改行をまたいだ置換を実現しました。
元のMarkdownファイルは一切変更されず、一時ファイルはアップロード後に自動削除されます。
REST APIでdrawioファイルを添付
markではファイル添付ができないため、アップロード後にREST APIで .drawio ファイルをページに添付します。
# ページタイトルからページIDを取得
get_page_id() {
local title="$1"
local encoded_title
encoded_title=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${title}'))")
curl -sf \
"${BASE_URL}/rest/api/content?spaceKey=${SPACE}&title=${encoded_title}" \
-u "${USERNAME}:${PASSWORD}" \
| python3 -c "import json,sys; r=json.load(sys.stdin)['results']; print(r[0]['id'] if r else '')"
}
# drawioファイルをページに添付
attach_drawio() {
local page_id="$1"
local drawio_file="$2"
curl -sf -X POST \
"${BASE_URL}/rest/api/content/${page_id}/child/attachment" \
-u "${USERNAME}:${PASSWORD}" \
-H "X-Atlassian-Token: no-check" \
-F "file=@${drawio_file};type=application/octet-stream" \
-F "minorEdit=true"
}
X-Atlassian-Token: no-check ヘッダーはCSRF対策の回避に必要です。また、minorEdit=true を指定することで、ウォッチャーに通知を飛ばさないようにしています。一括アップロードで全員に通知が飛ぶと大惨事なので、ここは地味に重要です。
親子ページの自動導出
設計書は「概要ページ → 処理設計ページ」の2層構造になっています。処理設計ページをアップロードする際、対応する概要ページを自動で親ページとして設定する仕組みが必要でした。
フォルダ名から親ページ名を変換
ファイルシステムのフォルダ名とConfluenceのページ名には命名規則の違いがあります。
| ファイルシステム | Confluence |
|---|---|
ap_001_ユーザー入力フォーム |
ap_001:ユーザー入力フォーム |
アンダースコア _ をコロン : に変換するのですが、ap_001 のようにID部分にもアンダースコアがあるため、単純な一括置換はできません。
# ap_001_ユーザー入力フォーム → ap_001:ユーザー入力フォーム
parent_page=$(echo "$src_dirbase" | sed -E 's/^(ap_[0-9]+)_/\1:/')
正規表現で「IDパターン(ap_NNN)の直後のアンダースコアだけ」をコロンに変換しています。
--auto-parents オプション
この機能は --auto-parents フラグで有効化します。概要ページのアップロードでは不要(固定の親ページを使う)なので、処理設計のときだけ使う設計です。
# 概要ページ(親ページ固定)
./upload_confluence.sh --auth-file .confluence_auth \
-f '概要/ap_001_*.md'
# 処理設計ページ(親ページ自動導出)
./upload_confluence.sh --auth-file .confluence_auth \
--auto-parents \
-f '処理設計/ap_001_ユーザー入力フォーム/*.md'
一括同期の実行フロー
実際の運用は2ステップです。
Step 1: メタコメント挿入(初回のみ)
./add_confluence_meta.sh 処理設計/ap_001_ユーザー入力フォーム
Step 2: アップロード
# 概要
./upload_confluence.sh --auth-file .confluence_auth \
-f '概要/ap_00[1-9]_*.md' \
-f '概要/ap_01[0-5]_*.md'
# 処理設計
./upload_confluence.sh --auth-file .confluence_auth \
--auto-parents \
-f '処理設計/ap_001_ユーザー入力フォーム/*.md'
glob展開で複数ファイルを一発指定でき、-f オプションを複数回指定することで柔軟なファイル選択が可能です。
認証情報の分離
APIトークンをコマンドラインに直書きするのはセキュリティ上の問題があります。.confluence_auth ファイルに分離し、.gitignore で除外しています。
# .confluence_auth
USERNAME=your-email@example.com
PASSWORD=your-api-token
# .gitignore
.confluence_auth
チーム内では chmod 600 .confluence_auth を推奨しています。
運用で詰まったポイントと対策
ハマり1: 親ページが「フォルダ型」だとmarkが使えない
Confluenceのページにはページ型とフォルダ型があり、フォルダ型の配下にmarkでページを作ることができません。Confluence上でページ型に変更するか、REST APIを直接使う必要があります。
ハマり2: タイトルに / を含むとParentメタコメントが壊れる
markの <!-- Parent: --> コメントはパス区切りに / を使います。タイトルに / が含まれるとパースが壊れるので、親ページの指定はメタコメントではなくコマンドの --parents オプションで行う設計にしました。
ハマり3: 添付済みdrawioの再アップロード
同名ファイルがすでに添付されている場合、REST APIは新しいバージョンとして上書きします。ただし、初回と2回目以降でHTTPステータスが異なることがあるため、エラーハンドリングではフォールバックメッセージを出すようにしています。
curl ... > /dev/null \
&& echo " [添付完了]" \
|| echo " [添付失敗 - すでに添付済みの可能性あり]"
Before / After(再掲)
冒頭のテーブルの再掲ですが、改めて。
| 項目 | Before(手動) | After(自動化) |
|---|---|---|
| 概要ページアップロード | 1ページ5分 × 20 = 100分 | コマンド1つで約2分 |
| 処理設計アップロード | 1ページ3分 × 90 = 270分 | Step 1 + Step 2 で約5分 |
| draw.io添付 | 手動で9ファイル添付 | 自動で完了 |
| 合計 | 約6時間 | 約7分 |
| 再実行コスト | 毎回同じ作業 | コマンド実行のみ |
特に大きいのは再実行コストです。設計書が更新されるたびに手動で同じ作業を繰り返す必要がなくなりました。git pull してスクリプトを実行するだけで、Confluenceが最新状態になります。
まとめ
- HTMLコメントでメタデータを埋め込む: Markdownの可読性を壊さずに、Confluence用の情報を付与する
- 一時ファイル方式でdraw.ioマクロを挿入する: 元ファイルを汚さず、git diffもクリーンに保つ
- フォルダ名から親ページを自動導出する: 命名規則とsedの正規表現で、手動設定を排除する
- 冪等性を確保する: 何度実行しても同じ結果になる安心感
-
認証情報を分離する:
.gitignoreとの組み合わせで事故を防ぐ
ドキュメントの「鮮度」を維持するのは、書くこと以上に仕組みの問題です。次はCI/CDパイプラインに組み込んで、mainブランチへのマージをトリガーにした自動同期を狙っています。
付録: スクリプト全文
add_confluence_meta.sh
#!/usr/bin/env bash
# 処理設計MarkdownファイルにConfluenceメタコメントを追加するスクリプト
# H1見出しをページタイトルとして <!-- Space: --> / <!-- Title: --> をファイル先頭に挿入する
# すでにメタコメントが存在するファイルはスキップする
#
# 使い方:
# ./add_confluence_meta.sh 処理設計/ap_001_ユーザー入力フォーム
# ./add_confluence_meta.sh 処理設計/ap_0*
#
# ドライラン(挿入せず確認のみ):
# ./add_confluence_meta.sh --dry-run 処理設計/ap_0*
set -euo pipefail
SPACE="MYPROJECT"
DRY_RUN=false
DIRS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run|-n) DRY_RUN=true; shift ;;
*) DIRS+=("$1"); shift ;;
esac
done
if [[ ${#DIRS[@]} -eq 0 ]]; then
echo "ERROR: 対象ディレクトリを指定してください" >&2
echo " 例: ./add_confluence_meta.sh 処理設計/ap_001_ユーザー入力フォーム" >&2
exit 1
fi
[[ "$DRY_RUN" == "true" ]] && echo "[ドライラン] ファイルは変更しません"
added=0
skipped=0
warned=0
for dir in "${DIRS[@]}"; do
[[ -d "$dir" ]] || { echo " [スキップ] ディレクトリが存在しません: $dir"; continue; }
for f in "$dir"/*.md; do
[[ -f "$f" ]] || continue
# すでにメタコメントがある場合はスキップ
if head -1 "$f" | grep -q '<!-- Space:'; then
echo " [スキップ] $(basename "$f") (メタコメント済み)"
(( skipped++ )) || true
continue
fi
# H1タイトルを取得
title=$(grep -m1 '^# ' "$f" | sed 's/^# //')
if [[ -z "$title" ]]; then
echo " [警告] H1タイトルが見つかりません: $f"
(( warned++ )) || true
continue
fi
echo " [追加] $(basename "$f")"
echo " Title: ${title}"
if [[ "$DRY_RUN" == "false" ]]; then
tmpfile=$(mktemp)
{
echo "<!-- Space: ${SPACE} -->"
echo "<!-- Title: ${title} -->"
echo ""
cat "$f"
} > "$tmpfile"
mv "$tmpfile" "$f"
fi
(( added++ )) || true
done
done
echo ""
echo "完了: 追加=${added} スキップ=${skipped} 警告=${warned}"
upload_confluence.sh
#!/usr/bin/env bash
# Confluenceアップロードスクリプト
# drawioファイルが存在する場合:
# 1. mark でページをアップロード(画面遷移図セクションにファイル名参照マクロを挿入)
# 2. REST API で .drawio ファイルをページに添付
# 元のMarkdownファイルは変更しない
#
# 使い方:
# ./upload_confluence.sh --auth-file .confluence_auth -f '概要/ap_001_*.md'
# ./upload_confluence.sh --auth-file .confluence_auth -f '概要/ap_0*.md'
set -euo pipefail
BASE_URL="https://your-domain.atlassian.net/wiki"
USERNAME=""
SPACE="MYPROJECT"
PARENTS="親ページ名"
PARENTS_DELIMITER=" | "
DRAWIO_DIR="概要/drawio"
TMPDIR_WORK=$(mktemp -d)
AUTH_FILE=".confluence_auth"
AUTO_PARENTS=false
# 引数解析
PASSWORD=""
FILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--password|-p) PASSWORD="$2"; shift 2 ;;
--username|-u) USERNAME="$2"; shift 2 ;;
--auth-file|-a) AUTH_FILE="$2"; shift 2 ;;
-f) FILES+=("$2"); shift 2 ;;
--parents) PARENTS="$2"; shift 2 ;;
--auto-parents) AUTO_PARENTS=true; shift ;;
*) shift ;;
esac
done
load_auth_file() {
local file="$1"
[[ -f "$file" ]] || return 0
while IFS='=' read -r key value; do
key=$(echo "$key" | tr -d '[:space:]')
value=$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
# Remove surrounding quotes when present.
value="${value#\"}"
value="${value%\"}"
value="${value#\'}"
value="${value%\'}"
case "$key" in
USERNAME) [[ -z "$USERNAME" ]] && USERNAME="$value" ;;
PASSWORD) [[ -z "$PASSWORD" ]] && PASSWORD="$value" ;;
""|\#*) ;;
esac
done < "$file"
}
load_auth_file "$AUTH_FILE"
if [[ -z "$USERNAME" ]]; then
echo "ERROR: USERNAME が未設定です (--username または ${AUTH_FILE} の USERNAME)" >&2
exit 1
fi
if [[ -z "$PASSWORD" ]]; then
echo "ERROR: PASSWORD が未設定です (--password または ${AUTH_FILE} の PASSWORD)" >&2
exit 1
fi
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "ERROR: -f でファイルを指定してください" >&2
exit 1
fi
cleanup() {
rm -rf "$TMPDIR_WORK"
}
trap cleanup EXIT
# ページIDをタイトルで取得
get_page_id() {
local title="$1"
local encoded_title
encoded_title=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${title}'))")
curl -sf \
"${BASE_URL}/rest/api/content?spaceKey=${SPACE}&title=${encoded_title}&expand=ancestors" \
-u "${USERNAME}:${PASSWORD}" \
| python3 -c "import json,sys; results=json.load(sys.stdin)['results']; print(results[0]['id'] if results else '')"
}
# drawioファイルをページに添付
attach_drawio() {
local page_id="$1"
local drawio_file="$2"
local filename
filename=$(basename "$drawio_file")
echo " [添付] $filename -> page_id=$page_id"
curl -sf -X POST \
"${BASE_URL}/rest/api/content/${page_id}/child/attachment" \
-u "${USERNAME}:${PASSWORD}" \
-H "X-Atlassian-Token: no-check" \
-F "file=@${drawio_file};type=application/octet-stream" \
-F "minorEdit=true" \
> /dev/null && echo " [添付完了]" || echo " [添付失敗 - すでに添付済みの可能性あり]"
}
upload_file() {
local src="$1"
local basename
basename=$(basename "$src" .md)
# ap_001 などのID部分を抽出
local ap_id
ap_id=$(echo "$basename" | grep -oE '^ap_[0-9]+')
local drawio_file="${DRAWIO_DIR}/${ap_id}.drawio"
local target="$src"
if [[ "$AUTO_PARENTS" == "false" && -f "$drawio_file" ]]; then
local drawio_filename
drawio_filename=$(basename "$drawio_file")
# 一時ファイルにファイル名参照マクロを埋め込む
local tmp="${TMPDIR_WORK}/${basename}.md"
local macro
macro='<ac:structured-macro ac:name="drawio" ac:schema-version="1"><ac:parameter ac:name="diagramName">'"${drawio_filename}"'</ac:parameter></ac:structured-macro>'
perl -0777 -pe \
"s|(### 画面遷移図\n)([^\n#][^\n]*\n)?|\$1\n${macro}\n\n|" \
"$src" > "$tmp"
target="$tmp"
echo " [drawioマクロ挿入] $drawio_filename"
else
echo " [drawioなし] $src"
fi
# 親ページを解決(--auto-parents 時はディレクトリ名から導出)
local effective_parents="$PARENTS"
if [[ "$AUTO_PARENTS" == "true" ]]; then
local src_dir src_dirbase parent_page
src_dir=$(dirname "$src")
src_dirbase=$(basename "$src_dir")
# ap_001_ユーザー入力フォーム → ap_001:ユーザー入力フォーム
parent_page=$(echo "$src_dirbase" | sed -E 's/^(ap_[0-9]+)_/\1:/')
if [[ "$parent_page" != "$src_dirbase" ]]; then
effective_parents="${PARENTS}${PARENTS_DELIMITER}${parent_page}"
fi
fi
# markでアップロード
mark \
--base-url "$BASE_URL" \
--username "$USERNAME" \
--password "$PASSWORD" \
--parents "$effective_parents" \
--parents-delimiter "$PARENTS_DELIMITER" \
-f "$target"
# drawioがある場合、ページIDを取得して添付(概要のみ)
if [[ "$AUTO_PARENTS" == "false" && -f "$drawio_file" ]]; then
# <!-- Title: xxx --> からタイトルを取得
local title
title=$(grep -m1 '<!-- Title:' "$src" | sed 's/<!-- Title: \(.*\) -->/\1/')
if [[ -n "$title" ]]; then
local page_id
page_id=$(get_page_id "$title")
if [[ -n "$page_id" ]]; then
attach_drawio "$page_id" "$drawio_file"
else
echo " [警告] ページIDが取得できませんでした: $title"
fi
fi
fi
}
# glob展開してアップロード
for pattern in "${FILES[@]}"; do
if [[ -f "$pattern" ]]; then
echo "==> $pattern"
upload_file "$pattern"
else
_dir=$(dirname "$pattern")
_glob=$(basename "$pattern")
while IFS= read -r -d '' f; do
echo "==> $f"
upload_file "$f"
done < <(find "$_dir" -maxdepth 1 -name "$_glob" -type f -print0 | sort -z)
fi
done
echo ""
echo "完了"