Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Makefile は `nix develop --command bash -c "..."` でラップ済み。AI は
| マイグレーション | `make migrate` / `make migrate-create MSG="..."` |
| インフラ validate | `make infra-validate` |
| コード重複検知 | `make dupe-check` (結果: `report/dupe/jscpd-report.json`) |
| ミューテーションテスト | `make mutation-backend` / `make mutation-web` (**長時間**。通常 CI には含まれない週次実行。詳細: 下記「ミューテーションテスト・Slack 通知」) |

セットアップ詳細・各コマンドの目的は `docs/development.md` を参照。

Expand Down Expand Up @@ -102,6 +103,24 @@ nix develop --command bash -c "cd web && npm run test:e2e"

CI 定義: `.github/workflows/ci.yml`

## ミューテーションテスト・Slack 通知(ADR-0017)

テストの検出力(弱い assertion / 実装なぞり)を週次のミューテーションテストで可視化し、CI 結果は用途別 Slack チャンネルへ通知する。詳細(ローカル実行・レポート確認・Secrets 登録手順)は `docs/development.md`「ミューテーションテスト」「Slack 通知」節が正本。

- **ローカル実行**: `make mutation-backend`(mutmut)/ `make mutation-web`(Stryker)。**フル実行は長時間**のため、対象を絞る場合は `nix develop --command bash -c "cd backend && .venv/bin/python -m mutmut run 'app.services.shared.sort_utils*'"` / `nix develop --command bash -c "cd web && npx stryker run --mutate 'src/utils/text.ts'"`
- **対象スコープの正本**: backend = `backend/pyproject.toml` の `[tool.mutmut]`、web = `web/stryker.conf.json`。決定論的ビジネスロジックに限定(schemas / models / routers / 自動生成コード等は対象外)
- **CI**: `.github/workflows/mutation.yml`(週次 月曜 3:00 JST + workflow_dispatch。**PR/push では動かない・fail しない warn-only**)
- **pytest の `--cov` は addopts に戻さない**: mutmut 干渉回避のため Makefile / test.yml の呼び出し側で付与している(ADR-0017)

| Slack Secret | 用途 | 送信元 workflow |
|---|---|---|
| `SLACK_WEBHOOK_URL_CI` | 通常 CI の失敗のみ | `notify.yml`(workflow_run) |
| `SLACK_WEBHOOK_URL_DEPLOY` | Cloud Run / Cloudflare Pages デプロイ結果(成功・失敗とも) | `notify.yml`(workflow_run) |
| `SLACK_WEBHOOK_URL_QUALITY` | ミューテーションテスト結果(score が閾値未満で ⚠️ 強調) | `mutation.yml` |
| `SLACK_WEBHOOK_URL_DEPS` | Renovate / Dependabot の PR 起票 | `deps-notify.yml`(pull_request_target) |

Secrets は未登録の間は通知が静かに skip される(CI は green のまま)。チャンネル作成後の登録は `gh secret set SLACK_WEBHOOK_URL_CI --body "https://hooks.slack.com/services/..."`(4 つとも。手順詳細: `docs/development.md`)。

## 作業開始時のブランチ運用(デフォルト)

**新しい作業に着手するときは、最初に main から作業ブランチを切る。** これはデフォルト挙動であり、合言葉や明示指示を待たない。
Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/deps-notify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Renovate / Dependabot が依存更新 PR を起票したら SLACK_WEBHOOK_URL_DEPS へ通知する(ADR-0017)。
#
# pull_request_target を使う理由: Dependabot が actor の pull_request イベントは
# Actions secrets ではなく Dependabot secrets しか参照できず Webhook が空になる。
# pull_request_target は base ブランチのコンテキストで実行され通常の secrets を参照できる。
# PR のコードは一切 checkout / 実行せず(イベントペイロードの文字列のみ扱う)、
# actor をボットに限定しているため pull_request_target でも安全。
# PR タイトル等の外部入力は run 内に直接式展開せず env 経由で渡す(インジェクション対策)。
name: Deps Notify

on:
pull_request_target:
types: [opened]

permissions:
contents: read

jobs:
notify-deps:
runs-on: ubuntu-latest
timeout-minutes: 5
# github.actor は「最後にイベントを起こした actor」でスプーフ可能なため、
# PR 作成者(pull_request.user.login)で判定する(zizmor bot-conditions)
if: github.event.pull_request.user.login == 'renovate[bot]' || github.event.pull_request.user.login == 'dependabot[bot]'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_DEPS }}

