tech.guitarrapc.cóm

Technical updates

OSSリポジトリで外部PRをどこまで信頼せずに設計するか

2025年のtj-actions侵害に続き、2026年もTanStack侵害があり、GitHub Actionsを経由した攻撃が継続して報告されています。よくも悪くもGitHubとGitHub ActionsはOSSリポジトリのCI/CDのデファクトスタンダードになっているため、攻撃者から見ても魅力的な攻撃対象になっていると感じます。CircleCIなどの外部CIと比べても、GitHub Actionsは権限や処理を追いかけやすく、ログも含めて攻撃しやすい面があります。

本記事は、2026年時点でも攻撃の起点になっている外部コントリビューターからのPR(以降、外部PR)にどう向き合うのがよいのかを整理したメモです。

外部PRをどう扱うのが現実的かを軸に、pull_request_targetやPRレビューの限界、侵入されたときの緩和策、GitHub Actions以外を併用する選択肢に焦点を当てます。

長いのでまとめ

記事が長いので、先に要点をまとめます。

  • 外部PRを受け付けるか決める
    • 外部PRを受け付けないなら、Settings > General > FeaturesのPull requestsをCollaborators onlyにする
    • 外部PRを受け付けるなら、Settings > ActionsRequire approval for all external contributors を有効にする
  • pull_request_target は使わない、pull_request で代替することを前提にする
    • どうしてもpull_request_targetを使うなら、actions/checkoutactions/cacheは使わない、権限はreadにとどめる
    • workflow_runイベントも同様に危ういので可能なら使わない
  • issuesissue_comment 起点のワークフローで、write 権限やスクリプト実行が混ざっていないか見直す
  • 外部PRでGitHub Actionsファイルの変更がPRに含まれているなら原則リジェクトする
  • GITHUB_TOKEN のデフォルト権限を最小化し、Allow GitHub Actions to create and approve pull requestsはオフにする
  • レビューにCopilot Code Review、CodeQL、Lintの補助を入れる
  • Immutable Release、Environmentを使った承認、OIDCトークン短命化で侵入後の被害を小さくする
  • npm stageは、これまでのリリース侵害に対するよい緩和策で他のパッケージマネージャーにも相当するものが欲しい

なかなか大変ですね。

外部PRはどう扱うべきか

外部PRをどこまで信頼できるかよりも「フォークPRに対してCIを実行すること自体がリスク」と前提を置いています。tj-actionsやTanStackをはじめとして、発生しているサプライチェイン問題の多くは、外部PRでCI(GitHub Actions)を動かすことが起点になっています。1

フォークPRに対してCIを実行することは、PRを通した任意のコードの挿入を許可するリスクととらえられます。npmに代表されるパッケージシステムによるサプライチェインが広がった現状において、任意コードを挿入された汚染パッケージは利用者に一気に波及するため、攻撃者から見ると任意コードを差し込む動機とリターンが高いといえます。GitHub Actionsのリモートアクションも同様にサプライチェインが広がっており、1つが侵害されると波及的に影響を広げます。

OSSプロジェクトの運営者としては、多数の外部PRから攻撃を見分ける難しさ、マージした後に気づくのが難しいこと、一度でも見逃すと影響が大きいこと、などが積み重なり頑張らないといけないという状況です。残念ながら一度でも悪意ある仕込みが入った外部PRをマージすると、デフォルトブランチが侵害されます。最初に一見すると害がない外部PRをマージさせて少しだけ安全性を緩めて、さらなるPRで間隙を突く手法もあります。デフォルトブランチの安全性が失われると、リリースにまで影響する可能性が高くなります。気づかずにリリース(パッケージ配布)まで到達すれば、攻撃者は本来の狙いに到達するまでサプライチェイン侵害を広げ続けます。

セキュリティ全般に言えることですが、一度侵入されたときのリスクが高いため、外部PRをありがたく受け入れることとリスクのバランスは崩れつつあると感じています。

内部コントリビューターの話を割愛する理由

あえて外部コントリビューターとしましたが、中にはXZ Utilsのバックドアのように、プロジェクトのコントリビューターとして信頼を積み重ねた上で、悪意あるコードを仕込む攻撃もあります。このケースを持ち出すと、OSSプロジェクト自体の信頼性の問題に話が飛んでしまうので、ここではあえて外部コントリビューターに限定して話を進めます。

この話は、ネットワークにおける境界防御の限界と、ポリシーをベースにしたゼロトラストセキュリティの必要性に似た面があると感じます。ただ、異なるコンテキストが混じるので、ここでは触れないことにします。

外部PRをどこまで信頼するかは、プロジェクトの性質や運営方針によって異なりますが、少なくとも外部PRに対してCIを実行すること自体がリスクであることを認識しておく必要があります。2026年5月現時点では、外部PRをそもそも受け付けない、あるいは受け付けるにしてもワークフロー実行の承認を慎重にする選択肢が考えられます。LLMによるコード生成の普及もあり外部PRがなくともLLMでコードを用意できうるシーンが増えてきていることもあり、外部PRを重視する比重に変化があるのは事実でしょう。

外部PRを受け付けないという選択

そもそも外部PRを受け付けないという選択肢もあります。GitHubは2026年2月にPull Requestの受付を内部コラボレーターに制限するPull request permissionsオプションを追加しました。デフォルトはAll usersです。

Pull request permissionsのデフォルトはAll users

Collaborators onlyを選択すると外部PRは受け付けなくできます。

Collaborators only

コードの変更は内部コラボレーターのみで行い、パブリックにコードやリリース、Issueは公開というスタイルに向いています。外部PRを受け付けないことで、CI経由の攻撃リスクを低減できます。コードを書き換えられる最も大きな口を塞げるというのは選択肢としては十分に魅力的でしょう。外部PRを受け付けないといっても、Issue(場合によってはDiscordなどの外部コミュニケーションチャンネル)を通じて、外部からのフィードバックやバグ報告を受け付けることはできるので、コントリビューションができなくなるわけではありません。

ただ、IssueやIssue Comment経由で起動するGitHub Actionsがあるかは注意しましょう。contents: writepull-requests: writeなどの権限を持っていると攻撃する口になります。たとえば、次のようなワークフローは一見よくありそうですが、危険の塊です。

on:
  issues:
    types: [opened, edited]  # <- 攻撃で使いやすい
  issue_comment:
    types: [created, edited] # <- さらに攻撃で使いやすい

jobs:
  example:
    permissions:
      contents: write        # コードの書き換えpushを許容してしまう
      pull-requests: write   # PRの作成や更新を許容してしまう
    runs-on: ubuntu-24.04
    steps:
      - name: Do something when an issue is opened or commented
        run: echo "Issue #${{ github.event.issue.number }} was ${{ github.event.action }}"
      - name: Do something when an issue is opened or commented  # <- Issueタイトル経由でスクリプトインジェクション攻撃できる
        run: echo "Issue ${{ github.event.issue.title }} "
      - name: Do something when an issue is opened or commented  # <- envに入れてダブルクォートで参照することでインジェクションを防げる
        run: echo "Issue ${ISSUE_TITLE} "
        env:
          ISSUE_TITLE: ${{ github.event.issue.title }}           # <- ここで環境変数に入れる

Issue/Issueコメントをスクリプトで直接解釈すると、攻撃者が任意のコードを差し込める余地があります。そもそも、外部PRを受け付ける受け付けないに関わらず、ワークフローでむやみとIssue/Issue Commentを使わないようにしましょう。

外部PRを受け付けるならワークフロー実行を慎重に許可する

