---
title: "Renovate の Auto-merge 対象から Digest を外した"
description: "GitHub Actions の commit SHA Pin は、その SHA を書き換える Renovate の PR を人が見なければ守りになりません。SHA Pin とフル semver のコメントが揃っていれば Digest 更新が単独で来るのはリリース済みのタグが動いたときだけなので、ラビーの共有 Preset の `automerge` 対象から Digest を外しました。`minimumReleaseAge` では止められない理由、Pin のコメントの揃え方、先に試した例外ルール、タグ Ruleset と `sha_pinning_required` も記録します。"
category: "DevOps"
tags: ["Renovate","GitHub Actions","Security","CI/CD","DevOps"]
publishedAt: "2026-08-27"
lastmod: "2026-08-27"
---

GitHub Actions を commit SHA で Pin していても、その SHA を書き換える [Renovate](https://docs.renovatebot.com/) の PR が人の目を通らずにマージされるなら、Pin は攻撃を防がず、次の Renovate 実行まで遅らせるだけです。

ラビーで共有している Renovate の Preset（`LabeeHive/renovate-config`）の `automerge` 対象から Digest を外しました。残したのは `minor`、`patch`、`pin` の3種類です。

```json5
{
  packageRules: [
    {
      matchUpdateTypes: ["minor", "patch", "pin"],
      automerge: true,
    },
  ],
}
```

::card[https://github.com/LabeeHive/renovate-config]

SHA Pin とフル semver のコメントが揃っていれば、通常のリリースは Patch や Minor の更新として届き、新しい SHA もその中に含まれます。Digest 更新が単独で来るのは、リリース済みのタグが別のコミットへ動いたときだけです。それは必ず人が確認すべきことなので、Digest は最初から `automerge` の対象に入れるべきではありませんでした。

前に書いた2本の記事は、SHA Pin と Renovate の `automerge` をセットで勧めていました。この記事はその続きで、どこに穴があったか、`minimumReleaseAge` ではなぜ止められないか、Pin のコメントの書き方で何が変わるか、先に試した例外ルールをなぜ採らなかったかを記録します。

::card[https://labee.jp/posts/github-actions-best-practices-rollout]

::card[https://labee.jp/posts/renovate-cloudflare-workers-grouping]

## 前の2本が勧めていたこと

1本目では、サードパーティアクションを commit SHA で Pin し、横に `# v7.0.1` のようにバージョンをコメントで残し、更新は Renovate に任せると書きました。

2本目では、その Renovate の設定を載せています。`minor`、`patch`、`pin`、`digest` の4種類を `automerge` の対象にしていました。設定の置き場所は当時の `LabeeHive/.github` から `LabeeHive/renovate-config` に移っていますが、このルールはそのまま引き継がれていました。

どちらも単体では間違いではありません。2つを重ねると「SHA Pin を書き換える PR は CI さえ通れば誰も見ずにマージされる」状態になります。

1本目の中でも、SHA Pin の節では「人がレビューしてからマージできます」と書き、Renovate の節では「CI が通れば自動マージされます」と書いていて、同じ記事の中で食い違っていました。Pin は参照を固定しますが、参照を書き換える側までは縛っていません。

## 張り替えられたタグは Renovate の PR になって入ってくる

2025年3月の [tj-actions/changed-files の事件](https://github.com/advisories/GHSA-mrrh-fwg8-r2c3)では、既存のバージョンタグがまとめて悪意あるコミットに張り替えられました。タグ参照で使っていたリポジトリは、次の実行でそのコミットを取り込みます。SHA Pin なら、その時点では古いコミットを指したままなので影響を受けません。

ただし Renovate が動くと話が変わります。Renovate は Pin された SHA の横にあるコメント（`# v7` や `# v7.0.1`）を現在のバージョンとして読み、そのタグが今どのコミットを指しているかを GitHub に問い合わせ、違っていれば新しい SHA に書き換える PR を作ります。これが Digest 更新です。

メンテナーが `v7` を新しいリリースに進めた場合も、攻撃者が `v7` を別のコミットに張り替えた場合も、Renovate から見ればどちらも同じ Digest 更新で、両者を見分ける材料がありません。

その先で何が起きるかは、2段階で決まります。1段目は Pin のコメントです。`# v7.0.1` のようにフル semver で書いてあれば、`v7.0.2` のリリースは Patch 更新として届き、`v7` タグが動いただけでは何も来ません。`# v7` のようにメジャーだけなら、リリースも張り替えも同じ Digest 更新になります。仕組みは後の節で追います。

2段目は、その Digest 更新を `automerge` するかどうかです。前の記事の設定では Digest も `automerge` の対象だったので、張り替えられたタグは次の Renovate の実行で、CI が通りさえすればデフォルトブランチに入っていました。

![同じ「v7 タグが別のコミットを指す」出来事が、Pin のコメントと Digest 更新の automerge 設定でどう扱われるか。# v7.0.1 なら Patch 更新として届き、これまで通り automerge される。# v7 なら Digest 更新になり、Digest も automerge する前の設定では CI が通れば誰も見ずにマージされ、Digest を automerge しない今回の設定では人がレビューしてからマージする](/images/posts/renovate-github-actions-digest-automerge-off/tag-move-fork.svg)

SHA Pin は攻撃を防いでおらず、次の実行まで遅らせているだけでした。残るゲートは CI だけです。

その CI も、`:skipStatusChecks` を `extends` に入れていたリポジトリがいくつかあり、そこでは CI の結果を待たずにマージされていました。こちらは先に外しています。

## `minimumReleaseAge` では止められない

`automerge` から外す前に、Digest 更新を `minimumReleaseAge: "7 days"` のように寝かせれば足りるかどうかを確かめました。足りません。

[Renovate の minimumReleaseAge の解説](https://docs.renovatebot.com/key-concepts/minimum-release-age/#digest-updates)によれば、Digest 更新は現在値に一致するバージョンのリリース時刻からの経過時間で判定されます（2026年7月30日リリースの 44.3.1 から）。問題はその「リリース時刻」の出どころです。

`github-tags` データソースが返すのは、タグが今指しているコミットの日付（注釈付きタグならタグを作った人の日付）で、タグが Push された時刻ではありません。コミットの日付は作る側が決める値なので、張り替えた当日でも7日の条件を素通りします。同じドキュメントが、Force Push されたタグの Digest 更新は `minimumReleaseAge` を即座に通り得る、と明記しています。

Docker Hub は `tag_last_pushed` を返すので、タグを Push し直せば7日の数え直しになります。同じ `minimumReleaseAge` でも、データソースによって守れる範囲が違います。

![minimumReleaseAge が Digest 更新の時刻として読む値の違い。github-tags はタグが今指すコミットの日付を読み、それはコミットを作る側が決める値なので、張り替えた当日でも Digest 更新が7日の条件を素通りして PR になり得る。Docker Hub はタグを Push した時刻 tag_last_pushed を読むので、Push し直すと7日の数え直しになる](/images/posts/renovate-github-actions-digest-automerge-off/release-age-timestamp.svg)

ラビーの Preset では、GitHub Actions の更新を寝かせる設定がそもそもありません。`extends` している `config:best-practices` が `minimumReleaseAge` を設定するのは npm だけ（`security:minimumReleaseAgeNpm`、3日）です。日数を設定しても上の理由で Digest 更新には効かないので、`automerge` から外すのが答えになります。

## Pin のコメントの粒度で `updateType` が変わる

Digest 更新を `automerge` から外すと決めたあと、影響範囲を洗い出す前に確かめておくべきことがありました。どの更新が Digest に分類されるかは、SHA ではなく横のコメントで決まります。Renovate のソースで追うと、次の3段です。

`github-actions` Manager は、`uses:` の参照がフル SHA なら、横のコメントを `currentValue`、SHA を `currentDigest` として取り出します。コメントが無い Pin は `currentValue` が決まらず `unversioned-reference` として更新対象から外れるので、コメントは飾りではなく、これがあるから Pin を更新できます。

`lookup` は、`currentDigest` がある依存に対して、バージョン更新の候補とは別に `updateType: 'digest'` の候補を必ず積みます。候補ごとに `newDigest` を解決し、`newValue` も `newDigest` も変わっていない候補は最後に捨てられます。

バージョンの比較には GitHub Actions 専用の Versioning を使います。その `getNewValue()` は、現在値が `v7` のようにメジャーだけで、新しい版が同じメジャーの `v7.0.2` なら、`v7` をそのまま返します。

返ってきた値が現在値と同じなので、`lookup` はそのバージョン更新を捨て、残るのは `v7` タグが指す先が変わったときの Digest 更新だけです。ドキュメントの例もこの形で、`# v7` のまま SHA だけが書き換わります。

```yaml
# コメントがフル semver なら、v7.0.2 のリリースは patch 更新になる
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# コメントがメジャーだけなら、v7 タグが動いたときの digest 更新しか来ない
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
```

コメントが `v7.0.1` なら、`getNewValue()` は `v7.0.2` を返し、更新は Patch に分類されて、新しい SHA も同じ更新に `newDigest` として含まれます。2つの書き方で、同じ出来事がどう見えるかを並べるとこうなります。

| コメント | `v7.0.2` がリリースされ、`v7` タグもそこへ進んだ | `v7.0.1` タグが別のコミットに張り替えられた | `v7` タグが別のコミットに張り替えられた |
|---|---|---|---|
| `# v7.0.1` | Patch 更新 | Digest 更新 | 何も起きない |
| `# v7` | Digest 更新 | 何も起きない | Digest 更新 |

フル semver のコメントでは、Digest 更新が来るのは「リリース済みのバージョンタグが動いた」ときだけです。リリースタグは動かないはずなので、これは必ず人が確認すべきもので、手動レビューに回す判断と噛み合います。

メジャーだけのコメントでは、通常のリリースも張り替えも同じ Digest 更新になります。Digest を `automerge` から外すと、そのアクションは **メジャー以外の更新が全部手動マージ** になります。

Digest を `automerge` から外す前提は、Pin のコメントがフル semver で揃っていることです。揃っていなければ、外した分だけ手動マージが増えます。`# v7` を `# v7.0.1` に直す作業は、SHA が実際にどの semver タグに対応するかをタグ一覧から引いてコメントだけを書き換えるもので、SHA 自体も CI の挙動も変わりません。

Renovate には [`helpers:pinGitHubActionDigestsToSemver`](https://docs.renovatebot.com/presets-helpers/) という Preset があり、この作業を肩代わりしてくれそうに見えますが、使っていません。`extractVersion` で `vX.Y.Z` に一致するタグだけを候補にするので、`DeterminateSystems/nix-installer-action` のようにメジャータグしか公開していないアクションは候補がゼロになり、PR が来なくなります。Renovate のログには「バージョンに見える結果が無い」と残りますが、PR が来ないこと自体に気づく仕組みはありません。

そうしたアクションは、メジャーだけのコメントのまま、更新が全部手動マージになることを受け入れています。バージョン更新とタグの張り替えを原理的に区別できないケースなので、まさに人が見るべき対象です。

## 先に試した例外ルールと、採らなかった理由

ラビーの Preset を変える前に、個人アカウント側の Preset で、GitHub Actions の Digest 更新だけを `automerge` から外す例外ルールを試しました。

```json5
{
  packageRules: [
    {
      matchUpdateTypes: ["minor", "patch", "pin", "digest"],
      automerge: true,
    },
    {
      matchManagers: ["github-actions"],
      matchDepTypes: ["action"],
      matchUpdateTypes: ["digest"],
      automerge: false,
    },
  ],
}
```

`matchDepTypes: ["action"]` を付けているのは、`github-actions` Manager が `uses:` 以外も扱うからです。`jobs.<id>.container.image` や `services` のイメージも同じ Manager が読み、`depType` はそれぞれ `container` と `service` になります。Manager だけでマッチさせると、E2E テストで使うコンテナーイメージまで巻き込みます。

この形にしたのは、Docker イメージの Digest 更新を `automerge` に残したかったからです。タグを張り替えられるという理屈は Docker にも当てはまりますが、実証できた仕組みは `github-tags` の時刻の取り方に固有のもので、範囲を広げるとコンテナー更新のレビューの手間が増えます。

ラビーの Preset では、この例外ルールを持ち込まず、`automerge` の対象から Digest を外しました。Pin のコメントをフル semver にしてあれば、Digest 更新が単独で来るのはリリース済みのタグが動いたときだけです。

リリース済みのタグが動いたら Manager を問わず人が見る、という線引きにしたので、Manager ごとに例外を書く理由がありません。Docker イメージの Digest 更新も `automerge` されなくなります。

## 自分のタグも張り替えられないようにした

自分が公開している側のタグも、同じ張り替えの対象です。タグを対象にした [Ruleset](https://docs.github.com/en/rest/repos/rules) を入れました。ルールは `update`、`deletion`、`non_fast_forward` の3つで、`creation` は入れていません。

`creation` を入れなかったのは、新規タグの作成を禁じるとリリースが打てなくなり、バイパスできるのはリポジトリ管理者、つまりオーナー自身だけになるからです。オーナーのトークンを持っている攻撃者は `creation` で止まらないので、入れても守れる範囲が増えません。

合わせて、リポジトリ設定の [`sha_pinning_required`](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository) を有効にしました。すでに全 Pin が SHA になっているので、現状を固定するだけで副作用はありません。設定は API から確認できます。

```bash
gh api repos/<owner>/<repo>/actions/permissions --jq '.sha_pinning_required'
```

この設定がリポジトリ内のローカルアクション（`uses: ./`）を弾くかどうかは、ドキュメントに書かれていません。`uses: ./` を使う CI で有効にしたあとに走らせて確かめたところ、Ubuntu と macOS の両方でジョブは成功しています。実測の結果であってドキュメント上の保証ではないので、GitHub 側が挙動を変えれば止まります。