diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75ec4b9..df6f011 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,11 +10,18 @@ on: pull_request: branches: [main] +# Read-only token. Pinned explicitly so this workflow never inherits write / PR-approval +# rights from repository defaults (Actions is allowed to create PRs for spec-sync). +permissions: + contents: read + jobs: build-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # read-only job; do not leave the token in .git/config - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -58,6 +65,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # read-only job; do not leave the token in .git/config - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -74,6 +83,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # read-only job; do not leave the token in .git/config - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index e4ba18b..b2ef207 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -52,6 +52,8 @@ jobs: $r.specs | ForEach-Object { " $($_.status.PadRight(12)) $($_.spec)" } | Write-Host "drifted=$($r.drifted.ToString().ToLower())" >> $env:GITHUB_OUTPUT "awareness=$($r.awarenessDrift.ToString().ToLower())" >> $env:GITHUB_OUTPUT + # latestSha は後段でブランチ名・スクリプトへ展開されるため、git SHA 形式であることを確認する(多層防御)。 + if ($r.latestSha -notmatch '^[0-9a-f]{40}$') { throw "unexpected upstream SHA format: '$($r.latestSha)'" } "latestSha=$($r.latestSha)" >> $env:GITHUB_OUTPUT "manifestRef=$($r.manifestRef)" >> $env:GITHUB_OUTPUT "compareUrl=$($r.compareUrl)" >> $env:GITHUB_OUTPUT @@ -96,9 +98,12 @@ jobs: # --- 2) 追跡 Issue の upsert(open な spec-sync Issue があれば本文更新、無ければ作成)--- - name: Upsert tracking issue + id: issue if: steps.decide.outputs.notify == 'true' shell: pwsh run: | + # ネイティブコマンド(gh)の非 0 終了でステップを失敗させる(既定では無言で継続してしまう)。 + $PSNativeCommandUseErrorActionPreference = $true $title = "Upstream spec drift: line/line-openapi @ ${{ steps.drift.outputs.latestSha }}" $body = @() $body += "Automated by ``.github/workflows/spec-sync.yml``. Do not edit by hand; this issue is upserted each run." @@ -111,16 +116,26 @@ jobs: if ($existing) { gh issue edit $existing --repo $env:REPO --title $title --body-file "$env:RUNNER_TEMP/issue-body.md" Write-Host "updated issue #$existing" + $number = $existing } else { + # ラベルが既存なら失敗するのは想定内(その 1 コマンドだけ失敗を許容)。 + $PSNativeCommandUseErrorActionPreference = $false gh label create spec-sync --repo $env:REPO --color 1d76db --description "Upstream OpenAPI spec drift tracking" 2>$null - gh issue create --repo $env:REPO --title $title --label spec-sync --body-file "$env:RUNNER_TEMP/issue-body.md" + $PSNativeCommandUseErrorActionPreference = $true + $url = gh issue create --repo $env:REPO --title $title --label spec-sync --body-file "$env:RUNNER_TEMP/issue-body.md" + Write-Host "created issue $url" + $number = ($url -split '/')[-1] } + # 後段(PR 結果コメント)が参照する。 + "number=$number" >> $env:GITHUB_OUTPUT + exit 0 # 同期回復時(ドリフト無し)は、残っている追跡 Issue をクローズする。 - name: Close tracking issue when back in sync if: steps.decide.outputs.notify == 'false' shell: pwsh run: | + $PSNativeCommandUseErrorActionPreference = $true $open = gh issue list --repo $env:REPO --label spec-sync --state open --json number --jq '.[].number' foreach ($n in $open) { if ($n) { gh issue close $n --repo $env:REPO --comment "Upstream specs are back in sync with the committed snapshot (no drift). Closed automatically by spec-sync." } @@ -133,6 +148,7 @@ jobs: run: dotnet tool install --global Microsoft.OpenApi.Kiota --version 1.34.1 - name: Regenerate at pinned upstream SHA + id: regen if: steps.decide.outputs.regenerate == 'true' shell: pwsh run: ./scripts/generate.ps1 -Update -Ref ${{ steps.drift.outputs.latestSha }} @@ -151,9 +167,13 @@ jobs: # --- 4) 下書き PR の作成/更新 --- - name: Open or update draft PR + id: pr if: steps.decide.outputs.regenerate == 'true' shell: pwsh run: | + # git/gh の非 0 終了でステップを失敗させる。既定(false)だと push 拒否等を握り潰したまま + # 後続へ進み、run が緑のまま無言で失敗する(2026-10 に上流 fa5577c9 の取り込みで顕在化)。 + $PSNativeCommandUseErrorActionPreference = $true $sha = "${{ steps.drift.outputs.latestSha }}" $short = $sha.Substring(0,8) $branch = "spec-sync/update-$short" @@ -166,12 +186,31 @@ jobs: # 再生成が触るのは openapi/(spec+manifest)と src/**/Generated/** のみ。 # 対象を限定し、CI スクラッチや不測のファイルを PR に巻き込まない。 git add openapi src - if (git diff --cached --quiet) { Write-Host "no file changes to commit"; exit 0 } - git commit -m "chore(spec-sync): update LINE OpenAPI specs to $short and regenerate" - # 専用の bot ブランチ(SHA 命名で決定的)。前回 run の残りは上書きしたいが、 - # レビュアーが同ブランチに積んだ fixup を握り潰さないよう --force-with-lease を使う - # (新規ブランチ=リモート追跡 ref 無しでもモダン git は許容)。 - git push --force-with-lease origin $branch + + # 以下 2 つは「非 0 終了=判定結果」のプローブなので一時的に失敗扱いを外す。 + $PSNativeCommandUseErrorActionPreference = $false + git diff --cached --quiet + $hasChanges = $LASTEXITCODE -ne 0 + # --exit-code: 該当 ref 無し=2。それ以外の非 0(認証/ネットワーク)は異常として落とす。 + git ls-remote --exit-code --heads origin $branch | Out-Null + $lsRemote = $LASTEXITCODE + $PSNativeCommandUseErrorActionPreference = $true + if ($lsRemote -notin 0, 2) { throw "git ls-remote failed (exit $lsRemote)" } + $branchExists = $lsRemote -eq 0 + + if (-not $hasChanges) { Write-Host "no file changes to commit"; exit 0 } + + # bot ブランチは上流 SHA 命名で、同じ SHA なら再生成内容は決定的。既存ブランチには push しない + # (checkout は追跡 ref を持たないため --force-with-lease は必ず stale 拒否になる。上書きすると + # レビュアーが積んだ fixup を消す)。main 側の生成器変更は既存ブランチに反映されない点を本文に明記する。 + $pushNote = $null + if ($branchExists) { + $pushNote = "Branch ``$branch`` already existed, so this run did **not** re-push it (reviewer fixups are preserved). If ``scripts/generate.ps1`` or the Kiota version changed on ``main`` since, regenerate on the branch manually. The build/test results below are from this run's regeneration on ``main``, not from the branch contents." + Write-Host "::notice::branch $branch already exists on origin; skipping push" + } else { + git commit -m "chore(spec-sync): update LINE OpenAPI specs to $short and regenerate" + git push origin $branch + } # PR 本文(チェックリスト付き) $pb = @() @@ -180,23 +219,87 @@ jobs: $pb += (Get-Content "$env:RUNNER_TEMP/drift-summary.md" -Raw) $pb += "" $pb += "### CI (this run)" + $pb += "- run: $env:GITHUB_SERVER_URL/$env:REPO/actions/runs/$env:GITHUB_RUN_ID" $pb += "- build: ``$buildOutcome``" $pb += "- test: ``$testOutcome`` (public API snapshot test catches breaking changes to the hand-written surface)" + $pb += "- PRs opened with ``GITHUB_TOKEN`` do not trigger ``ci.yml``; close/reopen the PR (or push a commit) to run the full CI before merging." + if ($pushNote) { $pb += ""; $pb += "> $pushNote" } $pb += "" $pb += "### Reviewer checklist" $pb += "- [ ] ``src/**/Generated/**`` の差分に新規オペレーション/モデルが無いか(あれば手書きファサード要検討=公開 API snapshot には出ない)" $pb += "- [ ] 公開 API snapshot テスト結果(破壊的変更の有無)を確認" $pb += "- [ ] 4役ゲート(code/security/spec/test-arch)を実施し ``docs/reviews/`` に記録" $pb += "- [ ] ``channel-access-token.yml`` の urn 正規化が効いているか(ビルド成功で担保)" - $pb += "- [ ] ``openapi/upstream-manifest.json`` の ref/hash が更新されているか" + $pb += "- [ ] ``openapi/upstream-manifest.json`` の ref/refDate/hash が更新されているか" + $issueNumber = "${{ steps.issue.outputs.number }}" + if ($issueNumber) { $pb += ""; $pb += "Closes #$issueNumber" } ($pb -join "`n") | Set-Content "$env:RUNNER_TEMP/pr-body.md" $existingPr = gh pr list --repo $env:REPO --head $branch --state open --json number --jq '.[0].number' + # 人が却下(未マージで close)した PR は作り直さない(上流 SHA が変わらない限り毎週再作成されるのを防ぐ)。 + $closedPr = gh pr list --repo $env:REPO --head $branch --state closed --json number,mergedAt --jq '[.[] | select(.mergedAt == null)][0].number' + if (-not $existingPr -and $closedPr) { + Write-Host "::notice::PR #$closedPr for $branch was closed without merging; not reopening" + "closedPr=$closedPr" >> $env:GITHUB_OUTPUT + exit 0 + } if ($existingPr) { gh pr edit $existingPr --repo $env:REPO --body-file "$env:RUNNER_TEMP/pr-body.md" Write-Host "updated PR #$existingPr" + $prUrl = gh pr view $existingPr --repo $env:REPO --json url --jq .url } else { - gh pr create --repo $env:REPO --draft --base main --head $branch ` + $prUrl = gh pr create --repo $env:REPO --draft --base main --head $branch ` --title "chore(spec-sync): update specs to $short and regenerate" ` --label spec-sync --body-file "$env:RUNNER_TEMP/pr-body.md" + Write-Host "created PR $prUrl" + } + "url=$prUrl" >> $env:GITHUB_OUTPUT + "pushed=$((-not $branchExists).ToString().ToLower())" >> $env:GITHUB_OUTPUT + exit 0 + + # --- 5) 結果を追跡 Issue へ記録(PR 作成失敗時も残す)--- + - name: Report to tracking issue + if: ${{ !cancelled() && steps.decide.outputs.regenerate == 'true' && steps.issue.outputs.number != '' }} + shell: pwsh + # ステップ出力は式でスクリプト本文へ直接展開せず env 経由で渡す(式インジェクション対策の定石)。 + env: + ISSUE_NUMBER: ${{ steps.issue.outputs.number }} + REGEN_OUTCOME: ${{ steps.regen.outcome }} + BUILD_OUTCOME: ${{ steps.build.outcome }} + TEST_OUTCOME: ${{ steps.test.outcome }} + PR_OUTCOME: ${{ steps.pr.outcome }} + PR_URL: ${{ steps.pr.outputs.url }} + PR_PUSHED: ${{ steps.pr.outputs.pushed }} + CLOSED_PR: ${{ steps.pr.outputs.closedPr }} + run: | + $PSNativeCommandUseErrorActionPreference = $true + $c = @() + $c += "spec-sync run: $env:GITHUB_SERVER_URL/$env:REPO/actions/runs/$env:GITHUB_RUN_ID" + $c += "" + $c += "- regenerate: ``$env:REGEN_OUTCOME``" + $c += "- build: ``$env:BUILD_OUTCOME``" + $c += "- test: ``$env:TEST_OUTCOME``" + $c += "- draft PR step: ``$env:PR_OUTCOME``" + if ($env:PR_URL) { + $c += "- PR: $env:PR_URL" + if ($env:PR_PUSHED -eq "false") { $c += "- branch already existed; not re-pushed" } + } elseif ($env:CLOSED_PR) { + $c += "- PR #$env:CLOSED_PR for this upstream SHA was closed without merging, so no new PR was opened. Reopen it (or delete the branch) to retry." + } elseif ($env:REGEN_OUTCOME -ne "success") { + $c += "- **Regeneration failed**, so no PR was opened. See the run log." + } else { + $c += "- **No PR was opened/updated.** Check the run log (no file changes after regeneration, the repository setting *Allow GitHub Actions to create and approve pull requests*, or a git/gh failure)." } + ($c -join "`n") | Set-Content "$env:RUNNER_TEMP/issue-comment.md" + gh issue comment $env:ISSUE_NUMBER --repo $env:REPO --body-file "$env:RUNNER_TEMP/issue-comment.md" + + # Build/Test は PR 本文へ結果を載せるため continue-on-error だが、失敗を run の結果(通知)にも出す。 + - name: Fail run when regenerated client does not build or pass tests + if: steps.decide.outputs.regenerate == 'true' && (steps.build.outcome != 'success' || steps.test.outcome != 'success') + shell: pwsh + env: + BUILD_OUTCOME: ${{ steps.build.outcome }} + TEST_OUTCOME: ${{ steps.test.outcome }} + run: | + Write-Host "::error::regenerated client: build=$env:BUILD_OUTCOME test=$env:TEST_OUTCOME (see the draft PR / tracking issue)" + exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index 54e5dbd..370b7c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,8 @@ dotnet docfx docs/manual/docfx.json # metadata + build → docs/manual/_s - **検知**: `pwsh scripts/check-spec-drift.ps1`(manifest 基準・純検知・ドリフト時 exit 1・`-Json`/`-FailOnAwareness` あり)。gh 優先・無ければ REST。 - **再取得+再生成**: `pwsh scripts/generate.ps1 -Update [-Ref ]`(SHA ピン再取得→正規化→manifest 更新→Kiota 生成)。既定(`-Update` 無し)は同梱 spec を使う再現生成で挙動不変。 - **週次自動化**: `.github/workflows/spec-sync.yml`(cron+手動)が 検知→`spec-sync` ラベルの Issue upsert(回復時 close)→再生成→**draft PR** 自動作成。**マージは常に人+4 役ゲート**(自動マージしない)。破壊的変更は公開 API snapshot が捕捉、生成コードのみの追加は PR チェックリストで人手確認。 + - **前提:** リポジトリ設定 *Allow GitHub Actions to create and approve pull requests* がオン(オフだと PR 作成だけ失敗する)。既存の bot ブランチ(`spec-sync/update-`)には push しない(同一 SHA は決定的・fixup 保護)。git/gh の失敗と再生成の build/test 失敗は run を失敗させ、結果は追跡 Issue にコメントされる。`GITHUB_TOKEN` 製の PR では `ci.yml` が走らないので、マージ前に close/reopen でフル CI を回す。 + - **⚠️ pwsh 7 の `ConvertFrom-Json`/`Invoke-RestMethod` は ISO 日付を `DateTime` に自動変換する**(文字列前提の処理は例外になる。`generate.ps1` の `refDate` で踏んだ)。 ## 規約 diff --git a/docs/LINE-dotnet-client-design.md b/docs/LINE-dotnet-client-design.md index 797da92..acbec77 100644 --- a/docs/LINE-dotnet-client-design.md +++ b/docs/LINE-dotnet-client-design.md @@ -246,6 +246,12 @@ Webhook(モデルのみ)/LIFF も同様に個別生成。 2. **追跡 Issue upsert** — drift 時、`spec-sync` ラベルの Issue を作成/更新(compare リンク・spec 別状態・上流コミット一覧)。同期回復時は自動クローズ。 3. **再生成** `scripts/generate.ps1 -Update -Ref ` — SHA ピンで再取得 → 正規化 → manifest 更新 → Kiota 再生成 → build + test。**Kiota CLI 版は 1.34.1 にピン**(R3・生成物との整合)。 4. **下書き PR** — 再生成コード+正規化 spec+manifest を含む draft PR を自動作成。**マージは常に人+4役ゲート**(自動マージしない)。 + - 前提: リポジトリ設定 *Allow GitHub Actions to create and approve pull requests* がオン(オフだと PR 作成が失敗する)。 + - bot ブランチ `spec-sync/update-` が既にリモートにあれば **push しない**(同一 SHA の再生成は決定的・レビュアーの fixup を保護。checkout は追跡 ref を持たないため lease 付き上書きは成立しない)。PR の作成/本文更新のみ行い、スキップした旨を本文に明記する。 + - 同じブランチの PR が**未マージで close**(人が却下)されていれば作り直さない(同一上流 SHA の間、毎週再作成されるのを防ぐ)。再試行は PR を reopen するかブランチを削除する。 + - git/gh の非 0 終了はステップを失敗させる(`$PSNativeCommandUseErrorActionPreference`)。無言失敗させない。 +5. **結果の記録** — run URL・build/test 結果・PR リンク(または PR 未作成の旨)を追跡 Issue にコメント(PR 作成失敗時も残す)。再生成クライアントの build/test 失敗は最終ステップで run 自体を失敗扱いにする(通知が届く)。 + - `GITHUB_TOKEN` で作成した PR では `ci.yml` が起動しない(GitHub 仕様)。マージ前に close/reopen 等でフル CI を走らせる。 破壊的変更は公開 API snapshot テストが捕捉。**生成コードだけの追加(新オペレーション/モデル)は snapshot に出ない**ため、PR 本文のレビュアーチェックリストで人手確認する(手書きファサードの要否判断)。全パッケージは `Line.Core` + Kiota ランタイム版にロックステップ連動。 diff --git a/docs/reviews/2026-10-07-spec-sync-hardening-review.md b/docs/reviews/2026-10-07-spec-sync-hardening-review.md new file mode 100644 index 0000000..f222b57 --- /dev/null +++ b/docs/reviews/2026-10-07-spec-sync-hardening-review.md @@ -0,0 +1,45 @@ +# 2026-10-07 spec-sync ワークフロー堅牢化 レビュー記録 + +## 概要 + +PR #11(上流 `fa5577c9` 取り込み)のゲートで出た spec-sync ワークフローの follow-up を、ブランチ `ci/spec-sync-hardening` で対応した。経緯は `2026-10-07-spec-sync-fa5577c9-review.md`。 + +- **無言失敗の排除**: Issue upsert / draft PR / 同期回復 close の各ステップで `$PSNativeCommandUseErrorActionPreference = $true`。非 0 終了自体が判定結果のプローブ(`git diff --cached --quiet`、`git ls-remote --exit-code`=0 存在/2 無し/他は throw)と `gh label create` だけ一時的に除外。 +- **既存 bot ブランチへ push しない**: checkout に追跡 ref が無いため `--force-with-lease` は常に stale 拒否だった。同一上流 SHA の再生成は決定的なので push をスキップ(レビュアー fixup を保護)し、PR 作成/本文更新のみ。本文に注記(生成器変更は手動再生成・build/test 結果は今回 run のもの)。 +- **却下 PR を作り直さない**: 同じブランチの PR が未マージで close されていれば作成しない(毎週の再作成ループ防止)。 +- **追跡 Issue へ結果コメント**(`!cancelled()`): regenerate/build/test/PR ステップの outcome と PR URL。PR が無い場合は原因候補(却下済み PR・再生成失敗・変更なし/リポジトリ設定/git・gh 失敗)を出し分け。ステップ出力は `env:` 経由で受け渡し。 +- **build/test 失敗で run を失敗扱い**(最終ステップ。`continue-on-error` のため `outcome` で判定)。 +- **PR 本文**: run URL、`GITHUB_TOKEN` 製 PR では `ci.yml` が走らない注記、`Closes #<追跡 Issue>`。 +- **多層防御**: `latestSha` を `^[0-9a-f]{40}$` で検証してから出力。 +- **`ci.yml`**: `permissions: contents: read` を明示、checkout に `persist-credentials: false`。 +- docs: 設計 §9、CLAUDE.md(前提のリポジトリ設定・挙動・pwsh 日付自動変換の落とし穴)。 + +## 検証 + +- YAML パース OK。`gh --jq` で null が空文字になること(却下 PR 判定の前提)を確認。 +- pwsh の終了コード(ls-remote: 既存 0 / 無し 2 / 不正 remote 128)と、GHA と同じ `$ErrorActionPreference='stop'` 下で `$PSNativeCommandUseErrorActionPreference=$true` のネイティブ非 0 が終端例外になることを手元で確認。 +- ワークフロー自体の実走は未実施(次回ドリフト時または `workflow_dispatch` で確認)。 + +## 3 役ゲート結果(すべて PASS・BLOCKING なし) + +- **security-reviewer = PASS**: 式展開の値はすべて GitHub が決める値(SHA・番号・URL・outcome)で、上流由来文字列は `Get-Content` 経由のみ=注入経路なし。`ci.yml` の `contents: read` で既存 3 ジョブは動く。force push 撤廃で fixup 上書き経路も消滅。 +- **test-arch-reviewer = PASS**: PR #11 で指摘した 3 件(push 拒否の無言失敗・build/test 失敗が run に出ない・Issue と PR の非連動)すべて解消。新規/既存ブランチ・変更なし・build/test 失敗・PR ステップ失敗の各分岐を机上で追跡し正しい。 +- **code-reviewer = PASS**: 切替範囲・`$LASTEXITCODE` 退避・早期 `exit 0` の必要性(ls-remote の 2 が残るため必須)・gh 出力 URL の扱いとも正しい。 + +## ゲート後に反映した非ブロッキング指摘 + +security N-a(SHA 形式検証)・N-b(`env:` 経由)・N-d(`persist-credentials: false`)、test-arch 1(却下 PR 再作成ループ)・2(再生成失敗を Report に出す)・3(既存ブランチ時の build/test 結果の注記)・4(close ステップの fail-loud)・5(`!cancelled()`)、code 4(コメントの SHA 表記)・5(close ステップ)・6(`env:` 経由)。 + +code 1(`generate.ps1` の refDate バグ本体)は PR #11 で修正済み。本ブランチは main から分岐しているため、PR #11 のマージで解消する。 + +## 残る follow-up(次サイクル) + +- `actionlint` を `ci.yml` に導入(式・`steps.*` 参照の静的検査)。`workflow_dispatch` で既存ブランチ分岐を一度実走させる。 +- spec 駆動の R1 ガードテスト(`servers` が api-data のパス集合と Blob 生成フィルタの一致集合を照合)。PR #11 記録の follow-up 4。 +- Issue upsert 失敗時は後続(再生成・PR・Report)が全て skip され何も記録されない(fail-loud の意図どおり・run は赤)。 +- main の branch protection / ruleset で bot の approve がマージ条件に数えられないことを確認(security N-c・人による設定確認)。 +- 既知: security L1(特権ジョブ内の build/test)。 + +## 判定 + +3 役 PASS。マージの go/no-go は人。