GitHubには、フォークPRからのPRでワークフローを実行するためのいくつかのオプションがあります。これを使うと、外部PRに対してGitHub Actionsが実行される前に承認を設けるかを調整できます。Organization(リポジトリ)の Settings > Actions に次のオプションがあります2

  • Require approval for first-time contributors who are new to GitHub: GitHubに新規で、かつこのリポジトリでコミット/PRもマージされたことがないユーザーだけがワークフロー実行の承認を必要とする
  • Require approval for first-time contributors: このリポジトリでコミット/PRもマージされたことがないユーザーだけがワークフロー実行の承認を必要とする (デフォルト)
  • Require approval for all external contributors: 外部コントリビューター全員がワークフロー実行の承認を必要とする

Approval for running fork pull request

現実的にはRequire approval for all external contributorsを選択するしかないでしょう。外部PRを信頼することは難しいという前提に立つと、「一度でもPRでCI実行を許可した後は、その人の以後のコミットでCIが実行される」というのはあまりにも無防備だからです。依然としてPRの内容を十分確認せずに承認してしまうリスクは残りますが、一度PRをマージさせた後に悪意あるPRを投げてくるようなパターンに対して、意図せずCIが実行されるリスクは減らせます。

後述しますがこのオプションを有効にしていてもpull_request_targetイベントを利用しているワークフローは「初回承認」をバイパスするため、あらゆる外部PRの承認なしCI実行を防ぐオプションにはなりません。

なぜpull_request_targetは避けたいのか

私は、pull_request_targetイベントは、事故を起こさないように使うにはあまりに難しすぎると考えています。たとえLLMでワークフローの内容を検証したとしても、継続的に事故が起こらないように使い続けられる自信がありませんし、リモートアクションを使っていたらなおさらです。

pull_request_targetイベントは、PRのRequire approvalをバイパスして実行されるためワークフローの書き方次第で事故が起きます。permissionsでwrite権限を使っていたり、キャッシュを使っていても問題になります。キャッシュのコンテンツ内容を監査しているOSSは果たしていくつあるかも疑問です。permissionsでreadを徹底するのは抜けやすいことを考えると、pull_request_targetは使わないほうがいいです。3

# ↓ そもそも pull_request_targetイベントは使わないほうがいい。代わりにpull_requestイベントを使う。
on:
  pull_request:
    branches: [main]

これまでに判明しているGitHub Actionsを経由した攻撃の多くは、pull_request_targetイベントを利用しています。個人的には、pull_request_targetイベントを利用しているワークフローはpull_requestイベントに今すぐ差し替えることを強くおすすめします。ほんとうに。

pull_request_targetイベントは次のような特徴があるため、攻撃をする側から見ると「リポジトリオーナーが認識できていない経路」で攻撃しやすいです。

  • 先に紹介した外部PRに対する承認(Require approval)がバイパスして実行される
  • pull_request_targetイベント + actions/checkoutの組み合わせでフォーク先のコードが無条件でチェックアウトされる
  • contents:writeでコード書き換え、actions/cacheでキャッシュポイゾニングが可能 (キャッシュ経由でデフォルトブランチまで侵入できる)

これまでにもpull_request_targetイベントを利用した攻撃が複数回報告されていることから、pull_request_targetイベントが危ない認識は十分広がっており、contents: writepull-requests:writeのようなwrite書き込みをしないことは共通認識でしょう。

しかし2026年5月11日に発生したTanStackインシデントはバンドルサイズを計測するためにpull_request_targetイベントを使っており攻撃を防ぐことができませんでした。経過の詳細は記事を読んでいただきたいですが、トリガーがpull_request_targetイベント、汚染の起点はactions/checkoutactions/cacheでした。キャッシュは本当に制御が難しいです。

TanStackインシデントにみるpull_request_targetの難しさ

TanStackが攻撃されたときのワークフローとアクションは過去のコミットから確認できます。経過を軽く見てみましょう。

攻撃者はTanStack/routerをフォーク。TanStack/routerにPRを作成して、pull_request_targetイベントを利用しているbundle-size.ymlワークフローのactions/checkoutでフォーク先コードを展開、pnpm install + pnpm nx run @benchmarks/bundle-size:buildを実行、vite_setup.mjsを実行してactions/cacheのキャッシュを汚染しました。キャッシュ汚染後、PRのHEADをmain HEADにforce pushしてファイル変更0にしつつPRをクローズ、ブランチが自動削除されています。あとは、正規の開発フローで汚染済みのキャッシュが展開、リリースに混入するのを待つだけです。実際、リリース時に汚染済みのキャッシュが展開され、npmへTrusted Publishing経由で汚染パッケージが公開されました。

PRとワークフローを見てみましょう。

まず、攻撃者はPRを作ります。起点は、TanStack/router/.github/workflows/bundle-size.ymlワークフローのpull_request_targetイベントです。このワークフローはactions/checkoutでフォーク先のコードをチェックアウトし、さらにOrg内部のアクションtanstack/config/.github/setup@mainを呼び出しています。

name: Bundle Size
on:
  # We use `pull_request_target` to split trust boundaries across jobs:
  # - `benchmark-pr` checks out PR merge code and runs it as untrusted with read-only permissions.
  # - `comment-pr` runs trusted base-repo code with limited write access to upsert the PR comment.
  pull_request_target:
  # ... 省略

permissions:
  contents: read

# ... 省略