steps:
- name: 通知メッセージを組み立て
id: compose
if: env.SLACK_WEBHOOK_URL != ''
env:
WF_NAME: ${{ github.workflow }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_ACTOR: ${{ github.event.pull_request.user.login }}
run: |
summary="📦 依存更新 PR: #${PR_NUMBER} ${PR_TITLE}"
body=$(printf '%s\n' \
"📦 *${WF_NAME}*: ${PR_ACTOR} が依存更新 PR を起票しました" \
"> #${PR_NUMBER} ${PR_TITLE} (branch: \`${PR_BRANCH}\`)" \
"<${PR_URL}|→ PR を開く>")
# untrusted 値(PR タイトル)が GITHUB_OUTPUT のデリミタ行と衝突しないよう run 毎にランダム生成する
delim="GHOUT_$(openssl rand -hex 8)"
{
echo "summary<<$delim"; echo "$summary"; echo "$delim"
echo "body<<$delim"; echo "$body"; echo "$delim"
} >> "$GITHUB_OUTPUT"

- name: Slack 通知 (依存更新 PR)
if: env.SLACK_WEBHOOK_URL != ''
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.0
with:
webhook: ${{ env.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"text": ${{ toJSON(steps.compose.outputs.summary) }},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ${{ toJSON(steps.compose.outputs.body) }}
}
}
]
}
273 changes: 273 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
# 週次ミューテーションテスト(ADR-0017)。
# テストスイートの検出力(弱い assertion / 実装なぞりテスト)を可視化する。
# フル実行が長時間かかるため通常 CI(ci.yml)には組み込まず、週次 schedule と
# 手動実行(workflow_dispatch)のみとする。結果は warn-only(fail させない)で、
# SLACK_WEBHOOK_URL_QUALITY へ killed / survived / score を通知する。
name: Mutation

on:
schedule:
# 毎週月曜 3:00 JST(= 日曜 18:00 UTC)
- cron: "0 18 * * 0"
workflow_dispatch:

permissions:
contents: read

env:
# mutation score(%)がこの値未満のとき Slack 通知を ⚠️ で強調する
MUTATION_SCORE_THRESHOLD: "80"

concurrency:
group: mutation-${{ github.ref }}
cancel-in-progress: false

jobs:
# Backend: mutmut(対象: backend/pyproject.toml の [tool.mutmut] を参照)
mutation-backend:
runs-on: ubuntu-latest
timeout-minutes: 120
outputs:
killed: ${{ steps.stats.outputs.killed }}
survived: ${{ steps.stats.outputs.survived }}
score: ${{ steps.stats.outputs.score }}
stats_ok: ${{ steps.stats.outputs.stats_ok }}

steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# 読み取り専用ジョブのため checkout の認証情報をディスクに残さない(サプライチェーン保護)。
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: "latest"
enable-cache: true
cache-dependency-glob: "backend/requirements.txt"

- name: Setup Python
run: uv python install 3.13

- name: WeasyPrint 用システムライブラリのインストール
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 \
libglib2.0-0 libgobject-2.0-0 libffi-dev libcairo2

- name: Install backend dependencies
working-directory: backend
run: uv pip install -r requirements.txt

# 生存ミュータントがあっても集計と通知は行うため continue-on-error にする
- name: Run mutmut
id: run
working-directory: backend
continue-on-error: true
run: uv run mutmut run 2>&1 | tee mutmut-run.log

# 生存ミュータントの一覧(調査用)。run が途中で落ちても best-effort で出力する
- name: 生存ミュータント一覧を出力
working-directory: backend
continue-on-error: true
run: uv run mutmut results > mutmut-results.txt

- name: 結果集計
id: stats
working-directory: backend
run: |
# export-cicd-stats が mutants/mutmut-cicd-stats.json に統計を書き出す。
# score の定義(ADR-0017 / Stryker と同基準):
# killed = killed + timeout(無限ループ化もテストによる検出とみなす)
# survived = survived + no_tests(どのテストにも触れられない = 検出不能)
# score = killed * 100 / (killed + survived + suspicious + segfault)
uv run mutmut export-cicd-stats || true
STATS=mutants/mutmut-cicd-stats.json
if [ -f "$STATS" ]; then
killed=$(jq '.killed + .timeout' "$STATS")
survived=$(jq '.survived + .no_tests' "$STATS")
denom=$(jq '.killed + .timeout + .survived + .no_tests + .suspicious + .segfault' "$STATS")
if [ "$denom" -gt 0 ]; then
score=$(( killed * 100 / denom ))
else
score=0
fi
{
echo "killed=$killed"
echo "survived=$survived"
echo "score=$score"
echo "stats_ok=true"
} >> "$GITHUB_OUTPUT"
else
echo "::warning::mutmut の統計ファイルが見つかりません(run が途中で失敗した可能性)"
{
echo "killed=0"
echo "survived=0"
echo "score=0"
echo "stats_ok=false"
} >> "$GITHUB_OUTPUT"
fi

- name: レポートを artifact 化
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: mutmut-report
path: |
backend/mutmut-run.log
backend/mutmut-results.txt
backend/mutants/mutmut-cicd-stats.json
if-no-files-found: warn
retention-days: 14

# Frontend: Stryker(対象: web/stryker.conf.json を参照)
mutation-web:
runs-on: ubuntu-latest
timeout-minutes: 60
outputs:
killed: ${{ steps.stats.outputs.killed }}
survived: ${{ steps.stats.outputs.survived }}
score: ${{ steps.stats.outputs.score }}
stats_ok: ${{ steps.stats.outputs.stats_ok }}

steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "20"
cache: npm
cache-dependency-path: web/package-lock.json