jobs:
  benchmark-pr:
    name: Benchmark PR
    if: github.event_name == 'pull_request_target'
    runs-on: ubuntu-latest
    outputs:
      current_json_b64: ${{ steps.capture.outputs.current_json_b64 }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6.0.1
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
          fetch-depth: 0
          persist-credentials: false
      - name: Setup Tools
        uses: tanstack/config/.github/setup@main
      - name: Measure Bundle Size
        run: pnpm nx run @benchmarks/bundle-size:build --outputStyle=stream --skipRemoteCache

# ... 省略

呼び出されたTanstack/config/.github/setup/action.ymlアクション(同OrganizationのAction用リポジトリ)でactions/cacheを実行していたため、フォーク元(TanStack/router)のキャッシュがフォーク先コードで汚染されました。

name: Setup Tools
description: Action that sets up Node, pnpm, and caching
runs:
  using: composite
  steps:
    - name: Setup pnpm
      uses: pnpm/action-setup@v4.4.0
    - name: Setup Node
      uses: actions/setup-node@v6.3.0
      with:
        node-version-file: .nvmrc
        package-manager-cache: false
    - name: Get pnpm store directory
      shell: bash
      run: |
        echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
    - name: Setup pnpm cache
      uses: actions/cache@v5.0.4
      with:
        path: ${{ env.STORE_PATH }}
        key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
        restore-keys: |
          ${{ runner.os }}-pnpm-store-
    - name: Install dependencies
      shell: bash
      run: pnpm install --frozen-lockfile

PRでforce pushを多用してpull_request_targetを利用した多重トリガーしたり、最後に差分をなくすなど、攻撃が発覚しにくいように工夫しています。汚染のためにgit force pushを繰り返し行っているのも攻撃にしばしばみられる特徴です。攻撃者のフォークリポジトリは残っているものの、アクティビティを見てもforce pushコミットの内容は確認できなくなっています。残念ながら、第三者が攻撃の内容を追いかけるのは難しい状況です。

インシデントを受けたTanStackは、pull_request_targetからpull_requestへ変更、かつSetup toolsのactions/cacheをやめています。この対応は個人的に納得できるものです。

TanStackの対応

変更後のBundle Size処理を見ると、actions/cacheの代わりに$GITHUB_OUTPUT経由でバンドルサイズを渡してPRへレポートしている。

name: Bundle Size

on:
  # <- もうpull_request_targetは使っていない
  pull_request:
    paths:
      - 'packages/**'
      - 'benchmarks/**'
  # ここでは省略しているがpushトリガーもある

# 省略

jobs:
  benchmark-pr:
    name: Benchmark PR
    if: github.event_name == 'pull_request' # <- イベントを縛っている
    runs-on: ubuntu-latest
    outputs:
      current_json_b64: ${{ steps.capture.outputs.current_json_b64 }} # comment-prに結果を渡す
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
          fetch-depth: 0
          persist-credentials: false

      - name: Setup Tools              # <- もうactions/cacheをしていない
        uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main

      - name: Measure Bundle Size
        run: pnpm nx run @benchmarks/bundle-size:build --outputStyle=stream --skipRemoteCache

      - name: Capture Benchmark Outputs # <- バンドルサイズをエンコードしている
        id: capture
        run: |
          {
            echo "current_json_b64=$(base64 -w 0 < benchmarks/bundle-size/results/current.json)"
          } >> "$GITHUB_OUTPUT"

  comment-pr:
    name: Upsert PR Comment
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    needs: benchmark-pr    # <- benchmark-prの結果を受け取る
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Restore Benchmark Outputs
        env:
          CURRENT_JSON_B64: ${{ needs.benchmark-pr.outputs.current_json_b64 }} # benchmark-prからバンドルサイズの結果を受け取る
        run: |
          mkdir -p benchmarks/bundle-size/results
          node -e "const fs=require('node:fs'); fs.writeFileSync('benchmarks/bundle-size/results/current.json', Buffer.from(process.env.CURRENT_JSON_B64 || '', 'base64'))"

      - name: Read Historical Data (if available)                # <- ベースラインのバンドルサイズを取得
        run: |
          mkdir -p benchmarks/bundle-size/results
          if git fetch --depth=1 origin gh-pages; then
            if git show origin/gh-pages:benchmarks/bundle-size/data.js > benchmarks/bundle-size/results/history-data.js 2>/dev/null; then
              echo "Loaded bundle-size history from gh-pages."
            else
              rm -f benchmarks/bundle-size/results/history-data.js
              echo "No bundle-size history found on gh-pages yet."
            fi
          fi

      - name: Build PR Report
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          REPOSITORY_NAME: ${{ github.event.repository.name }}
          REPOSITORY_OWNER: ${{ github.repository_owner }}
        run: |
          node scripts/benchmarks/bundle-size/pr-report.mjs \
            --current benchmarks/bundle-size/results/current.json \
            --history benchmarks/bundle-size/results/history-data.js \
            --output benchmarks/bundle-size/results/pr-comment.md \
            --base-sha "$BASE_SHA" \
            --dashboard-url "https://${REPOSITORY_OWNER}.github.io/${REPOSITORY_NAME}/benchmarks/bundle-size/"

      - name: Upsert Sticky PR Comment                              # <- PRにコメント
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          node scripts/benchmarks/common/upsert-pr-comment.mjs \
            --pr "${{ github.event.pull_request.number }}" \
            --body-file benchmarks/bundle-size/results/pr-comment.md

pull_request_targetをどうしても使いたい

ここまでの内容を踏まえて、なおpull_request_targetを使うならactions/checkoutは避けましょう。GitHub Actions Permissionsはread権限にとどめ、actions/cacheも禁止にしましょう。可能な限りフォーク先のコードを経由せず、何かを操作できる経路を減らします。

name: Bundle Size

on:
  pull_request_target:
    branches: [main]

jobs:
  example:
    permissions:
      # contents: write      # <- これはダメ
      contents: read         # <- どうしてもチェックアウトするならreadにとどめる
      issues: read           # <- gh issue listで読み取りに使う
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      # - uses: actions/checkout@v6.0.1 <- これはフォーク側をチェックアウトするので避ける
      #   with:
      #     persist-credentials: false
      # - name: Cache                   <- これは絶対避ける
      #   uses: actions/cache@v5.0.4
      #   key: ${{ runner.os }}-foo-bar-${{ hashFiles('**/some-lock.yaml') }}
      - name: list issues without checkout or cache
        run: gh issue list .... # 書き込みとか、チェックアウトしない処理だけにしたい
        env:
          GH_TOKEN: ${{ github.token }}

workflow_runイベントという代替手段の難しさ

GitHubは、pull_request_targetより安全な代替案としてpull_requestworkflow_runイベントの組み合わせを紹介しています。

pull_requestとworkflow_runイベントを組み合わせる例

pull_requestイベントでビルドして、workflow_runイベントでSecrets.FOOなどシークレットが必要な処理を行えば、PRのCIで攻撃されるリスクを減らしつつ、マージ後のリリースで攻撃されるリスクも減らせるという建付けです。

name: Bundle Size

on:
  pull_request:
    branches: [main]

jobs:
  build:
    permissions: {}           # <- 権限をミニマムにする
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - name: Save PR number
        env:
          PR_NUMBER: ${{ github.event.number }}
        run: |
          mkdir -p ./pr
          echo $PR_NUMBER > ./pr/pr_number
      - uses: actions/upload-artifact@v4    # <- workflow_runイベントに渡すためのデータをアップロード
        with:
          name: pr_number
          path: pr/

Bundle Sizeが正常完了したら、別のワークフローworkflow_runイベントをトリガーしてビルド結果のアーティファクトをダウンロードします。これで、PRが起点となりつつも、直接フォーク先のコードをチェックアウトすることなく、ビルド結果だけを利用できます。

on:
  workflow_run:
    workflows: ["Bundle Size"] # 上のワークフロー名
    types: [completed]

jobs:
  download:
    runs-on: ubuntu-latest
    steps:
      - name: 'Download artifact'           # <- 呼び出し元のアーティファクトをダウンロードするためにgithub-scriptを使う
        uses: actions/github-script@v8
        with:
          script: |
            let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
               owner: context.repo.owner,
               repo: context.repo.repo,
               run_id: context.payload.workflow_run.id,
            });
            let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
              return artifact.name == "pr_number"
            })[0];
            let download = await github.rest.actions.downloadArtifact({
               owner: context.repo.owner,
               repo: context.repo.repo,
               artifact_id: matchArtifact.id,
               archive_format: 'zip',
            });
            const fs = require('fs');
            const path = require('path');
            const temp = '${{ runner.temp }}/artifacts';
            if (!fs.existsSync(temp)){
              fs.mkdirSync(temp);
            }
            fs.writeFileSync(path.join(temp, 'pr_number.zip'), Buffer.from(download.data));

      - name: 'Unzip artifact'
        run: unzip "${{ runner.temp }}/artifacts/pr_number.zip" -d "${{ runner.temp }}/artifacts"

      - run: 何かしらの処理     # <- ここでdist/の中身を利用する

とはいえ、このワークフローはdist/を受け渡しているためdist/にビルド結果以外が仕込まれたりすると、dist/経由で攻撃するリスクがあります。この線引きとリスクのバランスをどうするかはプロジェクトの性質や運営方針によるでしょう。

ただ、workflow_runイベントもpull_request_target同様に権限が強すぎて同様に危うさがあります。例えば、workflowがメインブランチ以外もトリガーできるため、トリガーがどこだったか追いにくいという面は制御が難しくなりやすいです。利用は慎重に、可能なら使わないほうがいいでしょう。

on:
  workflow_run:
    workflows: ["Bundle Size"] # <- ワークフロー名を名前で指定できるため、意図しないワークフローもトリガーできてしまう。ワークフローIDのほうが安全そうなのに...
    types: [completed]         # <- ワークフローが完了したときに呼ばれる。フォークPRを起点としたワークフローか、などの制御がない
    # 必要ならブランチも指定できるので、メインブランチだけという保証もない

PRレビューだけで防げるのか

「見落としのないPRレビューは難しい」、これはレビュー経験がある多くの人が同意するところじゃないでしょうか。私自身、baseNN(base64など)エンコードされた文字列、不可視文字をファイルに紛れ込ませたPRを見落とさない自信はありませんし、とても怖いです。

不可視文字を使ったコード侵入といえばGlassWormサプライチェイン攻撃があります。何が書かれているか気づけなかったときを考えると外部PRでCIを承認したくないものの、現実的にはCIで検知するしかないでしょう。一般的なコーディングにおいて不可視文字やエンコーディング文字列を使うシーンが限定されるはずなので、それ自体をはじくのも有効になりえます。もちろん誤検知もあるでしょうが、外部PRの内容を機械的にチェックする仕組みで人間の見落としを減らしましょう。