- name: Install dependencies
run: npm ci --prefix web

# 生存ミュータントがあっても集計と通知は行うため continue-on-error にする
- name: Run Stryker
id: run
continue-on-error: true
run: npm run test:mutation --prefix web

- name: 結果集計
id: stats
run: |
# score の定義(ADR-0017 / mutmut 側と同基準):
# killed = Killed + Timeout
# survived = Survived + NoCoverage
# score = killed * 100 / (killed + survived)
REPORT=web/reports/mutation/mutation.json
if [ -f "$REPORT" ]; then
killed=$(jq '[.files[].mutants[].status | select(. == "Killed" or . == "Timeout")] | length' "$REPORT")
survived=$(jq '[.files[].mutants[].status | select(. == "Survived" or . == "NoCoverage")] | length' "$REPORT")
if [ $(( killed + survived )) -gt 0 ]; then
score=$(( killed * 100 / (killed + survived) ))
else
score=0
fi
{
echo "killed=$killed"
echo "survived=$survived"
echo "score=$score"
echo "stats_ok=true"
} >> "$GITHUB_OUTPUT"
else
echo "::warning::Stryker のレポートが見つかりません(run が途中で失敗した可能性)"
{
echo "killed=0"
echo "survived=0"
echo "score=0"
echo "stats_ok=false"
} >> "$GITHUB_OUTPUT"
fi

- name: レポートを artifact 化
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: stryker-report
path: web/reports/mutation/
if-no-files-found: warn
retention-days: 14

# 結果を SLACK_WEBHOOK_URL_QUALITY へ 1 メッセージで通知する。
# secrets はジョブ/ステップの if で直接参照できないため、env 経由で空判定する
# (Secret 未登録の間は静かに skip され、workflow は green のまま)。
notify-quality:
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [mutation-backend, mutation-web]
if: always()
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_QUALITY }}

steps:
- name: 通知メッセージを組み立て
id: compose
if: env.SLACK_WEBHOOK_URL != ''
env:
BE_RESULT: ${{ needs.mutation-backend.result }}
BE_STATS_OK: ${{ needs.mutation-backend.outputs.stats_ok }}
BE_KILLED: ${{ needs.mutation-backend.outputs.killed }}
BE_SURVIVED: ${{ needs.mutation-backend.outputs.survived }}
BE_SCORE: ${{ needs.mutation-backend.outputs.score }}
WEB_RESULT: ${{ needs.mutation-web.result }}
WEB_STATS_OK: ${{ needs.mutation-web.outputs.stats_ok }}
WEB_KILLED: ${{ needs.mutation-web.outputs.killed }}
WEB_SURVIVED: ${{ needs.mutation-web.outputs.survived }}
WEB_SCORE: ${{ needs.mutation-web.outputs.score }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
WORKFLOW: ${{ github.workflow }}
BRANCH: ${{ github.ref_name }}
run: |
threshold="$MUTATION_SCORE_THRESHOLD"
# 全体ステータス: ジョブ失敗/統計欠落 → ❌、いずれかの score が閾値未満 → ⚠️、それ以外 → ✅
if [ "$BE_RESULT" != "success" ] || [ "$WEB_RESULT" != "success" ] || \
[ "$BE_STATS_OK" != "true" ] || [ "$WEB_STATS_OK" != "true" ]; then
emoji="❌"
headline="Mutation testing がエラーで完走しませんでした"
elif [ "$BE_SCORE" -lt "$threshold" ] || [ "$WEB_SCORE" -lt "$threshold" ]; then
emoji="⚠️"
headline="*mutation score が閾値(${threshold}%)を下回っています*"
else
emoji="✅"
headline="mutation score は閾値(${threshold}%)以上です"
fi
body=$(printf '%s\n' \
"${emoji} *${WORKFLOW}* (branch: \`${BRANCH}\`)" \
"${headline}" \
"• *Backend (mutmut)* — killed: ${BE_KILLED} / survived: ${BE_SURVIVED} / score: *${BE_SCORE}%*" \
"• *Web (Stryker)* — killed: ${WEB_KILLED} / survived: ${WEB_SURVIVED} / score: *${WEB_SCORE}%*" \
"<${RUN_URL}|→ 実行結果とレポート artifact を開く>")
summary="${emoji} Mutation weekly report (${BRANCH})"
# GITHUB_OUTPUT のデリミタ行と本文が衝突しないよう run 毎にランダム生成する(他通知 workflow と統一)
delim="GHOUT_$(openssl rand -hex 8)"
{
echo "summary<<$delim"; echo "$summary"; echo "$delim"
echo "body<<$delim"; echo "$body"; echo "$delim"
} >> "$GITHUB_OUTPUT"

- name: Slack 通知 (QUALITY)
if: env.SLACK_WEBHOOK_URL != ''
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.0
with:
webhook: ${{ env.SLACK_WEBHOOK_URL }}
webhook-type: incoming-webhook
payload: |
{
"text": ${{ toJSON(steps.compose.outputs.summary) }},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ${{ toJSON(steps.compose.outputs.body) }}
}
}
]
}
Loading
Loading