対応例:

  • 行差分に対して異様な長さの文字列をはじく
  • 不可視文字に相当するUnicodeレンジチェック
  • base64などのエンコードされた文字列、不可視文字列の検出にLLMを併用して解釈ヒント
  • Copilot Code ReviewのようなLLMによるコードレビューをハーネスとして用意する
  • CodeQLを有効にする

少し前までは、CIではビルドやリグレッションテスト、パフォーマンス悪化を確認する程度が一般的でした。しかし、妙なコードが紛れていることを前提にチェックしていくというのも今後増えることでしょう。CIでビルドしかチェックしない牧歌的なやり方の終焉を予感させます。

正直なところ、外部コードをチェックアウトしたくない気持ちがあるため、ユーザー側で外部PRに変な文字列がないか検知するのはあまりやりたくない、というのが本音です。できることならGitHub側でうまく検知して弾けるようにしてほしいです。CodeQLなどGitHub提供のセキュリティ機能で怪しい攻撃パターンへのサポートが強化されると、運用負荷はかなり減るはずです4

フォークコミットを拾える仕組みの問題

GitHubはフォーク元からフォーク先のコミットを拾えるのですが、これがかなり厄介です。

TanStack/routerでフォーク先のコミットを見られるか試しましょう。フォーク先であるsprusr/routerにコミット https://github.com/sprusr/router/commit/60d63821f2a3cc2b9f808da9467ee9618c83e198 があります。 フォーク元のTanStack/routerでこのコミットはまだマージされていないので、本来 https://github.com/TanStack/router/commit/60d63821f2a3cc2b9f808da9467ee9618c83e198 はアクセスできないでほしいのですが、実際はフォーク先のコミットにTanStack/routerリポジトリのコミットURLからアクセスできます。

フォーク先のコミットをフォーク元リポジトリから参照できる

これが問題になるのが、GitHub ActionsのリモートアクションをSHAピンニングするプラクティスです。GitHub Actionsのリモートアクションは、actions/checkout@v6のような書き方ではなくactions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 のようにSHAでピンニングして参照することが推奨されています。バージョンタグで指定しているとバージョン側に不意の変更が入った時に意図せず参照するため、SHAでピンニングして影響を緩和するのが目的です。5

しかしリモートアクションをSHAでピンニングしても、フォーク先のコミットはアクセスできます。攻撃者から見ると、攻撃したいリポジトリで利用しているアクションをフォークして、コードをプッシュ。攻撃したいリポジトリのアクションSHAピンを書き換えるという流れで任意のアクションを呼ばせる余地があります。うかつに承認すれば、任意のコードが差し込めそうですね。

jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - uses: example/actions@0123456789abcdefg # imposter commitの例 (example/actionsではなく example111/actionsというフォークコミットを指しているとする)

ユーザーができる対策は2つあります。

  • Linterによる検知
  • GitHub Actionsファイルの変更を許容しない

そもそも外部PRでGitHub Actionsファイルを変更できるのが問題なので、外部PRでGitHub Actionsファイルの変更を許容しないのは妥当でしょう。触らせないは大事。怪しいコミットを検出Linterとして、私が作っているguitarrapc/seitonzizmorがあります。

  • seiton: Imposter CommitとしてアクションのSHAが、アクションオーナーのコミットであるかを検査するルールを提供
  • zizmor: Imposter CommitとしてアクションのSHAが、アクションオーナーのコミットであるかを検査するルールを提供

検知に限界はあるものの、ほとんどのケースでは有効に検知するはずです。

侵入されたときの被害をどう小さくするか

侵入されないのが一番ですが、侵入される前提で被害を小さくしておくことも大事です。コードやアカウントへの侵入が起きたときのリスクを減らすために、次のような対策が考えられます。

  • デフォルトブランチをforce push禁止にして保護する
  • TOTPからFIDO2/WebAuthnなどのハードウェアキーへの移行を検討する
  • GPG/SSH鍵によるコミット署名を導入して改ざんされたコミットを検知する
  • パッケージマネージャーへのリリース時はTrusted Publishingを利用する
  • VS Code拡張は最低限のもの以外外し、見覚えのないものがないか定期チェック

汚染されたパッケージがリリースされたときに備えて、次のような対策も考えられます。

  • Copilot Code ReviewのようなLLMによるコードレビューをハーネスとして用意する
  • SBOMを有効、公開して、パッケージに何が含まれるかを明示する
  • Attestationを有効にして、リリースされたパッケージがどのようなビルドプロセスを経て作られたかを明示する
  • デフォルトのGITHUB_TOKEN権限を最小にする
  • GitHubのImmutable Releaseを導入して、過去のリリース内容を改ざんできないようにする
  • パッケージ公開フローにGitHub Environmentを使ったレビュー承認を入れる
  • OIDCトークンの利用時間を最小にする

いくつかピックアップして紹介します。

GitHub Copilot Code Reviewを有効にする

GitHub Copilot Code Reviewは、PRをCopilotがレビューしてくれる機能です。単純に自分以外のレビュアーがいると考えてもいいでしょう。PRコードをLLMがレビューしてくれるので、自身が見落としたコードの問題点を指摘してくれる可能性がありますし、プロンプトで重視するポイントをカスタマイズできるので、セキュリティ的な観点を重視するように促すこともできます。

今なら、リポジトリ > Settings > Copilot > Code reviewからManage copilot code review automationsを開き、Copilot code reviewsを自動有効化するブランチルールをワンクリックで設定できます。

Manage copilot code review automations

Branch rules

デフォルトのGITHUB_TOKEN権限を最小にする

リポジトリのGITHUB_TOKENにはデフォルト権限があります。デフォルトでcontents/packages読み取り権限しか持ちませんが、2023年以前に作られたリポジトリはデフォルトでRead and write permissionsの権限がついています。

OrganizationやリポジトリのSettings > Actions > General > Workflow permissionsを見てRead repository contents permissionsに設定しましょう。また、Allow GitHub Actions to create and approve pull requestsもオフにしておくのがいいでしょう。これで、デフォルトGITHUB_TOKENの権限を最小にできます。

Workflow permissions

GitHubのImmutable Releaseは有効にしよう

リポジトリのSettings > GeneralにあるImmutable Releaseはデフォルト無効ですが有効にしましょう。これはかなり強力なので有効にできないか強く検討しましょう。たとえアカウント侵入に成功してGitHubリリース/タグを消すことはできても、同名GitHubリリース/タグを作れなくなります。過去リリースを汚染パッケージでリリースし直すのはよくみられる攻撃手口ですが、Immutable Releaseを入れておけばそれもできなくなります。

Immutable Releaseの設定

Immutable Releaseを有効にすると、次のような動作になります。

  • リリースがあるとき、リリースに紐づくタグは削除できない
  • リリースを消すとタグは削除できる
  • 一度リリースを消すと、同名リリースは作れない
  • 一度タグを削除すると、同名タグは作れない

ちなみにImmutable Releaseを有効にしてから出したリリースは、リポジトリ設定でImmutable Releaseを無効にしてもリリースを上書きできません。動作確認してて嬉しかったことです。素晴らしい。

GitHub Environmentを使ったpublishのレビュー承認

アカウントを乗っ取られると、リリースフローをトリガーされて誰も見てない間にリリースされる可能性があります。これに対抗するためGitHub Environmentを使ってリリースフローにレビュー承認を入れることを検討しましょう。

リポジトリのSettings > Environmentでリリース用Environmentを作ってReview Requiredを設定 + Prevent self-reviewを有効にすることで、リリースワークフローが止まり自分以外の承認がないと進みません。承認する人は、意図したリリースか確認してからリリースを承認するという流れです。

review required

release stop by review required

難点として、GitHub Actionsで完結しているので、1人開発だとGitHubアカウントが乗っ取られたときは突破されます。GitHubから分離されているという面で後述するnpm stageの方が安全なので、npmにリリースする場合はそちらを優先するのがいいでしょう。

OIDCのトークンは使う直前に発行して使用後にExpireさせる

パッケージマネージャーへのOIDCトークンレスパブリッシュは必ずしも安全を担保するものではありません。PATを発行する必要がないので、長期間PATとその管理リスクは減りますが、OIDCで発行したトークン自体は発行からジョブ終了までの間は有効で、攻撃者がトークンを詐取して利用するリスクがあります。

OIDCで発行したトークンは時限的で、job終了時に自動expireするはずなのでトークンを詐取されてもリスクは時間的に限定されます。これがPATではなくTrusted Publishingを使う動機です。とはいえ、トークン発行からジョブ終了まで時間がかかる処理では、その間に詐取したトークンを使われる余地があります。トークン発行は利用直前に行い、「パブリッシュ処理が終わったらすぐにjobを終了させる」とより安全です。

jobs:
  publish:
    permissions:
      id-token: write             # <- OIDCトークンを発行するための権限
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/download-artifact@v4 # <- パッケージをダウンロードしておいて
        with:
          name: my-package
      - name: NuGet Login         # <- パッケージ公開処理の直前でOIDCでトークン発行する
        uses: NuGet/login@v1
        with:
          user: my-nuget-username
      - name: Push package        # <- OIDCトークンを利用してパッケージを公開
        run: dotnet nuget push my-package/*.nupkg --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://www.nuget.org/api/v3/index.json
      # ジョブ終了でトークンもExpireされる

GitHub Actions以外を併用する選択

現時点では、GitHub Actionsで外部PRのCIを実行すること自体がリスクで、うまく制御し続けるのはかなり難しいと感じます。そう考えると、GitHub Actionsだけで完結させず、インターナルなCIを併用する選択肢も十分ありえます。

GitHubリポジトリを分離する案

例えば、リポジトリ(Foo)とは別にCI専用のリポジトリ(Foo-CI)を用意します。public repositoryは無権限でcloneできることを利用して、Fooリポジトリをクローンし、ビルドだけする仕組みのほうが安全といわれればそうです。

  • FooリポジトリではCIしない
  • Foo-CIリポジトリはwriteを一切除去、Foo-CIリポジトリでFooリポジトリをクローンしてビルドだけする

なぜ安全かというと、仮に外部PR経由でFoo-CIリポジトリに侵入、GitHubトークンが漏れてもFoo-CIリポジトリしかreadできず、そもそもFooリポジトリへアクセスができません。トークン詐取によるFooリポジトリのリリース/パッケージ公開もできないので影響をかなり封じ込められます。

問題は、どうやって外部リポジトリであるFoo-CIをFooからトリガーするかです。GitHub Actionsでgh cliからワークフローを実行するのは避けたいところで、やりやすいのはWebhookでしょう。ただ、それはJenkinsと同じようなことをやっているつらさがあります。JenkinsがCI第一世代、GitHub Actionsが第二世代だとすると、やろうとしているのは1.5世代のCIに見える感覚です。

仮にWebhookで組んだとしても、トリガーが追跡しやすいかどうかは別問題です。インシデント発生時にどこから侵入されたかを特定することは、被害範囲を見積もるうえで重要なので、トリガーはできるだけGitHub上で完結しているものが望ましいでしょう。GitHub ActionsのトリガーはPRやpushなどGitHub上で完結するものが多い一方、webhookやRepository Dispatchを使うと経路分析は難しくなりがちです。

CodeBuildなどクラウドCIと分離する案

別のやり方として、GitHub ActionsからはAWS CodeBuild/CodePipelineなどのクラウドCIを呼び出すだけにして、クラウドCI側でリポジトリをチェックアウトしてビルドするというやり方もあるでしょう。GitHub ActionsはクラウドCIを呼び出すだけなので、GitHub Actionsのリスクを減らせます。クラウドCIでどのような処理をしているか公開しなければ、攻撃者から見える情報も減らせます。

プライベートリポジトリや企業内部では以前から見られる方法ですが、OSSでこれを導入することも選択肢の1つです。個人的にはOSSでこのスタイルはとりたくないものの、有効ではあるでしょう。一方で、コラボレーターがクラウドCIへどうアクセスできるようにするかなど、運用の複雑さは問題になります。クラウドCIに詳しい人が限られ、LLMに頼らないと把握しにくいような状態になると、「やっていることがコラボレーター内ですらブラックボックスになる」という別のリスクも生まれます。

セルフホストランナーは避けたほうがいい

インターナルなCIという案を聞くと、セルフホストランナーを立てることを思いつきますが避けましょう。これはGitHubドキュメントでも示されている通り、Hosted Runnerと違ってセルフホストランナーは環境リセットされる保証がなく「ワークフロー内の信頼できないコードによって継続的に侵害される可能性が高い」ためです。Ephemeral Runnerをうまくコンテナで制御できるなら選択肢になりえますが、ほとんどの場合OSSでこの運用負荷に耐えるのは難しいでしょう。

GitHubやパッケージマネージャーに欲しいこと

外部PRや攻撃のリスクを利用者側だけで吸収するのには限界があるので、プラットフォーム側で対策が進んでほしいと感じます。

ビルドとリリースの分離

ビルド(GitHub Actions)とリリース(GitHub Release)やパブリッシュ(id-token)が一緒になっているため、トークンの範囲が広がりやすいのは問題を局所化できない一因です。ビルドはビルド、リリースはリリース、パブリッシュはパブリッシュと影響範囲を分けられるような仕組みがあれば、GitHubアカウントを乗っ取られても、汚染パッケージの公開には至らないように影響を封じ込める余地があります。

この観点で、先日発表されたnpm stageは、まさに欲しかった対応です。ドキュメントを見ると、CIからはステージまでしかリリースできず、ステージから本番リリースするにはTOTP(2要素認証)の入力が必要です。GitHub Actions(CI)はステージまでしかアクセスできないので、GitHubアカウントが乗っ取られただけではnpmにパッケージを展開できません。汚染されたパッケージを展開するには、npmアカウントの乗っ取りとTOTPフィッシングの両方が必要です。

  • CIが関与できる範囲: GitHub Actions (CI) -> npm stage (Trusted Publishing)
  • npm(リリース)で関与する範囲: npm publish (with 2FA)

現時点でステージングパブリッシュができるのはnpmぐらいで、他のパッケージマネージャーやサイトはそもそもステージという概念を持っていないようです6。ビルドとリリースを分離するには、パッケージマネージャー側にステージ + ステージから本番リリース + 本番リリース時に強めの本人認証、という仕組みが必要でしょう。npm stageがうまくいくことと、他言語やパッケージマネージャーに必要性が伝ってほしいです。

OSSのGitHub Actionsは無料のままなのか

GitHub Actionsは、リリースから2026年5月現時点まで、OSSに対しては無料で提供されてきました。めちゃめちゃお世話になっているものの、今後もOSSに対して無料で提供される動機はなくなりつつあると感じます。LLMを使った開発の進展もありますが、CI/CDが普遍的なインフラになりきり、GitHub自身がDependabotやCodeQL実行はユーザーのGitHub Actionsで実行されるようにシフトしています。

GitHubを多用している一人としては、ユーザー自身で独自にCIを準備する未来も想定しています。できれば、GitHubはOSSに対しては引き続き無料でGitHub Actionsを提供してほしいです。望みすぎな気もします。

まとめ

2026年時点では、外部PRを信頼できるかと聞かれた場合、私は「信頼しない前提で扱うしかない」と答えます。外部PRを受け付ける場合、CIを実行するか、どのイベントを許容するか、侵入されたときにどこまで被害を閉じ込められるかもセットで考えましょう。

私自身OSSにコントリビューションをしているので、コントリビューションが来ることは本当にありがたいですし大事にしています。しかしコントリビューションを利用する攻撃が存在する以上は、CI/CDの信頼境界を明確にする方がいいでしょう。ふわっとOSSをするなら、外部コントリビューションを受け付けない割り切りも効果的です。

GitHubやパッケージマネージャー側の改善にも期待していますが、目先のOSS運用としては「外部PRはuntrusted」を前提に考えるのがサプライチェイン攻撃に対する基本姿勢です。LLMの助けも借りつつ、仕組みでなんとかしていきましょう。

参照

ドキュメント

インシデント

ブログ

リポジトリ


  1. GitHub的には仕組みはGitHub Actions v2より仕組みが大きく変わっておらず元よりこのリスクはあったものの、ここ2年程で現実としてリスクを検討するようになったと捉えています。
  2. リポジトリならhttps://github.com/<owner>/<repo>/settings/actions参照
  3. 欲しいのは、権限拡張のないけど、デフォルトブランチ状態で実行できる何かだけど、そんなものはない
  4. CodeQLでこういうのを検知できたことがないのですが
  5. SHAピンニングが叫ばれたのは、tj-actionsにおけるリモートアクションのバージョンタグの書き換えにセキュリティインシデントがきっかけでした。
  6. すくなくともcrate.ioやNuGetは持っていません。

GitHub Agentic WorkflowとCOPILOT_GITHUB_TOKENと個人PATと運用

GitHub Actionsには、GitHub Agentic Workflowという機能があります。これを使うと、自然言語で書いた指示をgh cliでコンパイルするとGitHub Actionsのワークフローが生成されるというものです。昨日一日の作業をサマってIssueにまとめてと書くだけでワークフローが自動生成される、と聞くと面白そうじゃないですか? 実際良いです。

自然言語で書くだけでいいならLLMにやらせればいいのですが、Agentic Workflowは以下のような対策により安全なサンドボックス的な環境で実行されるのがいい点です。

  • ガードレールが内蔵されてプロンプト挿入や悪意のあるリポジトリから守られる
  • エージェントは読み込み専用トークンしか持たない
  • 書き込みトークンや秘密情報を持たない
  • ネットワーク的に隔離されたコンテナで実行される

今回はこのGitHub Agentic Workflowを使ってみて、OrganizationのPrivate Repositoryで使うにあたってはまる点、手放し運用が難しいというメモです。

簡単なまとめ

GitHub Agentic Workflowは手放し運用、Organizationの統制下で使えるという段階ではなく、以下の点に注意が必要です。

  • GitHub Agentic Workflowはsecrets.COPILOT_GITHUB_TOKENCopilot Requests: read権限が必要な個人トークンを求める
  • Organization制御下のPATは本権限が設定できないので、個人トークンを用意して設定する必要がある
  • gh awは頻繁に更新されるため、定期的にgh aw拡張を更新してワークフローファイルもコンパイルし直す必要がある

COPILOT_GITHUB_TOKENが必要

GitHub Agentic Workflowは、secrets.COPILOT_GITHUB_TOKENをワークフローで参照します。このPATにはCopilot Requests: read権限を付与し、Repository(またはOrganization)のSettings > Actions > Secretsに登録しておく必要があります。ワークフローでもこの状態が前提ですCOPILOT_GITHUB_TOKENというシークレットが登録されていなかったり、Copilot Requests: read権限がないとワークフローは失敗します。

以下は、GitHub Agentic Workflowで生成されるYAMLの一部です。

jobs:
  activation:
    runs-on: ubuntu-slim
    permissions:
      actions: read
      contents: read
      # 省略...
      - name: Validate COPILOT_GITHUB_TOKEN secret
        id: validate-secret
        run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
        env:
          COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} # ここで参照/チェックされる
      # 省略...

Secretsがない場合、次のようなエラーが出ます。

Run ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
Error: None of the following secrets are set: COPILOT_GITHUB_TOKEN
The GitHub Copilot CLI engine requires either COPILOT_GITHUB_TOKEN secret to be configured.

Common causes if the secret appears to be configured:
  - Organization secrets must have repository access granted
  - Environment secrets require the job to specify that environment
  - Secret names are case-sensitive - verify exact spelling

Documentation: 'https://github.github.com/gh-aw/reference/engines/#github-copilot-default'
Error: Process completed with exit code 1.

トークンにCopilot Requests: read権限がない場合、Execute GitHub Copilot CLIステップで次のようなエラーが出ます。

[copilot-harness] 2026-05-12T05:33:27.648Z attempt 4: spawning: /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir /home/runner/work/githubactions-lab/githubac
[copilot-harness] 2026-05-12T05:33:27.651Z attempt 4: process started (pid=517)

Authentication failed with provider at http://172.30.0.30:10002 (HTTP 401).
  Check your COPILOT_PROVIDER_API_KEY or COPILOT_PROVIDER_BEARER_TOKEN.

Changes   +0 -0
Duration  47s
[copilot-harness] 2026-05-12T05:33:30.898Z attempt 4: process exit event exitCode=1
[copilot-harness] 2026-05-12T05:33:30.899Z attempt 4: process closed exitCode=1 duration=3s stdout=0B stderr=180B hasOutput=true
[copilot-harness] 2026-05-12T05:33:30.899Z attempt 4 failed: exitCode=1 isCAPIError400=false isMCPPolicyError=false isModelNotSupportedError=false isNullTypeToolCallError=false isAuthError=false hasOutput=true retriesRemaining=0
[copilot-harness] 2026-05-12T05:33:30.899Z all 3 retries exhausted — giving up (exitCode=1)

OrganizationポリシーのPATは使えない

Organizationにも関わらず個人PATを使うと「Organization側でPATを拒否できない」「権限変更の統制が取れない」など運用で問題があるため、PATをOrganizationポリシーで制御することが多いでしょう。

しかしCopilot Requests権限は個人(Account)権限で、Organizationポリシーを適用したPATはAccount権限を持たずOrganization権限に置き換わります。OrgポリシーPATをCOPILOT_GITHUB_TOKENとして使えないことはGitHub Documentに未記載で、GitHub Issue #223で同じ悩みの民を確認できます。gh awリポジトリのIssueでGitHub Appトークンでの認証をサポートしてほしいと要望が出ていますが、現状は個人トークンでしか動かせない状況です。

シークレットの参照元はSecretsから変更できない

importやAgentic Workflowの指示マークダウンでsecrets.COPILOT_GITHUB_TOKENの参照元をSecretsではなく任意のシークレットプロバイダー(1Password/load-secrets-actionなど)に差し替えられないか試みたものの、プロンプトでのsecrets参照部分を差し替えは防御されており難しいようです。とはいえコンパイルされたワークフローは原則触れません。

ワークアラウンド

残念ながら個人トークンを用意してsecrets.COPILOT_GITHUB_TOKENに設定します。Organization所属のPATが使えないため、制御が甘くなるのでかなり嫌な感じで、受け入れられない場合もあるでしょう。その場合、いったんあきらめるしかない状況です。

gh awを更新する

ちなみに、gh awは、ちょくちょく更新されます。なので、GitHub Actionsで動作させていて「gh awでコンパイルされたバージョンが古いので更新しないと実行させないエラー」が出る場合もあります。例えば次のようなエラーを確認しています。

Run actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
Checking compile-agentic version: v0.65.2
Fetching update configuration from: https://raw.githubusercontent.com/github/gh-aw/main/.github/aw/releases.json
Error: Outdated compile-agentic version: v0.65.2 is below the minimum supported version v0.65.3. Update gh-aw to the latest version and recompile your workflow.

兆候で対処するには、手元でgh awを実行していて次のような警告が出たらgh aw拡張を更新しましょう。

$ gh aw compile
✓ .github/workflows/monthly-oss-repo-status.md (73.2 KB)
✓ Compiled 1 workflow(s): 0 error(s), 0 warning(s)

ℹ Compiler upgrade recommended: gh-aw v0.72.1 is behind the latest release v0.74.4.
ℹ Hint: upgrade the compiler with: gh extension upgrade github/gh-aw



A new release of aw is available: 0.72.1 → 0.74.4
To upgrade, run: gh extension upgrade aw
https://github.com/github/gh-aw

gh awの拡張を更新してからワークフローのコンパイルgh aw compileを再度実行すれば、警告が消えます。

$ gh extension upgrade aw
[aw]: upgraded from v0.72.1 to v0.74.4
✓ Successfully checked extension upgrades

$ gh aw compile
✓ .github/workflows/monthly-oss-repo-status.md (76.4 KB)
✓ Compiled 1 workflow(s): 0 error(s), 0 warning(s)

更新すると、.github/aw/actions-lock.jsonにあるパッケージバージョンが更新され、コンパイルして生成されたAgentic WorkflowのYAML、agentic-maintenance.ymlが更新されます。

この挙動は理解できるんですが、GitHub Actionsで動かしていて、1週間とかで突然更新しないと動かせないよ! って言われるのでびっくりします。 しょうがないので、gh extension upgrade awgh aw compileを定期的に実行するのが暫定対応になります。 正式リリース前に何か手が入るんだろうか...

まとめ

GitHub Agentic Workflowは、自然言語で書いた指示をgh cliでコンパイルするとGitHub Actionsのワークフローが生成される機能です。実際いい感じの機能なんですが、使ってみている感じだとまだ手放し運用できる段階ではなく、Organizationの統制下で使う時に個人PATが必要なのはいただけません。とはいえ、GitHub Actions経由のセキュリティ侵害事件が続く中、安全なサンドボックス環境で実行されるのは、ユーザー側ではやりにくい対応です。今後に期待しています。

参考

Directory.Build.propsで指定したPackageReferenceをプロジェクトファイルで上書きする

<PackageReference Include>は使うけど<PackageReference Update>は使わない、そう思っていた時期が私にもありました。今回は、<PackageReference Update>を使うと便利な例です。忘れそうなのでメモです。

CPMとDirectory.Build.propsで全プロジェクトにパッケージを追加

Central Package Managementを使用しているプロジェクトでDirectory.Packages.propsにてパッケージバージョンを指定し、Directory.Build.propsを使って全プロジェクトに暗黙的にパッケージ追加できます。個別のcsprojへパッケージを追加しなくていいのでかなり便利なやり方です。例えば、Microsoft.SourceLink.GitHubのバージョン8.0.0を全プロジェクトで利用したいときは、以下のようにします。

<!-- Directory.Packages.props -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
  </ItemGroup>
</Project>
<!-- Directory.Build.props -->
<Project>
  <ItemGroup>
    <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="all"/>
  </ItemGroup>
</Project>

プロジェクトファイルでは、Microsoft.SourceLink.GitHubを指定しなくてもビルド時にパッケージが入ります。

<!-- Normal.csproj -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
// Program.cs
Console.WriteLine("Hello, World!");
$ dotnet build
Restore complete (0.6s)
  Normal net10.0 succeeded (1.1s) → bin/Debug/net10.0/Normal.dll

Build succeeded in 2.6s

どのような時に困るのか

ただ、時々プロジェクトファイルでCPMを無効にしたくなる時があります。例えば、ライブラリ開発しているとプロジェクト参照ではなく、過去リリースしたNuGetパッケージで動作テストしたくなることがあります。この場合、プロジェクトファイルでCPMを無効にして、個別のプロジェクトファイルでPackageReferenceを追加します。

検証してみましょう。csprojをいじって、ビルド時にUseNuGetを指定するとCPMを無効にして任意のNuGetパッケージを追加するようにします。

<!-- Normal.csproj -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <!-- ここから追加 -->
  <PropertyGroup Condition="'$(UseNuGet)' != ''">
    <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup Condition="'$(UseNuGet)' != ''">
    <PackageReference Include="MagicOnion.Client" Version="$(UseNuGet)"/>
  </ItemGroup>
  <!-- ここまで -->

</Project>

これをビルドするとビルドエラーが発生します。原因は、Directory.Build.propsで指定されているMicrosoft.SourceLink.GitHubが追加されますが、Directory.Build.propsではバージョンを指定していないためです。それはそう。

$ dotnet build -p:UseNuGet=6.1.4
    ./Override/Normal.csproj : error NU1015: The following PackageReference item(s) do not have a version specified: Microsoft.SourceLink.GitHub

Restore failed with 1 error(s) in 1.2s

具体的な状況はdepsファイルで確認できます。Microsoft.SourceLink.GitHubのバージョンが"version": "(, )"になっており、指定されていないことがわかります。

# プロジェクト名がNormal.csprojなので、Normal.csproj.nuget.dgspec.jsonを見ます。
$ cat obj/Normal.csproj.nuget.dgspec.json
...省略

      "frameworks": {
        "net10.0": {
          "targetAlias": "net10.0",
          "dependencies": {
            "MagicOnion.Client": {
              "target": "Package",
              "version": "[6.1.4, )"
            },
            "Microsoft.SourceLink.GitHub": {
              "suppressParent": "All",
              "target": "Package",
              "version": "(, )"
            }
          },

...省略

一見するとNuGetパッケージを指定するときと同様に、.csprojに<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0"/> を追加すればよさそうです。が、これはDirectory.Build.propsと定義が重複するのでエラーになります。

<!-- Normal.csproj -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <!-- ここから追加 -->
  <PropertyGroup Condition="'$(UseNuGet)' != ''">
    <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup Condition="'$(UseNuGet)' != ''">
    <PackageReference Include="MagicOnion.Client" Version="$(UseNuGet)"/>
    <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0"/> <!-- ← これを追加 -->
  </ItemGroup>
  <!-- ここまで -->
</Project>

ビルドエラーからパッケージ定義が重複していることがわかります。

$ dotnet build -p:UseNuGet=6.1.4
./Bad/Normal.csproj : warning NU1504: Duplicate 'PackageReference' items found. Remove the duplicate items or use the Update functionality to ensure a consistent restore behavior. The duplicate 'PackageReference' items are: Microsoft.SourceLink.GitHub , Microsoft.SourceLink.GitHub 8.0.0.
    ./Bad/Normal.csproj : error NU1015: The following PackageReference item(s) do not have a version specified: Microsoft.SourceLink.GitHub

Restore failed with 1 error(s) and 1 warning(s) in 1.2s

Includeだと既存の定義があっても新しい定義を追加しようとするんですね。Directory.Build.propsで指定したPackageReferenceをcsprojで上書きしたいときは、<PackageReference Update="..." Version="上書きしたいバージョン" />を使います。

<!-- Normal.csproj -->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <!-- ここから追加 -->
  <PropertyGroup Condition="'$(UseNuGet)' != ''">
    <ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup Condition="'$(UseNuGet)' != ''">
    <PackageReference Include="MagicOnion.Client" Version="$(UseNuGet)"/>
    <PackageReference Update="Microsoft.SourceLink.GitHub" Version="8.0.0"/> <!-- ← Updateを使う -->
  </ItemGroup>
  <!-- ここまで -->
</Project>

ビルドが成功していますね。

dotnet build -p:UseNuGet=6.1.4
Restore succeeded with 1 warning(s) in 2.4s
Build succeeded with 2 warning(s) in 7.5s

まとめ

もし、どこかですでに<PackageReference Include="..."/>と定義したパッケージをプロジェクトファイルで上書きしたいときは、<PackageReference Update="..." Version="X.Y.Z"/>を使うと上書きできます。

状況的に、Directory.Build.propsやカスタムprops読み込みを使っていない限りは遭遇しないでしょう。ただ、csproj管理の効率化のためにDirectory.Build.propsを使っているときは、思いがけず遭遇する可能性があります。

今ならこういうのはLLM聞けば? ってなるのですが、割とこういうファイルを探索する系はLLMもすんなり答えにたどり着かないことがあります。基礎知識ということで。なお、Microsoft Learnにはこの記載がないので、なかなか気づけないです。

参考

.NET9 SDKから dotnet add package コマンドが dotnet package add コマンドに変更された

.NET SDKを使うと、dotnetコマンドを使ってビルドやパッケージ追加、プロジェクト作成などができます。さて、パッケージ追加コマンドは.NET8までdotnet add packageでしたが、.NET9からdotnet package addにコマンドが変更されました。.NET9はSTSなので、LTSとしては.NET 10からの変更です。

今回はこの変更について見ていきます。

dotnetコマンドの一覧

dotnetコマンド体系は、基本的にdotnet [sdk-options] [command] [command-options] [arguments]の形で構成されています。1SDKから実行するビルドなどのコマンドとしては、dotnet builddotnet rundotnet testなどがあります。このコマンドは、コマンドとして実行したいbuildnewruntestは動詞として用意されていますが、他は名詞先行のコマンド体系になっています。

以下は.NET 8でのヘルプですが、パッケージ追加はdotnet add package、リファレンス追加はdotnet add referenceのように、動詞先行のコマンド体系も混じっていました。

$ dotnet --version
8.0.418

$ dotnet --help
... 省略
SDK commands:
  add               Add a package or reference to a .NET project.
  build             Build a .NET project.
  build-server      Interact with servers started by a build.
  clean             Clean build outputs of a .NET project.
  format            Apply style preferences to a project or solution.
  help              Show command line help.
  list              List project references of a .NET project.
  msbuild           Run Microsoft Build Engine (MSBuild) commands.
  new               Create a new .NET project or file.
  nuget             Provides additional NuGet commands.
  pack              Create a NuGet package.
  publish           Publish a .NET project for deployment.
  remove            Remove a package or reference from a .NET project.
  restore           Restore dependencies specified in a .NET project.
  run               Build and run a .NET project output.
  sdk               Manage .NET SDK installation.
  sln               Modify Visual Studio solution files.
  store             Store the specified assemblies in the runtime package store.
  test              Run unit tests using the test runner specified in a .NET project.
  tool              Install or manage tools that extend the .NET experience.
  vstest            Run Microsoft Test Engine (VSTest) commands.
  workload          Manage optional workloads.

.NET 9以降、先のコマンドがdotnet package adddotnet reference addのように名詞先行のコマンド体系で扱えます。

$ dotnet --version
10.0.103

$ dotnet --help
... 省略
SDK commands:
  build             Build a .NET project.
  build-server      Interact with servers started by a build.
  clean             Clean build outputs of a .NET project.
  format            Apply style preferences to a project or solution.
  help              Opens the reference page in a browser for the specified command.
  msbuild           Run Microsoft Build Engine (MSBuild) commands.
  new               Create a new .NET project or file.
  nuget             Provides additional NuGet commands.
  pack              Create a NuGet package.
  package           Search for, add, remove, or list PackageReferences for a .NET project.
  publish           Publish a .NET project for deployment.
  reference         Add, remove, or list ProjectReferences for a .NET project.
  restore           Restore dependencies specified in a .NET project.
  run               Build and run a .NET project output.
  sdk               Manage .NET SDK installation.
  solution          Modify Visual Studio solution files.
  store             Store the specified assemblies in the runtime package store.
  test              Run unit tests using the test runner specified in a .NET project.
  tool              Install or manage tools that extend the .NET experience.
  vstest            Run Microsoft Test Engine (VSTest) commands.
  workload          Manage optional workloads.

変更があったのはパッケージ関連とリファレンス関連です。例えば、パッケージ追加はdotnet package add、リファレンスの追加はdotnet reference addになっています。旧来のコマンドもエイリアスとして残っていますが、今後は新しいコマンドを使うといいでしょう。

New noun-first form (.NET9+) Alias for (.NET8-)
dotnet package add dotnet add package
dotnet package list dotnet list package
dotnet package remove dotnet remove package
dotnet reference add dotnet add reference
dotnet reference list dotnet list reference
dotnet reference remove dotnet remove reference

一般的にCLIのコマンドは名詞先行で用意されていることが多く、dotnetにおいてはdotnet adddotnet listは一意に何をするのか一貫性がないというのが起点のようです。たしかに一貫性は大事。

まとめ

dotnet packageには、add以外にlistremoveもあるので、パッケージを操作するというのが迷いにくい名詞先行になったのは悪くなさそうです。個人的には、一般的に動詞先行のCLIコマンドが多い気もするのですが、dotnetとして一貫性があればいいと感じます。

実際のところ、パッケージ追加コマンドを始めてみたときにdotnet add packageだったのは違和感がありました。慣れてしまって忘れていましたが、当時を思い返すとdotnet package ...でコマンドが統一されるのは使いやすいと感じます。はじめは戸惑いますが、エイリアスもあるので焦らず慣れていくといいでしょう。

参考

リリースノート

Issue

redditでも好みが分かれる話だったりします


  1. [sdk-options]--list-sdksとかのdotnetコマンド自体のオプションなので、ここでは省略します。

Windows 11でウィンドウスナップのサイズがおかしくなった時にリセットする

Windows 11の便利操作の1つがウィンドウスナップです。ウィンドウを画面の端にドラッグすると自動的にサイズ調整されて配置されるあの機能です。今回はこのスナップでウィンドウサイズが変になった時、スナップサイズをリセットする方法を紹介します。長年困ってました。

ウィンドウスナップとは

モニターの真ん中上にウィンドウを持っていくと、スナップ配置の選択肢が表示されます。ウィンドウスナップは、この配置にウィンドウを自動的にリサイズして配置する機能です。複数ウィンドウを並べて使うときに重宝するんですよね。

スナップの例

例えば、横ディスプレイの右上にウィンドウを持っていくと、ディスプレイを4分割した右上に配置されます。

横ディスプレイの右上スナップ例

縦ディスプレイの真ん中右ににウィンドウを持っていくと、ディスプレイを縦3分割した真ん中に配置されます。

縦ディスプレイのスナップ例

キーボード操作でWindowsキー + 矢印キーでもスナップ操作ができます。例えば、Windowsキー + 右矢印キーを押すと、ディスプレイの右半分にウィンドウがスナップされます。

Windows+右矢印でディスプレイの右半分にスナップされる

さらにもう一度右矢印キーを押すと、隣のディスプレイの左半分にスナップされます。

右にディスプレイがもう一枚ある時にさらにWindows+右矢印をすると、そのディスプレイの左半分にスナップされる

Windowsキー + 上矢印Windowsキー + 下矢印で上下のスナップができるため、キーボードだけでウィンドウを自在に配置できます。便利。

ウィンドウスナップのサイズがおかしくなった時の対処法

ウィンドウスナップは便利ですが、たまに変なサイズにスナップされることがあります。例えば、横ディスプレイの右半分にスナップしたいのに、なぜか8分の1の幅でスナップされてしまうことがあります。この症状が一度起こると、再起動しても直りません。スナップを多用しているので長い間困っていました。

Windows+右矢印のスナップサイズがおかしい

この症状を解消するには、以下の手順を行います。

  1. Settings > Multi-tasking settingsを開く
  2. Snap Windowsメニューが現在ONになっていることを確認
  3. 一度OFFに切り替える
  4. 再びONにする

これで変なウィンドウサイズにスナップされる症状が解消します。

スナップがOnになっている

スナップをOffにする

まとめ

ずっとわからないまま放置していたのですが、ふと「ウィンドウスナップが嫌だからOFFにしている」という記事を見かけて、どのような感じかとOFFにしたら不便でした。で、再度ONにしたら困っていた症状が直りました。2年前に知りたかった。

この記事が同じ症状で困っている方の参考になれば幸いです。