CI for an ASP.NET MVC 5 app on .NET Framework 4.8 with GitHub Actions
An ASP.NET MVC 5 application on .NET Framework 4.8 needs a repeatable build long before it needs automated production deployment. A good first workflow restores packages, compiles the solution, runs the existing tests and produces a web deployment package someone can inspect.
This is a setup guide built from official documentation that I re-read on November 2, 2026, and the workflow below is not just a sketch: I ran it unchanged against a minimal public sample, legacy-mvc48-ci-sample, an MVC 5 app on .NET Framework 4.8 with two MSTest tests. That run on windows-2022 restored, built, passed 2 of 2 tests, produced the package and uploaded both artifacts. A real application will have more projects and dependencies, so the paths and names are still yours to adapt.
The Windows runner
GitHub's .NET build and test guide is written around the dotnet CLI, which fits SDK-style projects. A classic MVC 5 web project with packages.config is usually built with Visual Studio's MSBuild instead, so the runner matters.
The Windows Server 2022 runner inventory, image version 20261026.347.1 when I checked, lists Visual Studio Enterprise 2022, the MSBuild component, vswhere and .NET Framework 4.7.2, 4.8 and 4.8.1. That inventory changes over time, so record the image version shown in each workflow run.
Write down the repository contract first
Before touching YAML, list the solution path, the web project, the test projects, the build configuration and what the package should be called. Also list everything that only exists on a developer's machine: private NuGet feeds, DLLs referenced from a local folder, generated files, custom build tasks. Each one either gets an authorized way into CI or becomes an explicit blocker. That list is usually where legacy builds fail, not in the YAML.
Restore packages.config dependencies
Microsoft's nuget restore reference accepts a solution file, so nuget restore LegacyMvc.sln restores every packages.config in it. Keep it as its own step so a restore failure is not confused with a compile error.
NuGet/setup-nuget puts nuget.exe on the runner; its latest release on November 2 was v4.0. nuget-version: latest is convenient for a first run, and the README also accepts version ranges such as 5.x, so pin one once the build works. If you use private feeds, supply credentials through secrets and the feed's documented method, never by committing a developer's NuGet.Config.
Build, test, package
microsoft/setup-msbuild finds MSBuild with vswhere and adds it to PATH. Its README is explicit that it "does NOT add other Visual Studio tools (like VSTest…)" to PATH, so the test step below locates vstest.console.exe itself. The MSBuild switches used here (/m, /t, /p) are in the MSBuild command-line reference.
For packaging, Microsoft's guide to building and packaging web application projects shows DeployOnBuild=true with DeployTarget=Package: after the build, the Web Publishing Pipeline runs its Package target and produces [project name].zip, plus a .deploy.cmd and a SetParameters.xml next to it. The template searches for that zip instead of assuming a folder.
Tested on the sample repository above, on November 2, 2026. Save an adapted copy as .github/workflows/legacy-mvc.yml. The action versions are the current major tags from each project's README or releases page; for stricter supply-chain control, pin reviewed commit SHAs.
name: Legacy MVC CI
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: windows-2022
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@v7
- uses: microsoft/setup-msbuild@v3
- uses: NuGet/setup-nuget@v4
with:
nuget-version: latest
- name: Restore packages.config dependencies
run: |
nuget restore LegacyMvc.sln -NonInteractive
if ($LASTEXITCODE -ne 0) { throw 'Restore failed' }
- name: Build solution
run: |
msbuild LegacyMvc.sln /m /t:Build /p:Configuration=Release '/p:Platform=Any CPU'
if ($LASTEXITCODE -ne 0) { throw 'Build failed' }
- name: Run existing tests
run: |
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
$vs = & $vswhere -latest -products '*' -property installationPath
$vstest = Join-Path $vs 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe'
$tests = 'tests\LegacyMvc.Tests\bin\Release\LegacyMvc.Tests.dll'
if (!(Test-Path $vstest)) { throw 'vstest.console.exe not found' }
if (!(Test-Path $tests)) { throw 'Test assembly not found' }
& $vstest $tests '/Logger:trx;LogFileName=tests.trx' '/ResultsDirectory:TestResults'
if ($LASTEXITCODE -ne 0) { throw 'Tests failed' }
[xml]$trx = Get-Content 'TestResults\tests.trx' -Raw
if ([int]$trx.TestRun.ResultSummary.Counters.total -lt 1) { throw 'No tests were discovered' }
- name: Create web deployment package
run: |
msbuild src\LegacyMvc\LegacyMvc.csproj /p:Configuration=Release /p:DeployOnBuild=true /p:DeployTarget=Package
if ($LASTEXITCODE -ne 0) { throw 'Packaging failed' }
$zip = Get-ChildItem src\LegacyMvc -Recurse -Filter 'LegacyMvc.zip' | Select-Object -First 1
if (!$zip) { throw 'LegacyMvc.zip not found' }
New-Item -ItemType Directory -Force artifacts | Out-Null
Get-ChildItem $zip.DirectoryName | Copy-Item -Destination artifacts
- name: Upload package
uses: actions/upload-artifact@v7
with:
name: legacy-mvc-package
path: artifacts/
if-no-files-found: error
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: legacy-mvc-test-results
path: TestResults/
if-no-files-found: warn
Make "tests ran" an actual check
The VSTest.Console options cover the test assembly arguments, /Logger and /TestAdapterPath. MSTest, NUnit and xUnit each need their adapter available to the runner; if discovery fails on CI but works locally, /TestAdapterPath is the first thing to look at. In the sample, MSTest 4.5.2 installed through packages.config was discovered without it.
The template reads the TRX file and fails when zero tests were reported, because a test step that exits cleanly after discovering nothing looks green and proves nothing. If the repository has no tests, say so in the handover instead of advertising coverage. And remember what unit tests don't cover: IIS configuration, the real database and browser behavior need their own checks.
Inspect the package before trusting it
Download the artifact and open it. The same Microsoft guide explains that SetParameters.xml is generated from the project and can include connection strings found in web.config, so this is exactly where a production connection string can leak into an artifact. A zip that exists is a good sign, not proof that it deploys.
If the project already has reviewed publish settings, you can keep them in a profile under Properties/PublishProfiles/ and select it with PublishProfile, as Microsoft's command-line deployment tutorial does. That tutorial lists three command-line publish methods, MSDeploy, Package and FileSystem; for CI you want the profile to use Package, not publish straight to a server. It was written for Visual Studio 2012 and notes that building a single web project then also needed /p:VisualStudioVersion, so if MSBuild cannot find the web application targets, that property is worth checking.
Keep production behind a separate gate
No passwords, tokens or production connection strings in the YAML or in committed publish profiles. When you add a deployment job later, point it at a protected environment with required reviewers, which GitHub documents in its deployments and environments reference. Naming an environment in YAML does not create the approval rule by itself. Check your plan, too: the same page says that on GitHub Free, Pro and Team, required reviewers "are only available for public repositories", and most legacy business apps live in private ones.
The first milestone is modest and useful: a package you inspected and test results you can read. Production deployment comes after staging works, access is approved and there is a rollback plan.
If you have an older ASP.NET MVC or .NET Framework app that needs a build like this, or a focused fix, I offer a fixed-scope service on Upwork: upwork.com
初めてのCI/CD。pushしただけで公開まで流れる理由を、図で理解した(GitHub Actions + Cloudflare Workers)
1. はじめに
CI/CDという言葉は知っていましたが、なかなか勉強するまでには至りませんでした。それが仕事で使うことになり、とうとう勉強を始めることにしました。
とはいえ、どこから手をつければいいのか分かりません。調べると記事はたくさんあり、手順も丁寧に書かれています。ただ、どれも「こう書けば動く」が中心で、なぜpushしただけで最後まで流れるのかは、読んでもいまいち分かりませんでした。
幸い、今はAIという優秀なパートナーがいます。そこで今回は、AI(Claude Code)に助けてもらいながら、自分の環境で1つずつ検証していきました。
まず作ったのは、GitHubにpushすると、GitHub ActionsがCloudflare Workersへ自動で公開してくれるしくみです。画面で操作したのは3つ、書いたファイルも3つだけで、pushすると公開まで勝手に進んでいきます。実際に動かしてみても、やはり疑問は「何で流れるの?」、これだけでした。
この記事では、手順に加えて、なぜ最後まで自動で流れるのかを図で説明します。同じところでもやもやしている人の参考になればうれしいです。
なお、記事の内容はすべて自分の環境で動かして確かめていますが、CI/CDは初心者です。間違いなどがあれば、コメントで教えていただけるとうれしいです。
2. 作ったもの
GitHubのmainブランチにpushすると、約30秒で https://test.tobaru.net のページが更新されるしくみです。ページはHTMLファイル1つだけの、いちばん小さなものにしました。
ひとことで言うと、GitHubに置いたファイルを、GitHubのロボット(Actions)がCloudflareまで運んでくれます。運ぶときにCloudflareのドアを開けるので、ロボットに合鍵(APIトークン)を預けてあります。
!04_overview.png
自分で用意したサーバーはありません。DNSのレコードとhttpsの証明書も、手では作っていません。
3. 環境
| 項目 | 内容 |
|---|---|
| GitHub | Freeプラン、Privateリポジトリ |
| Cloudflare | Freeプラン |
| ドメイン | Cloudflare Registrarで取得したtobaru.net(DNSもCloudflare) |
| デプロイの道具 | cloudflare/wrangler-action@v4、wrangler 4 |
| 作業したPC | Windows 11、VS Code、Git |
自分のPCにNode.jsやwranglerは入れていません。wranglerはGitHub Actionsの中でだけ動きます。
ドメインのDNSがCloudflareにあることが、後で出てくる「1行でDNSと証明書ができる」の条件になります(5章の(5))。
4. 手順
画面で操作したのは(1)〜(3)の3つだけです。(4)はファイルを書くだけ、(5)はpushするだけです。
(1) GitHubにリポジトリを作る
- 名前はtobaru-net-test、Privateにしました
- README・.gitignore・ライセンスは付けず、空のリポジトリにします(ローカルで作ったファイルをそのままpushするため)
(2) CloudflareでAPIトークンを作る
1. 右上の人のアイコン → プロフィール → APIトークン → トークンを作成する
2. テンプレート「Cloudflare Workers を編集する」を使う
3. 範囲を絞る
- アカウントリソース: 自分のアカウントだけ
- ゾーンリソース: 特定のゾーン → tobaru.netだけ
4. 作成する。トークンは一度しか表示されないので、そのまま(3)に貼る
範囲を絞っておくと、万一トークンが漏れても、できることは「Workersの更新」と「tobaru.netの設定」だけです。
(3) GitHubのSecretsに登録する
リポジトリの Settings → Secrets and variables → Actions → New repository secret で、2つ登録します。
| Name | 値 |
|---|---|
| CLOUDFLARE_API_TOKEN | (2)で作ったトークン |
| CLOUDFLARE_ACCOUNT_ID | CloudflareのアカウントID |
(4) ファイルを3つ書く
tobaru-net-test/
├─ public/
│ └─ index.html … 見せるページそのもの
├─ wrangler.jsonc … Cloudflareへの注文書(何を・どこで出すか)
└─ .github/
└─ workflows/
└─ deploy.yml … Actionsへの指示書(いつ・何をするか)
public/index.htmlは、ふつうのHTMLです(中身は省略)。
wrangler.jsoncです。ふつうのJSONはコメントが書けませんが、jsonc(JSON with Comments)は//で書けます。
{
// Cloudflare側でのWorkerの名前(名札)。
// この名前のWorkerがなければ新しく作り、あれば中身を置き換える
"name": "tobaru-net-test",
// Workersの動き方の基準日
"compatibility_date": "2026-10-01",
// 静的なファイル(HTML・画像など)の置き場所。この中身がそのままサイトに並ぶ
"assets": {
"directory": "./public"
},
// どのURLで出すか。custom_domain: true でDNSレコードと証明書も作ってくれる
"routes": [
{ "pattern": "test.tobaru.net", "custom_domain": true }
],
// xxx.workers.dev というCloudflareのURLでは公開しない
"workers_dev": false
}
.github/workflows/deploy.ymlです。
name: Deploy
# いつ動くか(イベントの条件)
on:
push:
branches: [main] # mainブランチにpushされたとき
workflow_dispatch: # Actionsタブの「Run workflow」ボタンで手動でも動かせる
jobs:
deploy:
runs-on: ubuntu-latest # GitHubが数十秒だけ貸してくれるLinux
permissions:
contents: read
steps:
# 1. このcommitのファイル一式をランナーに取り出す
- uses: actions/checkout@v5
# 2. wranglerをインストールして動かす(commandを書かなければ wrangler deploy)
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} # 金庫の合鍵
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # どのアカウントに送るか
wranglerVersion: "4"
(5) pushする
git init -b main
git add -A
git commit -m "最初のページとデプロイ設定"
git remote add origin https://github.com/<ユーザー名>/tobaru-net-test.git
git push -u origin main
pushしてから約30秒で、https://test.tobaru.netが開けるようになりました。10秒おきに確かめると、最初の3回はつながらず、4回目で200が返ってきました。
5. なぜ最後まで自動で流れるのか
答えを先に書くと、何かがずっと監視しているわけではありません。「イベントが起きたら、こう動く」という小さな決まりが、前の結果を受け取ってつながっているだけです。
pushしてから公開されるまでを、5つの段階に分けました。
!05_sequence.png
画像をクリックすると、大きく表示されます。
ここからは、私が分からなかったところを順に説明します。
(1) GitHubはフォルダを監視しているの?
監視はしていません。pushやプルリクエストなどのイベントが起きたときにだけ、GitHubがそのcommitの.github/workflows/を開きます。中のYAMLを全部読んで、on:の条件に合うものだけを動かします。
それが分かる例です。
| こんなとき | デプロイは動く? |
|---|---|
| README.mdだけ変えてpush | 動く。deploy.ymlは変わっていないが、「mainへのpush」というイベントが起きたから |
| deploy.ymlを書き換えたが、pushしていない | 動かない。GitHubは自分のPCの中を見ていない |
| deploy.ymlを書き換えてpush | 書き換えた後の内容で動く。そのcommitのファイルを読むから |
| 別のブランチにpush | 動かない。on:がbranches: [main]だから |
| プルリクエストをmainにマージ | 動く。マージするとmainにcommitが入り、mainへのpushと同じになるから |
「受付の人がマニュアルをずっと見張っている」のではありません。「呼び鈴が鳴ったらマニュアル棚を開いて、今回の用件に当てはまるページを探す」イメージです。マニュアル棚が.github/workflows/、ページの見出しがon:です。
(2) ファイルを指定していないのに、なぜwrangler.jsoncが使われるの?
deploy.ymlのデプロイの手順には、「どのファイルを送るか」を書いていません。それでも動くのは、書かなかったときの決まり(既定値)が2段階あるからです。
1. wrangler-actionの決まり: commandを書かなければdeployを実行する(action.ymlに「Defaults to "deploy"」とあります)。workingDirectoryも書かなければ、リポジトリのいちばん上で実行する
2. wranglerの決まり: wrangler deployは、実行したフォルダでwrangler.jsonc / wrangler.json / wrangler.tomlを探して読む
しかも、wrangler.jsoncそのものは送られません。「何を・どこに・どんな名前で」の指示として読まれ、送られるのはassets.directoryに書いたpublic/の中身です。
決まった名前・決まった場所に置けば何も書かなくても動き、変えたいときだけ書く。この考え方を「設定より規約(Convention over Configuration)」と呼ぶそうです。(1)の.github/workflows/も同じです。
(3) Cloudflareで何も作っていないのに、なぜWorkerができるの?
wrangler.jsoncの"name": "tobaru-net-test"で、「この名前のWorkerを、この中身にして」と頼んでいるからです。Cloudflareは、その名前のWorkerがなければ新しく作り、あれば中身を置き換えます。上の図のaltのところです。
どのアカウントに作るかはaccountId、入っていいかはapiTokenで伝えています。どちらもSecretsから渡しているので、deploy.ymlには値そのものを書いていません。
(4) Secretsは何のため?
Secretsは合鍵を入れておく金庫です。deploy.ymlには${{ secrets.CLOUDFLARE_API_TOKEN }}と、「金庫のこの引き出しを使って」とだけ書きます。合鍵そのものはどのファイルにも書かないので、コードを人に見せても漏れません。
(5) 1行でDNSと証明書までできるの?
できました。routesに"custom_domain": trueと書くと、「このホスト名をまるごとこのWorkerにする」意味になり、CloudflareがDNSのレコードとhttpsの証明書を作ってくれます。
ただし、次の2つがそろっているからできることです。
- tobaru.netのDNSが、同じCloudflareアカウントにある(Cloudflareが自分でレコードを書き込める)
- APIトークンにtobaru.netを触る権限がある(4章の(2)でゾーンを絞って許可した)
DNSが別の会社にあるドメインでは、この1行だけでは済みません。
custom_domainを付けずに"pattern": "test.tobaru.net/api/*"のように書くと、「このURLに来たアクセスだけWorkerが受け取る」という別の使い方になります。この場合、DNSのレコードは自分で作ります。
6. 警告が出たところ
最初は、他の記事と同じくcloudflare/wrangler-action@v3を使っていました。デプロイは成功しましたが、Actionsの結果のAnnotationsに、警告1件とお知らせ1件が出ました。
!01_annotations.png
(1) Node.js 20 is deprecated(警告)
> Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: cloudflare/wrangler-action@v3.
wrangler-action@v3はNode.js 20向けに作られていて、GitHub側がNode.js 20を廃止していく途中なので、Node.js 24で動かしているという知らせです(GitHubのお知らせ)。今は動いていますが、将来動かなくなるおそれがあります。
調べるとv4が出ていて、action.ymlにusing: "node24"とありました。入力の項目(apiToken・accountIdなど)は同じなので、@v3を@v4に変えるだけで警告が消えました。
この修正もdeploy.ymlを1文字直してpushしただけです。しくみそのものの設定を変えた場合も、ページを変えた場合と同じ流れで反映されます(5章の(1)の表の3行目)。
(2) ubuntu-latestがUbuntu 26に移る(お知らせ)
> The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026.
runs-on: ubuntu-latestの中身が、順に新しいUbuntuに切り替わるという案内です(runner-imagesのIssue)。今回のワークフローはwranglerを動かすだけなので、そのままにしています。
(3) nameを変えるときの注意
つまずいたわけではありませんが、しくみから考えると注意が必要だと分かったところです。
nameを変えてpushすると、Cloudflareは名前が変わったとは受け取りません。「その名前のWorkerがないので新しく作る」と受け取ります(5章の(3))。古いWorkerは残ったままになるので、変えるときは古いほうの片付けも必要です。
7. Cloudflareの画面で確かめる
Cloudflareの画面では何も作っていませんが、pushしたあとに開くと、次の2つができていました。
(1) Workersの一覧
「Workers & Pages」を開くと、wrangler.jsoncのnameで付けたtobaru-net-testがあり、routesで頼んだtest.tobaru.netがつながっていました。
!02_workers.png
「デプロイメントを表示」を開くと、pushした回数ぶんの履歴が並んでいます。GitHubのcommitと1回ずつ対応しています。
(2) DNSのレコード
tobaru.netのDNSのページには、自動で作られたtestのレコードがありました。自分では1つも作っていないのに、本当にレコードが出ていてびっくりしました。
!03_dns.png
| 見えているもの | 意味 |
|---|---|
| 左端の鍵のアイコン | 自動で作られたレコードなので、ここからは編集できない |
| タイプ「Worker」 | AやCNAMEではなく、「このWorkerに届けて」という特別なレコード |
| コンテンツ「tobaru-net-test」 | wrangler.jsoncのname。名札でつながっている |
| 「プロキシ済み」 | アクセスはいったんCloudflareが受け取る |
外のDNS(1.1.1.1)にtest.tobaru.netを聞いてみると、返ってくるのはCloudflareのIPアドレス(104.21.x.xなど)でした。「プロキシ済み」なので、サーバーの場所ではなくCloudflareのアドレスが返ります。応答のヘッダーにはServer: cloudflareとCF-RAY: …-NRTがあり、東京の拠点から返っていることも分かりました。
8. まとめ
最初は、なぜ自動で最後まで流れるのかが、いまいち理解できませんでした。でも、監視という大掛かりな仕組みではありませんでした。イベントが起きたときの動きが決まっていて、その決まりどおりに動くと、流れるように最後までつながる。それが分かったとき、美しいなーと思いました。DNSの画面に、自分では作っていないレコードが出ていたときも感動しました。
今回分かったことを並べると、次の3つです。
- GitHubは監視していない。イベントが起きたときに.github/workflows/を読み、on:に合うものだけを動かす
- 書いていないことは既定値で動く。wrangler-actionはdeployを実行し、wranglerは決まった名前のwrangler.jsoncを探す
- CloudflareはnameでWorkerを見分け、なければ作り、あれば置き換える。custom_domain: trueならDNSと証明書も作る
次は、プルリクエストを作ったらチェックだけを動かし、mainにマージしたらデプロイする形(CIとCDの分離)を試す予定です。チェックも、pull_requestというイベントに決まりを足すだけで作れるはずです。
1 like
CIを最大4.6倍高速化する方法
!
この記事は2023年11月1日に執筆されたものです。
こんにちは、InflabのDevOpsエンジニアのJakeです。
皆さんは、CIパイプラインの実行に時間がかかりすぎて、PRレビューに影響が出たことはありませんか?
プロジェクトの規模が大きくなるにつれて長くなっていくCI/CDパイプラインの所要時間に、頭を悩ませたことはありませんか?
本記事では、CIを最大4.6倍まで高速化できたノウハウを共有します。
依存関係とビルドのキャッシュがヒットした場合の数値
最適化の結果
この記事のポイント
CI(継続的インテグレーション)の所要時間を最大79%削減
Jenkins Pipelineの最適化:
ビルド、テストを実行するDockerコンテナの依存関係キャッシュのパスを設定
モノレポのCIにインクリメンタルビルドを適用: turborepo remote cache
シンプルながら非常に効果的で、簡単に適用できるCI/CDパイプライン最適化のノウハウを共有します。
最適化の背景
Inflabの開発チームではモノレポを導入し、パッケージ単位でコードを管理しています。プロジェクトの規模が大きくなるにつれてコードベースとパッケージが増え、CIの所要時間が急増しました。
プロジェクト初期: パッケージ1個、4分14秒
プロジェクト後期: パッケージ9個、12分4秒
プロジェクトの成長とともにコードで使うライブラリがどんどん増え、依存関係(dependency)のインストール時間が長くなりました。
また、ビルドとテストを順番に実行していたため、所要時間はどんどん積み上がっていきました。ビルドパイプライン側で直接並列実行させるなどの手も試しましたが、依存するパッケージ間の複雑な関係や並行性の問題があり、別の解決策を探す必要がありました。
そこで pnpm と turborepo を導入し、モノレポプロジェクトの生産性を改善することになりました。
Yarnとpnpmの比較
次のグラフは、npm、pnpm、yarn、yarn PnPのベンチマークです。
画像出典: pnpm.io
グラフのとおり、依存関係のインストールでは条件によって約2倍速くなっています。私たちがまとめたそれぞれの特徴は次のとおりです。
yarn
依存関係のインストール時間: 最大120秒
複雑な node_modules の構造により、ディスクI/OとCPUリソースを消費
pnpm
依存関係のインストール時間: 最大63秒
symlinkでグローバルキャッシュのパス(.pnpm-store)にリンク -> 不要なファイルコピーが減少 -> ディスクI/Oが減少
node_modules
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ └── bar -> <store>/bar
│ ├── index.js
│ └── package.json
└── foo@1.0.0
└── node_modules
└── foo -> <store>/foo
├── index.js
└── package.json
これまで使っていた yarn には、依存関係をインストールすると複雑な node_modules ディレクトリ構造のせいでディスクI/OとCPUリソースが無駄に消費されるという欠点がありました。そのため、より高スペックなビルドサーバーが必要になり、所要時間も長くなっていました。
pnpm の導入後は、すべての依存パッケージを .pnpm-store にインストールし、各パッケージ内の node_modules ディレクトリにはsymlinkを張るだけの形になりました。これによりディスクへの書き込みが減り、依存関係のインストール時間を約60秒短縮できました。
Turborepo
Turborepoを説明する前に、モノレポについて少しおさらいしておきます。
Monorepoとは?
画像出典: monorepo.tools
モノレポ(Monorepo)とは?
A monorepo is a single repository containing multiple distinct projects, with well-defined relationships.
モノレポとは、関係が明確に定義された複数の個別プロジェクトを含むリポジトリを指す。
モノレポを導入すると、毎回多数のリポジトリを作成・管理する手間を減らせます。詳しくはリンク先をご参照ください。
関係が明確に定義されておらず、カプセル化もされていない単なるコードの寄せ集めはモノレポではなく、Monolithとも異なると説明されています。
このようにモノレポでは、複数のアプリやライブラリを1つのリポジトリで管理するため、同じ作業を多数のリポジトリに繰り返し適用する必要がありません。
しかし、モノレポのコードベースの規模が大きくなるとどうなるでしょうか?
ビルドとテストを順次実行することによる所要時間の増加
変更されていない部分まで、不要なビルド & テストを繰り返し実行
次の例は、モノレポのCIパイプラインで、変更されたパッケージのビルドだけを並列実行するよう構成したものです。
stage('Docker Build') {
parallel {
stage('Package A') {
when {
anyOf {
changeset "cmd/package-alpha//*"
changeset "docker/package-alpha//*"
}
}
steps {
sh 'docker build -t package-alpha -f docker/package-alpha/Dockerfile .'
}
}
stage('Package B') {
when {
anyOf {
changeset "cmd/package-beta//*"
changeset "docker/package-beta//*"
}
}
steps {
sh "docker build -t package-beta -f docker/package-beta/Dockerfile ."
}
}
...(中略)...
プロダクトや組織の規模に応じて適切にリポジトリを分ける方法もありますが、結局のところ、モノレポを専用のツールなしで上記の例のようにCIパイプラインで直接管理しようとすると、かなりの工数がかかるでしょう。
パッケージが追加されるたびに手作業でCIパイプラインを修正するのは、本当に大変です。(とにかく手間がかかります)
パッケージ同士が複雑な依存関係で絡み合っている場合、それをすべてCIパイプラインで実装するのは困難です。
こんなときに必要になるのが、turborepo や nx のようなモノレポツールです。
さまざまなモノレポツールがありますが、本記事では私たちが導入した turborepo を中心に説明します。
主な機能
turborepo は、JavaScriptおよびTypeScriptのコード向けの高性能なビルドシステムです。公式ページで説明されている特徴は次のとおりです。
Incremental Builds
Content-aware hashing
Parallel execution
Remote Caching
Zero runtime overhead
Pruned subsets
Task pipelines
Meets you where you’re at
Profile in your browser
ここで強調したいのは、Incremental Builds、Content-aware hashing、Parallel execution、Remote Cachingです。順番に見ていきましょう。
Incremental Builds
日本語に直訳すると「増分ビルド」です。
追加・変更された部分だけをテスト & ビルドすることで、大規模なモノレポのCI所要時間を大幅に短縮します。
Detecting affected projects/packages
Content-aware hashing
コードの内容からハッシュ値を計算し、変更を検知します。
Gitに依存するchangesetとは対照的です。
Parallel execution
従来はビルドを順番に実行していたため、CPUリソースに無駄がありました。
ビルドを並列で実行することで、CPUコアを効率的に使い、ビルドをより早く終わらせることができます。
Local Task Orchestration
Remote Caching
Local Computation Caching
taskの処理結果をローカルに保存して再利用する機能です。同じマシンでは、同じ状態のコードに対するビルドやテストを2回以上行いません。
Distributed Computation Caching
Local Computation Cachingを拡張した概念で、taskの処理結果を1台のマシンではなく複数の環境にまたがって共有します。
CIエージェントのいずれかがすでにそのコードの状態をビルド・テストしていれば、ほかのエージェントがそれを再実行することは決してありません。
Remote Cachingは、Distributed Computation Cachingと似た概念です。
同じコードベースを修正する作業者が複数人いると仮定しましょう。
作業者Aが先にビルドとテストを実行していた場合、その結果は共用のキャッシュサーバーに保存されます。
続いて作業者Bがビルドとテストを実行するときは、共用キャッシュサーバーにキャッシュされた作業者Aの処理結果を読み込み、不要なビルドの実行をスキップします。
共用キャッシュサーバーにはビルドの成果物ファイルもあわせて保存されるため、それをすぐにデプロイに活用できます。同じ作業が繰り返し実行されるCIパイプラインを効果的に改善できます。詳しい数値は後ほど共有します。
上記の4つの機能をうまく活用すれば、大規模なプロジェクトもぐっと楽に管理できます。
ところで、なぜ本記事のタイトルはTurborepoの導入ではなく、CIの改善なのでしょうか?
いくつか見落としていた点があり、これらのメリットを活かしきれていなかったからです。ここから説明していきます。
CI所要時間の最適化
Inflabでは、Jenkinsを使ってCI/CDパイプラインを構築しています。
CIジョブがトリガーされると、JenkinsのEC2エージェント上でDockerイメージを起動し、次の順序でジョブを実行します。
依存関係のインストール(Install Dependencies)
テスト(Test)
ビルド(Build)
静的解析(Static Analysis)
依存関係のインストール
依存関係のインストール処理をまとめると、次のとおりです。
pnpm i --frozen-lockfile
必要な依存ライブラリのバージョンを確認
--frozen-lockfile: 必要な依存関係のマニフェストファイル(pnpm-lock.yaml)が存在するかを確認し、記載されたバージョンに従って依存関係をインストール
npmjs.com、GitHub Registry のようなパッケージリポジトリから依存関係をダウンロードしてインストール
post-installスクリプトを実行
先ほど、pnpm を導入しただけで依存関係のインストール時間を約60秒短縮できたとお伝えしました。
実行環境が毎回変わるCI環境では、依存関係のキャッシュを導入すれば、さらに多くの時間を節約できます。
環境ごとの依存関係インストールの結果
ローカル環境では、pnpm i を実行するとグローバルな依存関係キャッシュのディレクトリからキャッシュを読み込み、不要なインストールをスキップします。一方、CI環境では実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定する必要があります。
ローカル環境: インストールしたすべての依存関係はグローバルキャッシュのディレクトリ(~/Library/pnpm/store/*)に蓄積
一度インストールした依存関係をほかのプロジェクトでも参照し、不要なインストールを省略(キャッシュヒット時は3秒前後)
CI環境: 実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定しなければ毎回インストールが走る(約60秒)
CI環境での依存関係のキャッシュ
CI環境で依存関係をキャッシュする方法には、主に次のようなものがあります。
圧縮: node_modules のパスをtar & gzipで圧縮
CI用のキャッシュイメージを作る: 依存関係をあらかじめインストールしたDockerイメージを作成し、それをビルドに活用する方法
キャッシュボリュームのマウント: build agentに残っているglobal cacheを活用する方法
圧縮
GitHub Actions actions/cache@v3 の例
...
- id: cache
uses: actions/cache@v3
with:
path: '/node_modules'
key: ${{ runner.os }}-node-${{ hashFiles(format('/{0}', steps.hash-file.outputs.hash-file)) }}
...
Jenkins Job Cacherプラグインの活用例
cache(maxCacheSize: 250, defaultBranch: 'develop', caches: [
arbitraryFileCache(path: 'node_modules', cacheValidityDecidingFile: 'package-lock.json')
]) {
// ...
}
依存関係が入った node_modules ディレクトリをtarballにまとめ、gzipやxzなどで圧縮する方法です。
キャッシュは pnpm-lock.yaml のようなマニフェストファイルのハッシュで区別されるため、必要な依存関係が変わるたびにインストールし直す必要があるのが欠点です。
また、node_modules ディレクトリの圧縮は基本的にシングルスレッドで行われるため、時間がかかります。
プロジェクトの規模が大きくなると node_modules ディレクトリも大きく複雑になるため、キャッシュファイルのダウンロードと展開だけで2分以上かかることもあります。
これでは本末転倒なので、いっそ圧縮しないほうが良い場合もあります。
プロジェクトの規模が大きい場合
pnpm install の所要時間: 60秒
キャッシュファイルのダウンロードと展開の所要時間: 120秒以上
場合によっては、実際に所要時間を計測・比較したうえで判断する必要あり
GitHub Actionsを使う場合は、pnpmの公式ドキュメントのガイドに従い、cache: 'pnpm' を設定するだけで簡単に適用できます。
pnpm公式ドキュメントで推奨されているGitHub Actionsパイプラインの例
name: pnpm Example Workflow
on:
push:
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
node-version: [15]
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
Jenkinsパイプラインを使う場合は、次のように設定することが推奨されています。
pipeline {
agent {
docker {
image 'node:lts-bullseye-slim'
args '-p 3000:3000'
}
}
stages {
stage('Build') {
steps {
sh 'corepack enable'
sh 'corepack prepare pnpm@latest-8 --activate'
sh 'pnpm install'
}
}
}
}
上記の例ではpnpmのstore pathを別途指定していないため、JENKINS_WORKSPACE の配下に .pnpm-store のパスを作成して依存関係をキャッシュします。
しかし、Jenkins Multibranch Pipelineの場合は、ブランチやPull Requestごとに別々のWorkspaceのパスを持ちます。
つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。
これを解決する方法は、キャッシュボリュームのマウントで後ほど説明します。
CI用のキャッシュイメージを作る
次に紹介するのは、CI用のキャッシュイメージを作る方法です。
次の例は、pnpm fetch を使って依存関係のキャッシュイメージを作る Dockerfile です。
FROM node:18-slim
pnpmのインストール手順は省略
pnpm fetch does require only lockfile
COPY pnpm-lock.yaml ./
RUN pnpm fetch --prod
上記のイメージをpullしてから依存関係をインストールすれば、キャッシュを活用してすぐにビルドできます。
イメージタグにはマニフェストファイル(pnpm-lock.yaml)のハッシュ値を指定します。そのため先ほどの方法と同じく、このファイルが変更されたらイメージを再ビルドする必要があります。
when cache miss, build and push into image registry like ECR
docker build -t rallit-frontend-dep:02d93ab24 -f dep.Dockerfile .
docker push rallit-frontend-dep:02d93ab24
when build
docker run -i --rm -v $WORKSPACE:/workspace rallit-frontend-dep:02d93ab24 'pnpm i --frozen-lockfile'
ただし、pnpm fetch --prod コマンドでインストールした依存関係をイメージとしてビルドしてpullするため、その過程でディスクのオーバーヘッドが発生します。
社内で所要時間を計測した結果、実際のCI環境ではイメージをpullして実行するのに約168秒かかることが分かりました。
キャッシュイメージを作る方法も、プロジェクトの規模が大きいほど時間がかかり、通常の pnpm install より約108秒も遅くなりました。
pnpm-lock.yaml が変更されるたびにこうしたキャッシュイメージをビルドし、コンテナイメージレジストリにアップロードするのは非効率だと判断しました。
Dockerのマルチステージビルドを使ってイメージをビルドしている方は、pnpmの公式ドキュメントを参考にcache type mountingを適用することをおすすめします。
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
DockerのRUN専用キャッシュを活用する
パッケージをインストールするたびに、毎回インターネットからすべてのパッケージを取得する必要はありません。
--mount フラグとともに明示的なキャッシュを使うと、ビルド間で対象ディレクトリの内容が保持されます。
このレイヤーを再ビルドする必要がある場合は、そのパスにあるキャッシュを活用します。
私たちはDockerのマルチステージビルドを使わず、ビルドの段階ごとに個別に最適化したイメージを使っているため、この方法は適用できませんでした。
続いて、依存関係のインストール時間を大きく短縮できた、キャッシュボリュームのマウントについて説明します。
キャッシュボリュームのマウント
先ほど、Jenkinsパイプラインでpnpmを使って依存関係をインストールする場合は、キャッシュを別途設定する必要があると述べました。
つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。
実際の .pnpm-store のパスを比較しながら見ていきましょう。
従来(AS-IS)
CACHE_PATH のパスが JENKINS_WORKSPACE の中にある
異なるプロジェクト、ブランチ、PR間で依存関係のキャッシュを共有できない
パスの比較
JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242
CACHE_PATH=/workspace/rallit_frontend_PR_15242/.pnpm-store
例えば、rallit_frontendのPR#15242のCIジョブが完了したとしましょう。
/workspace/rallit_frontend_PR_15242/.pnpm-store には、先ほどインストールした依存関係が残っています。
続いてPR#15243が作成されると、どうなるでしょうか?
PR#15243は /workspace/rallit_frontend_PR_15243/.pnpm-store を参照することになります。
このフォルダは実際には存在しないので、依存関係を最初からインストールすることになり、その分時間がかかります。
先にインストールしたPR#15242の依存関係キャッシュは、まったく使えません。
たった1行の変更でも約60秒余計に待たされるので、かなり不便です。
変更後(TO-BE)
エージェント内にGlobal Dependency Cacheのパスを設定
同じエージェントで実行するビルドはDependency Cacheを活用
パスの比較
JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242
CACHE_PATH=/tmp/.pnpm-store
CIジョブを実行するすべてのコンテナで、pnpm store-path オプションに同じパスを指定しました。
これにより、rallit_frontendのPR#15242、PR#15243はどちらも同じパス(/tmp/.pnpm-store)を参照するようになります。
PR#15243のCIジョブでは、PR#15242ですでにインストールされた依存関係を再利用することになり、約60秒かかっていた依存関係のインストール時間をわずか3秒に短縮できました。
pnpm store-path オプションは、ビルド用のベースイメージを作る Dockerfile で次のように指定しました。
set global node cache directory
ARG CACHE_PATH=/cache/node
RUN yarn config set cache-folder ${CACHE_PATH}/.yarn
RUN pnpm config set store-dir ${CACHE_PATH}/.pnpm-store
パッケージマネージャーにYarnを使っているプロジェクトでも依存関係のキャッシュパスを共有できるよう、Yarnにも同様の設定を入れています。
続いて、このDockerイメージを実行する Jenkinsfile 側でも CACHE_PATH 環境変数を設定します。
env.DOCKER_OPTION = '-v /tmp:/cache/node -e CI=1'
(中略)
stage('Install') {
steps {
script {
sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm i --frozen-lockfile'"
}
}
}
JenkinsfileのDocker拡張機能を使わず、コマンドで直接Dockerコンテナを実行する場合
pnpmがCI環境かどうかの判定に使う環境変数の1つである BUILD_NUMBER が渡されないため、CI=1 のデフォルト値が適用されません。
-e BUILD_NUMBER=$BUILD_NUMBER で渡すか、-e CI=1 環境変数を直接設定するのがおすすめです。
詳しくは公式ドキュメントをご参照ください。
CI=trueの場合、pnpmはロックファイル(pnpm-lock.yaml)を生成せず、ロックファイルがマニフェストと同期していない場合、更新が必要な場合、または存在しない場合はインストールに失敗します。
この対応によって、直前のビルドで使われた依存関係のキャッシュを、ほかのリポジトリのCIでも再利用できるようになりました。
適用前
- pnpm i --frozen-lockfile
Scope: all 7 workspace projects
Lockfile is up to date, resolution step is skipped
Progress: resolved 1, reused 0, downloaded 0, added 0
Packages: +3357
(中略)
Progress: resolved 3357, reused 1253, downloaded 2025, added 3357, done
Done in 1m 28.6s
適用後
- pnpm i --frozen-lockfile
Scope: all 7 workspace projects
Lockfile is up to date, resolution step is skipped
Packages: -11
-----------
Done in 2.6s
依存関係インストール処理の最適化結果
依存関係キャッシュMiss: 1分28秒
依存関係キャッシュHit: 2.6秒
依存関係のインストール時間を最大97%短縮
依存関係のインストールは従来の88秒から2.6秒へと85.4秒減り、約97%短縮できました。
もちろん、ビルドエージェントが入れ替わって依存関係のキャッシュパスが初期化されると、最初の1回だけは1分28秒のインストールが必要です。
それでも、ビルドのたびに依存関係をインストールしていた従来とは違い、エージェント内で依存関係のキャッシュを共有できるので、後続のビルドではインストール時間を97%以上短縮できます。
複数のリポジトリが同じライブラリを使っていても、それぞれバージョンが異なる場合があります。
この場合、使用するバージョンを1つに統一するとキャッシュヒット率(Cache Hit Rate)が上がり、上記と同様に依存関係のインストール時間を大幅に短縮できました。
例: inflearn-frontend と rallit-frontend で使っている Datadog RUM(Real User Monitoring) エージェントのバージョンが一致している場合、npm registryからの依存関係のダウンロードとインストールをスキップし、ビルドエージェントにあるグローバルな依存関係キャッシュを活用して時間を短縮できる。
(rallit-frontend で先にインストールしていれば inflearn-frontend でそれを利用でき、その逆も可能)
テストとビルドの所要時間の短縮
先に述べたとおり、モノレポでは一部のパッケージを修正しただけでも、すべてのパッケージをテスト・ビルドしなければなりません。
Turborepoを使えば、変更されたパッケージと、それに依存するパッケージだけをテスト・ビルドするので、CIの所要時間を大幅に短縮できます。これをTask Cacheと呼びます。
Inflabのフロントエンドチームは早くからTurborepoをローカル開発環境に導入していたため、実際に変更されたコードだけをテスト・ビルドして、作業内容を素早く検証できていました。
これはコンテキストスイッチの頻度を減らし、生産性の向上につながりました。(実験:遅いビルド時間の隠れたコスト in GeekNews)
Turborepo Task Caching
package.json にある build、test、lint などをtaskと呼びます。
Turborepoはtaskの実行結果とログをキャッシュし、遅いtaskを高速化します。
Content-aware hashingでソースコードの変更を検知し、変更されたパッケージに対してのみ build、test、lint などのtaskを実行します。
テストとビルドの結果は、まずLocal Filesystem Cacheとして ./node_modules/.cache/turbo/78awdk123.tar.zst のようなパスに保存されます。
Taskキャッシュがヒット(hit)したパッケージはビルドをスキップし、変更されたパッケージだけを新たにテスト・ビルドします。
実行結果をもとにTaskキャッシュを更新し、常に最新の状態に保ちます。
Taskキャッシュがミス(miss)した場合は、次のようにtaskを実行し、その結果でLocal Filesystem cacheを更新します。
/lib:build: cache miss, executing 86fb10d5583f361c
/app:build: cache miss, executing 7804de963ab93a0e
(中略)
Tasks: 5 successful, 5 total
Cached: 0 cached, 5 total
Time: 4m36.208s
...writing to cache...
Taskキャッシュがヒット(hit)した場合は、キャッシュ作成時のログを再生(replay)し、ビルド結果をキャッシュサーバーからダウンロードします。
/lib:build: cache hit, replaying logs 86fb10d5583f361c
/ds:build: cache hit, replaying logs 7804de963ab93a0e
/a:build: cache hit, replaying logs 8be7f0056074cbc3
/b:build: cache hit, replaying logs d01733e33b71a814
Tasks: 5 successful, 5 total
Cached: 5 cached, 5 total
Time: 4.114s >>> FULL TURBO
Taskキャッシュの適用結果
TaskキャッシュMiss: 4分36秒
TaskキャッシュHit: 4.114秒
Taskキャッシュの導入で所要時間を約98.5%改善
Taskキャッシュも依存関係のキャッシュと同じく、キャッシュがない初回は時間がかかります。しかしそれ以降のビルドでは変更されたパッケージに対してのみTaskを実行するため、かなりの時間を節約できました。
問題は、別途設定をしなければLocal Filesystem Cacheしか使えないことです。
Jenkinsエージェントではworkspaceをブランチ・PR単位で分けているため、検証済みのPRを develop ブランチにマージすると、テストとビルドを最初からやり直す必要がありました。
CIパイプラインで一度ビルドしたものは再ビルドせずに済ませたいところです。どうすれば改善できるでしょうか?
Turborepo Remote Caching
Turborepo Remote Caching機能を使えば、この問題を解決できます。
Taskキャッシュを複数のビルドエージェント間で共有でき、インクリメンタルビルドを積極的に活用できるようになります。
Remote Cachingを使うには、追加費用が発生するVercelのクラウドを使うか、Vercelが公開しているAPI仕様に従ってTurborepo custom remote cache serverを自前で実装する必要があります。
私たちは、オープンソースとして公開されているducktors/turborepo-remote-cacheを使いました。
ducktors/turborepo-remote-cache
Turborepo custom remote cache serverのオープンソース実装で、self-hosted環境で使うのに適しています。
シンプルなWebサーバーアプリケーションで、次の実行方法をサポートしています。
Dockerコンテナとして実行
AWS Lambdaなどのサーバーレス関数として実行
ストレージについては、次のようなさまざまなオプションが用意されています。
Amazon S3(または互換ストレージ)
ファイルシステム
Lambda + S3の組み合わせで完全にサーバーレスで構築することもできます。それぞれのメリット・デメリットを踏まえて、環境に合った構成を選べば良いでしょう。
ここでは、Docker + ファイルシステムでカスタムキャッシュサーバーを手軽に構築する手順を紹介します。
次のように compose.yaml ファイルを作成します。${} の中に入る変数は .env ファイルで定義します。
services:
turborepo-remote-cache:
image: ducktors/turborepo-remote-cache:1.14.2
ports:
- 3000:3000
volumes:
- ./tmp:/tmp
environment:
- NODE_ENV=production
- PORT=3000
- TURBO_TOKEN=${TURBO_TOKEN}
- LOG_LEVEL=info
- STORAGE_PROVIDER=local
- AWS_REGION=${AWS_REGION}
続いてガイドに従い、Turborepoのカスタムキャッシュサーバーのアドレスといくつかのパラメーターを、Dockerの環境変数として渡します。
TURBO_API: キャッシュサーバーのアドレス
TURBO_TOKEN: 認証トークン
TURBO_TEAM: キャッシュを使うteam(主にプロジェクト単位で指定)
stage('Build') {
steps {
script {
env.DOCKER_OPTION = "$DOCKER_OPTION -e TURBO_API=$TURBO_API -e TURBO_TOKEN=$TURBO_TOKEN -e TURBO_TEAM=$TURBO_TEAM"
sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm build'"
}
}
}
Dockerの環境変数ではなくturbo.jsonなどの設定ファイルに直接書く方法もありますが、今回はCI専用のキャッシュサーバーとの連携なので、上記のようにパイプライン側に記述しました。
適用後にビルドを何度か実行すると Remote caching enabled というログが出力され、Taskキャッシュが蓄積・再利用されていることが確認できます。
- pnpm build:development
• Packages in scope: ......
• Running build:development in 6 packages
• Remote caching enabled
a:build: cache hit, replaying logs 83da8d8fb24dd1a9
b:build: cache hit, replaying logs 249bb66c4e03301b
c:build: cache hit, replaying logs 8be7f0056074cbc3
(中略)
Tasks: 5 successful, 5 total
Cached: 5 cached, 5 total
Time: 4.114s >>> FULL TURBO
ハッシュはコードの状態をもとに計算されるので、ブランチやPRが違っても、コードを修正していなければTaskが無駄に再実行されることはありません。
では、Continuous Deploymentのパイプラインでビルドキャッシュを使う場合、ビルド成果物はどうデプロイすれば良いでしょうか?
turbo.jsonでコマンドごとにoutputsのパスを設定すると、そのパスがビルド成果物として扱われ、Turborepoのリモートキャッシュサーバーに保存されます。キャッシュヒット時には、それがダウンロードされます。
そのため、デプロイ時の混乱を防ぐには、outputs オプションを正しく設定しておく必要があります。(関連する公式ドキュメント)
turbo.json
{
"$schema": "turbo.build",
"pipeline": {
"app#build": {
"outputs": ["dist/"]
},
"util#build": {
"outputs": ["lib/"]
}
}
}
このオプションを正しく設定していないと、デプロイパイプラインを経由しない緊急ロールバックのあとにパイプラインを再実行しても、本番環境に反映されないことがあるので注意してください。
便利さの裏には慎重さと正確さが欠かせないことを、心に留めておきましょう。
ソースコードの静的解析
依存関係のインストールと、テスト・ビルド。この2つを最適化した結果、すべてのキャッシュがヒットすれば、ここまでの処理は10秒前後で終わります。
静的解析では、コードのセキュリティ脆弱性から、重複やパフォーマンス上の問題が起こりうる箇所まで見つけられるため、SonarQubeを積極的に導入・活用しています。
しかし、CIのたびに静的解析を実行していたため、コードの規模によっては40秒から最大2分以上かかることもありました。
これを短縮するため、解析が不要なファイルを sonar-project.properties で除外しました。
sonar.exclusions=packages//public/mockServiceWorker.js, packages//src/@types//, packages//src/mocks//, packages//vite.config.ts,
sonar.analysis.mode=incrementalは使用不可
以前は -Dsonar.analysis.mode=incremental パラメーターを追加して変更箇所だけを解析する機能がありましたが、現在はサポートが終了しています。
代わりに、変更されたディレクトリだけを解析する仕組みをパイプラインに自前で実装する方法や、CIのポリシーを変更して develop ブランチへのマージ時にのみ解析する方法があります。
銀の弾丸(silver-bullet)は存在しないので、皆さんの環境に合った方法を選んで改善してください。
詳しくはSonarQubeのドキュメントをご参照ください。
私たちのContinuous Deploymentパイプラインには、コンテナのデプロイ完了を待つ処理がありました。その待ち時間にSonarQubeの解析を並行して実行することで、約40秒〜最大2分かかっていた静的解析の時間を短縮できました。
CI最適化の結果
キャッシュHitを想定(install、buildがともにヒットした場合)
pnpmの依存関係キャッシュとTurborepoのTaskキャッシュを適用したことで、CIの所要時間を最大79%短縮できました。
モノレポの中でも、パッケージ数とコード量が多いプロジェクトほど最適化の効果が際立ちました。パッケージが多いほど、修正による影響範囲が小さくなるためです。
さらに、turborepo もモノレポも導入していないプロジェクトE、Fでも、依存関係のインストールを最適化しただけでCIが約1.6〜2倍速くなりました。
調査対象の平均CI所要時間は6分3秒から2分4秒になり、最大66%改善できました。静的解析まで最適化すれば、さらに時間を節約できると期待しています。
おわりに
以上、簡単な修正でCIの所要時間を劇的に改善した経験を共有しました。皆さんもぜひ試してみて、得られた成果やより良い方法をコメントで教えていただけるとうれしいです。
最適化を進めるなかで私を信頼し、込み入った質問にも丁寧に答えてくださったInflab開発チームの皆さんのおかげで、無事に最適化を終えることができました。ありがとうございました。
次回の記事では、JenkinsのEC2エージェントのコンピューティングコストを最大70%節約できたスポットインスタンスの導入をテーマにお届けします。
Inflab DevOpsチームの最適化はまだまだ続きます。今後もご期待ください!
参考資料
monorepo.tools
pnpm.io
macOS runners are vulnerable to the eMudhra intermediate CA compromise · Issue #15228 · actions/runner-images : 👀
---
runner-images#15228
macOS + GitHub Actions: eMudhra fixes require ongoing curation. Runner images can retain vulnerable CA bundles; updating Homebrew doesn't fix Apple's curl/Python. Apple Python can also ignore CA env overrides. Test each client--and recheck after OS updates. runner-images#15228
Github Action 镜像仓库中包含了大量的开发环境安装脚本,满足了我的收集癖。
github.com/actions/runner-images/tree/main/images…
在逛windows blogs的时候看到
python3.15版本的tier2 (jit) 支持了winonarm平台。
blogs.windows.com
然后在文章中看到了Github Action runner的镜像仓库。
进一步发现这些脚本。 t.co
5 likes · 146 views
GitHub Actions : le tutoriel pas à pas pour tester et publier une API Node.js
Les six notions de GitHub Actions
Tout workflow GitHub Actions se lit avec six mots, définis dans la page Understanding GitHub Actions de la documentation officielle (consultée le 31 octobre 2026) :
| Notion | Ce que c'est | Dans notre exemple |
|---|---|---|
| Workflow | Un processus automatisé décrit par un fichier YAML dans .github/workflows/ | ci.yml |
| Événement | L'activité du dépôt qui déclenche le workflow | push et pull_request sur main |
| Job | Une suite d'étapes exécutées sur un même runner ; les jobs tournent en parallèle sauf dépendance | test et image |
| Step | Une commande shell ou une action, exécutées dans l'ordre | npm ci, npm test |
| Runner | La machine qui exécute un job, un seul job à la fois | ubuntu-latest, hébergé par GitHub |
| Action | Une extension réutilisable, souvent prise sur le GitHub Marketplace | actions/checkout, docker/login-action |
Chaque exécution part d'une machine virtuelle neuve, et chaque action du Marketplace est du code tiers qui tourne avec vos droits, d'où l'étape 6.
!Schéma du workflow GitHub Actions de ce tutoriel : un événement push ou pull_request déclenche deux jobs de test en parallèle sur Node.js 24 et 26, puis un job qui publie l'image Docker sur ghcr.io
---
Étape 1 : un premier workflow pour l'API Node.js
Le point de départ : récupérer le code, installer Node.js et les dépendances, lancer les tests. L'API du tutoriel déclare "test": "node --test" dans son package.json et exige Node.js 24 ou plus. Créez .github/workflows/ci.yml :
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm test
npm ci installe exactement ce que fixe package-lock.json, que le dépôt doit contenir, et npm test lance node --test. Le timeout-minutes évite qu'un test bloqué tourne jusqu'à la limite de 6 heures par job des runners hébergés, fixée par la page des limites de GitHub Actions.
Pourquoi checkout v7 et pas v4 ? Selon son README, la v5 est passée au moteur node24, la v6 range les identifiants Git hors de .git/config, et la v7 refuse par défaut le code d'un fork sous pull_request_target ou workflow_run.
Les dernières versions au 31 octobre 2026 sont actions/checkout v7.0.1 et actions/setup-node v7.1.0. L'onglet Actions affiche ensuite chaque exécution ; pour lire les journaux d'un runner Ubuntu, voyez nos commandes Linux de base.
---
Étape 2 : tester sur Node.js 24 et 26 avec une matrice
Une matrice exécute le même job plusieurs fois avec des valeurs différentes. Pour l'API, deux versions comptent : Node.js 24, minimum exigé par le package.json, et Node.js 26, passé en LTS avec la version 26.13.0 du 28 octobre 2026 selon l'index des versions de Node.js. Remplacez le job test :
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
node-version: [24, 26]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
Les deux jobs tournent en parallèle. fail-fast: false laisse finir le second si le premier échoue. La page des limites plafonne une matrice à 256 jobs, et chaque job est facturé séparément.
---
Étape 3 : le cache, plus rapide mais pas sans risque
Le cache de GitHub Actions évite de retélécharger les paquets npm à chaque exécution : ajoutez cache: npm sous with. D'après le README de setup-node, la clé dérive du hachage de package-lock.json et l'action met en cache le dossier global de npm, pas node_modules.
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: npm
Les limites, lues dans la référence du cache de dépendances le 31 octobre 2026 :
- 10 Go par dépôt par défaut. Une entrée non lue depuis plus de 7 jours est supprimée.
- Aucun secret dans le cache. Toute personne ayant un accès en lecture peut ouvrir une pull request et lire un cache.
Le mot-clé cache-mode (read, write, write-only ou none) fixe les droits d'un job sur le cache. Il se place en tête du workflow ou dans un job, par exemple cache-mode: read sous test:, d'après la référence de syntaxe. Selon la documentation du cache consultée le 31 octobre 2026, un déclencheur peu fiable comme pull_request_target n'obtient par défaut que la lecture, et y déclarer write réintroduit le risque d'empoisonnement du cache.
---
Étape 4 : secrets, variables et environnements
Un jeton n'entre jamais dans le YAML : il se range dans Settings, Secrets and variables, Actions, et se lit avec ${{ secrets.NOM }}. Une valeur non sensible, comme une URL, devient une variable lue avec ${{ vars.NOM }}, au niveau du dépôt, de l'organisation ou d'un environnement, selon la page sur les variables.
Pour rattacher le job image de l'étape 5 à un environnement production, avec une variable PRODUCTION_URL créée dans cet environnement, ajoutez au job :
environment:
name: production
url: ${{ vars.PRODUCTION_URL }}
La référence de syntaxe autorise le contexte vars dans url. Mais la page de gestion des environnements réserve les environnements aux dépôts publics sur GitHub Free ; en privé, il faut Pro, Team ou Enterprise. C'est pourquoi le fichier complet plus bas s'en passe.
Notre workflow n'a besoin d'aucun secret ajouté à la main. Les tests de l'API fixent eux-mêmes JWT_SECRET et DB_PATH=':memory:' dans test/api.test.js, et le job image construit l'image sans démarrer le conteneur. Quant au GITHUB_TOKEN, créé pour chaque job, il suffit à publier sur le registre de GitHub.
---
Étape 5 : construire et publier l'image Docker sur GHCR
GHCR, le registre de conteneurs de GitHub (ghcr.io), accepte un push authentifié par GITHUB_TOKEN. L'image reprend le Dockerfile multi-étapes de notre tutoriel Docker pour débutants, basé sur node:24-alpine, placé à la racine du projet. Ajoutez ce job à ci.yml :
image:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=sha
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Ce que fait chaque bloc :
- needs et if. L'image n'est construite que si les deux tests réussissent, et seulement pour un push sur main.
- docker/metadata-action. L'image prend le nom du dépôt, ghcr.io/<compte>/<dépôt>, soit ghcr.io/<compte>/api-taches si le dépôt s'appelle comme le dossier du tutoriel. L'action produit les tags main et sha- suivi du commit abrégé, plus les labels OCI. Son README précise qu'il met le nom d'image en minuscules, ce qu'exige le registre.
- context: . avec checkout. Sans checkout, build-push-action lit le dépôt par Git et ignore les modifications des étapes précédentes, selon son README.
- type=gha. Les couches Docker vont dans le cache de GitHub Actions. Avec plusieurs images, fixez un scope distinct, sinon chaque build écrase le cache du précédent, prévient la documentation de Docker.
Deux pièges documentés par GitHub sur la page du Container registry. Un paquet publié pour la première fois est privé par défaut. Et le GITHUB_TOKEN ne peut pas pousser si une image du même nom a déjà été poussée à la main sans être reliée au dépôt.
Côté coût, la facturation de GitHub Packages indique le 31 octobre 2026 que le stockage du Container registry est gratuit pour l'instant, avec un mois de préavis avant tout changement. L'image se déploie ensuite derrière notre reverse proxy Nginx avec Docker Compose.
---
Étape 6 : GITHUB_TOKEN minimal et actions épinglées
Cette étape a deux volets : limiter ce que le jeton peut faire, et figer le code des actions exécutées.
Le principe du moindre privilège
Le GITHUB_TOKEN est limité au dépôt et valable le temps du job. Ses droits dépendent du réglage du dépôt, puis du bloc permissions. La référence de syntaxe fixe la règle utile : dès que vous citez une permission, toutes celles que vous ne citez pas passent à none.
D'où notre schéma : contents: read au niveau du workflow, packages: write dans le seul job image. C'est ce que récompense le test Token-Permissions d'OpenSSF Scorecard, qui classe ce risque comme élevé. L'option persist-credentials: false de checkout évite en plus de conserver le jeton pour les commandes Git suivantes.
Épingler les actions sur un SHA
Un tag comme @v7 peut être déplacé. Selon l'avis de sécurité GitHub GHSA-mrrh-fwg8-r2c3 (CVE-2025-30066), des attaquants ont redirigé en mars 2025 plusieurs tags de tj-actions/changed-files vers un commit malveillant, qui affichait les secrets dans les journaux entre le 14 et le 15 mars.
La référence de sécurité de GitHub Actions recommande donc d'épingler chaque action sur l'empreinte complète d'un commit, en vérifiant qu'elle vient du dépôt de l'action et non d'un fork. La forme est uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 : l'empreinte fige le code, le commentaire garde la version lisible. Le fichier complet plus bas épingle ainsi les six actions sur les versions publiées au 31 octobre 2026. Nous avons lu ces empreintes sur l'API de GitHub ; vérifiez-les sur la page des versions de chaque action.
Pour ne pas figer des versions vulnérables, laissez Dependabot proposer les mises à jour avec .github/dependabot.yml, selon la documentation de Dependabot :
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Selon la référence de sécurité, les alertes Dependabot ignorent les actions épinglées par SHA ; les mises à jour de version, elles, fonctionnent.
GitHub va plus loin avec les fichiers de verrouillage des workflows : .github/workflows/actions.lock, généré par l'extension gh actions-lock, fige le commit de chaque action, dépendances indirectes comprises, et vérifie l'identité du dépôt source. La référence de sécurité le présente comme la meilleure protection, qui remplace l'épinglage par SHA pour les dépôts qui l'adoptent. Ce tutoriel garde les SHA : la documentation ne dit pas si le dispositif est en préversion ou disponible pour tous, et il exige une extension de GitHub CLI.
---
Étape 7 : concurrence et workflows réutilisables
Deux mots-clés évitent le gaspillage et la copie de YAML. Le premier, concurrency, se place en tête de ci.yml :
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
D'après la page sur la concurrence, un seul workflow s'exécute à la fois par groupe, et une exécution en attente est remplacée par la plus récente. L'expression annule aussi l'exécution en cours, mais seulement pour une pull request. Sur main, l'exécution en cours va à son terme, mais une exécution en attente reste remplacée par la plus récente : seul le dernier push en file est publié.
Le second, workflow_call, transforme un workflow en brique réutilisable. Déplacez le job de test dans .github/workflows/tests-node.yml :
name: Tests Node.js
on:
workflow_call:
inputs:
node-versions:
description: Versions de Node.js, au format JSON
type: string
default: '[24, 26]'
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
node-version: ${{ fromJSON(inputs.node-versions) }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
Dans ci.yml, le job test devient un simple appel, et image garde son needs: test :
jobs:
test:
uses: ./.github/workflows/tests-node.yml
permissions:
contents: read
with:
node-versions: '[24, 26]'
La forme ./.github/workflows/{filename} est celle que la documentation sur la réutilisation donne pour un workflow du même dépôt. La même page, consultée le 31 octobre 2026, mentionne aussi une forme $/.github/workflows/{filename}, qu'elle dit recommandée et indisponible sur GitHub Enterprise Server ; nous ne l'avons pas testée. La référence des workflows réutilisables fixe dix niveaux d'imbrication au plus et 50 workflows réutilisables distincts par fichier ; les variables env de l'appelant ne sont pas transmises, et le workflow appelé ne peut pas élever les permissions reçues.
---
Le fichier ci.yml complet
Voici .github/workflows/ci.yml une fois les étapes 1 à 7 assemblées, avec le job test écrit en entier (sans le workflow réutilisable de l'étape 7 ni l'environnement optionnel de l'étape 4) : déclencheurs, permissions minimales, concurrence, matrice avec cache, publication sur GHCR, et chaque action épinglée sur son SHA. Sa syntaxe a été vérifiée avec le module yaml de Python ; il n'a pas été exécuté sur GitHub. Avec le workflow réutilisable de l'étape 7, le job test ci-dessous est remplacé par l'appel à tests-node.yml.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
node-version: [24, 26]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm test
image:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=sha
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
---
Combien coûte GitHub Actions en octobre 2026
GitHub Actions reste gratuit pour les dépôts publics sur les runners standards, et sur vos propres machines. Pour les dépôts privés, la page de facturation de GitHub Actions, lue le 31 octobre 2026, exprime désormais le quota en dollars de calcul inclus, et non plus en minutes, déduits selon le tarif de chaque runner :
| Offre | Calcul inclus par mois | Soit, sur Linux 2 cœurs | Stockage d'artefacts | Cache par dépôt |
|---|---|---|---|---|
| GitHub Free | 12 $ | 2 000 minutes | 500 Mo | 10 Go |
| GitHub Pro | 18 $ | 3 000 minutes | 1 Go | 10 Go |
| GitHub Team | 18 $ | 3 000 minutes | 2 Go | 10 Go |
| GitHub Enterprise Cloud | 300 $ | 50 000 minutes | 50 Go | 10 Go |
La conversion est notre calcul à 0,006 $ la minute. Les 2 000 minutes souvent citées, y compris dans notre guide CI/CD, correspondent donc aux 12 $ de l'offre Free sur Linux 2 cœurs ; la page de prix de GitHub affiche bien 2 000, 3 000 et 50 000 minutes pour Free, Team et Enterprise, avec Team à 4 $ et Enterprise à 21 $ par utilisateur et par mois.
Le quota en dollars change la donne dès que vous quittez Linux. La grille des runners facture 0,002 $ la minute sur Linux 1 cœur, 0,005 $ sur Linux arm64 2 cœurs, 0,010 $ sur Windows 2 cœurs et 0,062 $ sur macOS 3 ou 4 cœurs. Les 12 $ de l'offre Free couvrent donc environ 193 minutes de macOS seulement.
!Graphique en barres horizontales GitHub Actions : selon notre calcul, les 12 dollars inclus dans GitHub Free couvrent 6 000 minutes sur Linux 1 cœur, 2 400 sur Linux arm64 2 cœurs, 2 000 sur Linux 2 cœurs, 1 200 sur Windows 2 cœurs et 193 sur macOS 3 ou 4 cœurs
> Notre calcul. Chaque job étant arrondi à la minute supérieure, notre ci.yml coûte au moins 3 minutes par push sur main et 2 par pull request. À 50 de chaque par mois sur un dépôt privé, cela fait au moins 250 minutes, bien dans le quota Free. Une matrice sur trois systèmes d'exploitation changerait tout : une minute de macOS coûte plus de dix minutes de Linux 2 cœurs.
Le stockage se paie à part : 0,25 $ par Go et par mois au-delà de l'allocation d'artefacts, 0,07 $ pour le cache si vous avez relevé sa limite de 10 Go, selon la même page.
---
Runners auto-hébergés : gratuits, pas sans danger
Un runner auto-hébergé est une machine à vous qui exécute les jobs à la place des machines de GitHub. La page de facturation les range, le 31 octobre 2026, parmi les usages gratuits qui ne réduisent pas le calcul inclus. GitHub avait annoncé en décembre 2025 une redevance de 0,002 $ la minute sur les runners auto-hébergés à partir du 1er mars 2026, puis a écrit la reporter « pour prendre le temps de réévaluer notre approche » (notre traduction), sur sa page consacrée aux changements de prix de 2026.
Selon la page sur les runners auto-hébergés, vous payez la machine et mettez à jour vous-même système et outils. Checkout et setup-node, passées au moteur node24, exigent le runner v2.327.1 ou plus selon leurs README. Un job peut y tourner 5 jours d'après la page des limites, mais le GITHUB_TOKEN ne se renouvelle que pendant 24 heures, précise la page GITHUB_TOKEN.
Le vrai risque est ailleurs. Selon la référence de sécurité, un runner auto-hébergé ne garantit pas une machine propre à chaque job et peut être compromis durablement : il ne devrait presque jamais servir à un dépôt public, où n'importe qui peut ouvrir une pull request. Elle mentionne aussi des runners éphémères, dits JIT, qui n'exécutent qu'un job.
Verdict : pour cette API, les runners hébergés suffisent. Notre comparatif de l'hébergement cloud pour développeurs aide à choisir la machine.
---
Ce que nous n'avons pas pu vérifier
Ce tutoriel est une synthèse documentaire. Restent incertains :
- L'exécution des workflows. Leur syntaxe YAML a été vérifiée avec un analyseur Python, mais aucun n'a tourné sur GitHub. Testez-les d'abord sur une branche.
- Le sort de la redevance des runners auto-hébergés. Reportée, mais nous n'avons trouvé aucune annonce qui l'annule.
- Le prix de GitHub Pro. La page de prix lue ne l'affiche pas, ni aucun prix en euros.
- Le statut exact des fichiers de verrouillage. La documentation les décrit sans préciser s'ils sont en préversion ou disponibles pour tous.
- La durée réelle des jobs. Nos chiffres de minutes sont des minimums dus à l'arrondi, pas des mesures.
---
Questions fréquentes
Combien de minutes GitHub Actions gratuites pour un dépôt privé ?
Sur l'offre Free, 12 $ de calcul par mois, soit 2 000 minutes sur le runner Linux 2 cœurs à 0,006 $ la minute, mais 193 seulement sur macOS, selon la page de facturation de GitHub lue le 31 octobre 2026. Les dépôts publics sur les runners standards et les runners auto-hébergés ne consomment rien.
Quelle version d'actions/checkout utiliser ?
La v7 : au 31 octobre 2026, la dernière version publiée est la 7.0.1. Épinglez-la sur son SHA complet, avec la version en commentaire.
Quelle différence entre une action et un workflow ?
Le workflow est votre fichier YAML, déclenché par un événement, qui contient des jobs. Une action est une brique appelée dans un step avec uses, comme actions/setup-node. Un workflow réutilisable s'appelle, lui, au niveau d'un job.
Comment publier une image Docker avec GitHub Actions ?
Connectez-vous à ghcr.io avec docker/login-action et le GITHUB_TOKEN, puis construisez et poussez avec docker/build-push-action, dans un job qui déclare packages: write (étape 5).
---
À propos de l'auteur
Abdelilah Ourti est ingénieur IA et architecte logiciel. Il conçoit et déploie des applications web et des systèmes d'IA, et publie sur NeurauTech des guides et des comparatifs destinés aux développeurs francophones. Ses autres articles sont réunis sur sa page auteur.
Méthode. Synthèse documentaire fondée exclusivement sur des sources publiques consultées le 31 octobre 2026 : documentation de GitHub Actions, de GitHub Packages et de Dependabot, pages de facturation et de prix de GitHub, README et pages de versions des actions officielles et des actions Docker, documentation de Docker, OpenSSF Scorecard, avis de sécurité de GitHub, index des versions de Node.js. Les empreintes SHA ont été lues sur l'API de GitHub. La syntaxe YAML a été vérifiée localement, mais aucun workflow n'a été exécuté. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs mentionnés.
Une erreur ou une version périmée ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée.
Dernière mise à jour : 31 octobre 2026.
Image : Alberto Rodríguez (Unsplash License).
GitHub Actions Node 20 Deprecation: What Broke on 23 September (and Why node20 Isn't the Problem)
GitHub removed Node 20 from the Actions runners on 23 September 2026, and an action that declares using: node20 still doesn't fail for that: since 16 June the runner rewrites node20 to node24 before it executes anything, and it does the same with node12 and node16. What went away on the 23rd was the escape hatch: the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION variable no longer brings Node 20 back. The next date with real consequences is 2 November, when macos-14 is retired.
That inverts who is at risk. If your CI has been green since June without that variable, the 23rd changed nothing for you on GitHub-hosted runners. The ones that break are precisely the teams that already hit an action that can't handle Node 24, set the variable to keep moving, and filed the real fix under later. And around that date there are four more with real consequences: the self-hosted runner minimum version (25 September), retention for checks and runs (1 October), the macos-14 brownouts (October) and its retirement (2 November).
I checked this against the runner's source code and against this site's own CI, which uses two node20 actions. Everything below comes with its source.
The calendar, by date
| Date | What changes | What breaks | How to detect it |
|---|---|---|---|
| 14, 16 and 18 Sep (11:00-15:00 ET) | Brownouts ahead of the self-hosted runner minimum version | Outdated runners cannot register or run jobs during the window | Runner deprecations API (below) |
| 17 Sep | ubuntu-22.04 and ubuntu-22.04-arm enter deprecation | Nothing fails yet; queues may be longer | grep for the label |
| 21-30 Sep | windows-11-arm moves to the Visual Studio 2026 image | The toolchain underneath changes | grep windows-11-arm |
| 23 Sep | Node 20 leaves the runners | ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION is ignored; Linux ARM32 runners lose support | grep for the variable |
| 25 Sep | Self-hosted runner minimum version is enforced | Registering requires ≥ 2.329.0; a runner that doesn't install each release within 30 days stops receiving jobs | Deprecations API |
| 1 Oct | Actions retention now covers checks, workflow runs and statuses | Anything past your retention period (90 days by default) is deleted | Repo/org retention setting |
| 5-31 Oct | Eight macos-14 brownouts | Jobs scheduled inside the window fail | grep macos-14 |
| 2 Nov | macos-14 retired | Jobs with that label terminate with an error | grep macos-14 |
| 17 Apr 2027 | ubuntu-22.04 retired (brownouts before, in March and April) | Jobs terminate with an error | grep for the label |
1. Node 20: what actually happens on 23 September
What the notice says
GitHub's changelog post from 19 September 2025, with an editor's note dated 25 August 2026, sets two dates: from 16 June 2026 runners use Node 24 by default, and the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true opt-out back to Node 20 "will only work until we upgrade the runner and remove Node20 on September 23rd, 2026." It adds two platform limits: Node 24 is incompatible with macOS 13.4 and lower, and it has no official ARM32 support.
What the notice does not explain is what happens to an action whose action.yml still says using: node20. That lives in the code.
What the runner code says
In HandlerFactory.cs (identical at tag v2.337.0, the current release), the runner does two things before launching a JavaScript action:
1. If the action declares node12 or node16, it bumps it to node20.
2. If it ends up on node20, it asks NodeUtil.DetermineActionsNodeVersion which version to use.
That function has three phases, driven by feature flags on GitHub's side:
// Phase 3: Always use Node 24 regardless of environment variables
if (requireNode24)
{
return (Constants.Runner.NodeMigration.Node24, null);
}
// ...
// Phase 2: Node 24 is the default
if (useNode24ByDefault)
{
if (allowUnsecureNode)
{
return (Constants.Runner.NodeMigration.Node20, null);
}
return (Constants.Runner.NodeMigration.Node24, null);
}
PR #3948, which introduced this, is blunt about it: in phase 3, "All actions use Node 24" and "No opt-out options available." No branch returns an error for declaring node20. The action runs on a different Node, which is very different from not running.
Note the order too: node12 → node20 → node24. An old action with using: node16 (for example actions/checkout@v3) ends up on Node 24 as well.
The proof: this site's CI
This blog's CI workflow uses actions/checkout@v4 and actions/setup-node@v4. The official action.yml files at those tags declare using: node20; from v5 onward they declare node24 (I checked tags v4 through v7 of both). The run on 11 September, on runner 2.337.0 with the ubuntu-24.04 image, finished green with this annotation:
Node.js 20 is deprecated. The following actions target Node.js 20 but are being
forced to run on Node.js 24: actions/checkout@v4, actions/setup-node@v4.
And in the log of those two actions' steps, this line:
Node 20 is being deprecated. This workflow is running with Node 24 by default.
If you need to temporarily use Node 20, you can set the
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable.
The same annotation appears on the very first run of that workflow, on 23 August. In other words, my two node20 actions have never run on Node 20 in this CI, and I didn't touch anything to make that happen. 23 September doesn't change their runtime, because that change already happened in June. Bumping them to v5 or later clears the annotation, and it's worth doing, but there's no clock on it.
What it looks like when it does break
There are three real ways to fail, and none of them is a GitHub message saying "Node 20 no longer exists."
You had the variable set. Some action of yours tripped on Node 24 after 16 June, you set ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true, and it stayed on Node 20. In phase 3 the runner ignores the variable without telling you: the requireNode24 branch returns null as its message. The action goes back to Node 24, and the error you see is its own code's error, the same one you saw the first time. One hint that phase 3 is live: with the current runner code, the "If you need to temporarily use Node 20…" line disappears from the log, because it is only printed while Node 24 is the default but not yet mandatory.
The action uses something Node 24 removed. Per the Node 24 deprecations table and the 24.0.0 release notes, these reached end-of-life in that release:
| API | Status in Node 24 | How it fails |
|---|---|---|
| tls.createSecurePair() | Removed (DEP0064) | TypeError: ... is not a function when called |
| fs.truncate() with a file descriptor | Removed (DEP0081) | Throws ERR_INVALID_ARG_TYPE: it expects a path. Use fs.ftruncate() |
| dirent.path | Removed (DEP0178) | Doesn't throw on its own: the property is gone and you read undefined. Use dirent.parentPath |
| OutgoingMessage.prototype._headers / _headerNames | Removed (DEP0066) | You read undefined. Use getHeaders() |
dirent.path is the sneakiest one on the list, because reading it throws nothing: the failure shows up later, wherever that path gets used, or never, if it's only concatenated into a string and comes out as undefined/file. If you maintain an action that walks directories, search for that property before you trust the green check.
Your self-hosted runner can't run Node 24. On Linux ARM32, the same NodeUtil.cs has a kill switch that fails the step with:
Linux ARM32 runners are no longer supported. Please migrate to a supported platform.
Before it's flipped, the runner keeps those actions on Node 20 with a warning whose default date is the Node 20 removal date: "Linux ARM32 runners are deprecated and will no longer be supported after September 23rd, 2026." GitHub hasn't published the exact day the switch flips; the changelog only says ARM32 loses support after the Node 20 deprecation. On macOS, Node 24's BUILDING.md requires macOS 13.5 or later.
2. The audit: what to look for and where
In one repository
Run this from the root:
# 1. The switch that stops working on 23 September
grep -rn 'ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION' .github/
# 2. Image labels with a date. No "runs-on:" on purpose,
# so matrices show up too (os: [ubuntu-22.04, ...])
grep -rnE 'ubuntu-22\.04|macos-14|windows-11-arm' .github/
# 3. Your own or local actions that declare an old Node
grep -rnE --include=action.yml --include=action.yaml \
"using:[[:space:]]*['\"]?node(12|16|20)" .
Search 2 has a blind spot: runs-on: ${{ vars.RUNNER }} or a label passed as an input to a reusable workflow won't show up in any grep. For those, the log is the source of truth. The "Set up job" step prints the real image:
gh run view <run-id> --log | grep -m1 'Image:'
# Image: ubuntu-24.04
If the matrix is long or nested, converting it to JSON and filtering it with jq is usually faster than reading the YAML by eye:
> Free tool: JSON and YAML Converter — Convert JSON to YAML and YAML to JSON instantly, with configurable indentation, download and copy. All in your browser, nothing uploaded.
To find out which runtime each third-party action you use declares, including SHA-pinned ones, you have to read its action.yml at that exact ref. This script does it with gh:
grep -rhoE "uses:[[:space:]]*['\"]?[^[:space:]'\"#]+@[^[:space:]'\"#]+" .github/ \
| sed -E "s/uses:[[:space:]]*['\"]?//" | sort -u \
| while read -r ref; do
spec=${ref%@*}; ver=${ref#*@}
case "$spec" in ./*|docker://*) continue ;; esac
repo=$(cut -d/ -f1-2 <<<"$spec"); sub=$(cut -d/ -f3- <<<"$spec")
for f in action.yml action.yaml; do
rt=$(gh api "repos/$repo/contents/${sub:+$sub/}$f?ref=$ver" --jq .content 2>/dev/null \
| base64 -d | grep -E '^[[:space:]]*using:' | tr -d " '\"" | cut -d: -f2)
[ -n "$rt" ] && { echo "$rt $ref"; break; }
done
done
On this repo it prints:
node20 actions/checkout@v4
node20 actions/setup-node@v4
It handles subpaths (github/codeql-action/init@v3) and SHAs. A SHA pointing at a node20 release doesn't fail on the 23rd, for the same reason @v4 doesn't: the runner forces it onto Node 24. What's different with a SHA is how it gets updated: nothing moves on its own, so Dependabot or a human has to bump it.
One warning before bumping a major: read the release notes. actions/setup-node v5 turned on automatic caching when package.json has a packageManager field, and v6 limited it to npm. If you already pass cache: npm explicitly, as my CI does, it doesn't affect you.
Across an organization
GitHub code search reaches every repo at once:
gh search code --owner YOUR_ORG 'ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION'
gh search code --owner YOUR_ORG 'ubuntu-22.04 path:.github/workflows'
gh search code --owner YOUR_ORG 'macos-14 path:.github/workflows'
gh search code --owner YOUR_ORG node20 --filename action.yml
Two limits of the code search API that will bite you: it only searches the default branch (a workflow on a release branch won't show up) and it allows 10 requests per minute. While preparing this post I hit HTTP 403: API rate limit exceeded as soon as I chained a handful of searches; add a sleep between them if you automate it.
3. Self-hosted runners: two dates in three days
If you only use GitHub-hosted runners, skip this section. If you run your own, 25 September matters more than the 23rd.
The 12 June changelog post resumes enforcement of two rules on github.com:
- To register (or re-register) a runner you need version 2.329.0 or later.
- To keep running jobs, the runner must install each new release within 30 days of its publication. "A runner pinned to 2.329.0 that never updates again will not pick up jobs."
Full enforcement begins on 25 September 2026 (the notice's table labels that date as GitHub Enterprise Cloud, and the same notice states the change applies to github.com), with brownouts first: on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern, out-of-date runners can neither register nor run jobs. What you see, per the notice, is that "workflows targeting unsupported runners may remain queued or fail."
The way to find out when your version expires is an endpoint GitHub added on 3 September. I queried it today:
gh api repos/OWNER/REPO/actions/runners/deprecations/2.335.1
# {"runner_version":"2.335.1","runtime_deprecates_at":"2026-09-24T15:30:55Z"}
gh api repos/OWNER/REPO/actions/runners/deprecations/2.337.0
# {"runner_version":"2.337.0","runtime_deprecates_at":null}
A runner on 2.335.1 falls out of support on 24 September, so by the time full enforcement starts on the 25th it no longer gets jobs. You get the installed version on the machine itself with ./config.sh --version.
The self-hosted checklist, in order:
1. Update the runner to the current release and leave auto-update on. If it's off, you need a manual cadence shorter than 30 days.
2. Rebuild the images, VM templates and containers you create runners from. The changelog asks you to recreate runners built from older cached images or templates: if the image ships a runner older than 2.329.0, it won't register.
3. Linux ARM32: move to arm64. Node 24 has no official support there, the runner's linux-arm package only bundles the Node 20 binary (externals.sh), and the code that fails the step is already in the runner.
4. macOS 13.4 or earlier: upgrade the OS. GitHub lists Node 24 as incompatible with those versions.
5. Actions that already declare node24 (such as actions/checkout@v5) require runner v2.327.1 at minimum. If you've done step 1, you already have it.
4. Retention, 1 October: history gets deleted
This change breaks no build, but it deletes data. According to the 27 August changelog post, starting 1 October 2026 checks, workflow runs and statuses follow the same retention setting that already governed artifacts and logs, 90 days by default. Until now they were kept for "400+ days" regardless of your configuration.
- For public repositories the maximum is 90 days.
- The change is not retroactive: raising retention later won't restore anything.
- Check and workflow run metadata isn't billed as storage; the associated artifacts and logs are.
What breaks is anything that reads that history beyond your period: deployment metrics computed from gh run list, audits that link to a specific run, quarterly reports. Export what you need before 1 October.
What the changelog does not say: what happens to a pull request open for more than 90 days whose required check expires. I couldn't find it documented. If you have long-lived PRs under branch protection, look at them after 1 October before assuming anything.
5. Images: macos-14 has a date, ubuntu-22.04 doesn't yet
macos-14 (actions/runner-images#13518): deprecation started on 6 July and support ends on 2 November 2026. Before that there are eight brownouts during which jobs fail: 5, 12, 16, 19, 23, 26, 29 and 30 October, each from 14:00 UTC to 00:00 UTC the next day. It covers macos-14, macos-14-large and macos-14-xlarge. The replacement is macos-15, macos-26 or macos-latest, which started pointing to macOS 26 with the migration that began on 15 June.
ubuntu-22.04 (actions/runner-images#14254): on 17 September deprecation only begins. The issue warns about longer queue times at peak hours, not failures. Retirement is on 17 April 2027, with brownouts that the issue schedules for March and April (no year given, but they fall before the retirement). It also covers ubuntu-22.04-arm. The replacement is ubuntu-24.04, ubuntu-26.04 or ubuntu-latest.
If someone tells you ubuntu-22.04 breaks this week, it doesn't. Migrate calmly, but don't leave it for March.
What's in the changelog and breaks nothing
- cache-mode (10 September): a new key to limit cache access per workflow or job. It's opt-in: workflows that don't set it "continue to use the existing secure defaults."
- The separate Code Quality path (20 August): only affects you if you have reports filtering on dynamic/github-code-scanning/codeql or on the github-advanced-security actor.
- actions/checkout and pull_request_target (18 June): this one does break, but it already happened. Since 20 July the floating tags, @v4 included, refuse to check out fork code in pull_request_target. If your workflow depended on that, it's already red. It's the same family of risk I covered in the worm that got in through npm trusted publishing.
What I'm doing with my CI
Nothing urgent. actions/checkout@v4 and actions/setup-node@v4 have been running on Node 24 since June, the workflow doesn't use the opt-out variable, it runs on ubuntu-latest (currently ubuntu-24.04) and on GitHub-hosted runners. 23 September changes nothing for it.
I am going to bump both actions to their current major, for two reasons unrelated to the date: a permanent yellow annotation trains you to ignore annotations, and an action its authors no longer test on the runtime it actually runs on is debt. If you're also thinking about your own project's Node rather than the actions', I covered that in what breaks when upgrading to Node 26.
Frequently asked questions
What happens to GitHub Actions on 23 September 2026?
GitHub removes Node 20 from the runners. Since 16 June 2026 runners had already been running actions on Node 24 by default, and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true let you go back to Node 20. From 23 September that variable stops working and every JavaScript action runs on Node 24. Self-hosted runners on Linux ARM32 lose support, because Node 24 has no official support for that platform.
Does an action that declares using: node20 fail after 23 September?
Not for declaring it. The runner code (HandlerFactory.cs and NodeUtil.cs in actions/runner) rewrites node12 and node16 to node20, and node20 to node24; in the final phase it ignores the environment variables and always picks Node 24, without returning an error. The action fails if its code uses something Node 24 removed, such as tls.createSecurePair or fs.truncate with a file descriptor, or if it runs on a self-hosted Linux ARM32 runner or on macOS 13.4 or earlier. The logs show the annotation: The following actions target Node.js 20 but are being forced to run on Node.js 24.
Do I have to update actions/checkout@v4 and actions/setup-node@v4?
It's a good idea, but no date forces it. The v4 tags of both declare node20 and v5 onward declare node24. On GitHub-hosted runners, the v4 tags have already been running on Node 24 since June and keep working. When bumping setup-node, read the release notes: v5 turned on automatic caching when package.json has packageManager, and v6 limited it to npm.
How do I find affected workflows across my organization?
With GitHub code search: gh search code --owner YOUR_ORG with ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION, with ubuntu-22.04 path:.github/workflows, with macos-14 path:.github/workflows, and with node20 --filename action.yml for your own actions. The API only searches the default branch and allows 10 requests per minute. Labels that come from variables or expressions don't show up in any search: for those, check the Image: line of the Set up job step in the log.
Does ubuntu-22.04 break on 17 September 2026?
No. On 17 September deprecation only begins, and the notice talks about longer queue times at peak hours. Jobs start failing during the brownouts that issue 14254 in actions/runner-images schedules for March and April, before the image is retired on 17 April 2027. The one with a date this fall is macos-14: brownouts in October and retirement on 2 November 2026.
What do self-hosted runners need to do before 25 September?
Update the runner. From 25 September 2026 GitHub requires version 2.329.0 to register a runner, and each new release must be installed within 30 days to keep receiving jobs. There are brownouts first on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern. The endpoint GET /repos/OWNER/REPO/actions/runners/deprecations/VERSION returns when your version stops running jobs. Also rebuild the images you create runners from.
What changes with GitHub Actions retention on 1 October 2026?
Checks, workflow runs and statuses start following the artifact and log retention setting, which defaults to 90 days; before, they were kept for more than 400 days. For public repositories the maximum is 90 days, and the change is not retroactive. If you have metrics or audits that read old runs, export the data before that date.
---
Originally published on ortamarco.me.
GitHub ActionsからAWSへ安全に接続する:OIDCとIAMロールの実装手順
GitHub ActionsからAWSへデプロイする際、AWSアクセスキーをSecretsに保存する方法だけが選択肢ではありません。OIDC(OpenID Connect)を使えば、GitHubが発行する身元証明用トークンをAWSに渡し、短期間だけ有効な認証情報を受け取れます。
この記事では、GitHub ActionsからS3へファイルをアップロードする例で、実装と失敗しやすい箇所を整理します。2026年10月時点の公式ドキュメントを確認しています。
仕組みを理解する
GitHub Actions
→ GitHubからOIDCトークンを取得
→ AWS STSでIAMロールを引き受ける
→ 一時的なAWS認証情報を使ってS3にアクセス
OIDC:異なるサービス間で実行元の身元を証明するための仕組み。
STS:AWSの一時的な認証情報を発行するサービス。
IAMロール:AWSで引き受けられる権限のまとまり。
信頼ポリシー:そのロールを誰が引き受けられるか決める設定。
長期アクセスキーの保管とローテーションを減らせる一方で、ロールの権限は引き続き最小限に設定する必要があります。
1. GitHubのOIDCプロバイダーをAWSへ登録する
AWS IAMでIDプロバイダーを作成し、次の値を設定します。
項目
設定値
プロバイダーURL
token.actions.githubusercontent.com
Audience
sts.amazonaws.com
Audience(オーディエンス)はトークンの利用先を表します。一般的なAWSの構成で公式Actionを使う場合の値です。既存のIDプロバイダーがある場合は重複して作らず設定を確認します。
2. 信頼ポリシーで実行元を限定する
IAMロールの信頼ポリシー例です。AWSアカウントIDやリポジトリ名はダミー値です。
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:example-org/example-api:ref:refs/heads/main"
}
}
}]
}
sub(subject)はトークンの実行元を識別する値です。この例は特定のリポジトリのmainブランチだけを許可します。AWS公式ドキュメントも、組織・リポジトリ・ブランチなどで信頼対象を絞ることを推奨しています。
2026年7月15日以降に作成したGitHubリポジトリは要注意です。 GitHub公式リファレンスによると、新規リポジトリのデフォルトsubには変更されない所有者IDとリポジトリIDが含まれます。
従来形式
repo:example-org/example-api:ref:refs/heads/main
新形式(IDは説明用の値)
repo:example-org@111111/example-api@222222:ref:refs/heads/main
実際に使用する形式とIAMの信頼ポリシーを一致させてください。従来から存在するリポジトリも設定変更や移転で形式が変わる場合があります。またGitHub Environmentを使う場合は、ブランチ参照の代わりに環境名を含むsubを使うため、環境のデプロイ保護ルールも設定します。
3. AWS操作権限を最小にする
信頼ポリシーは「誰が使えるか」、権限ポリシーは「何をできるか」で、別々の設定です。S3の特定の場所にファイルをアップロードするだけなら、次のように絞れます。
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": ["arn:aws:s3:::example-deploy-bucket/releases/*"]
}]
}
s3:PutObjectはS3へのファイル書き込み権限です。s3:*やAdministratorAccessを最初から付けないようにします。S3バケットの設定やKMS暗号化などによっては追加権限が必要です。
4. GitHub Actionsの設定例
.github/workflows/deploy.ymlに次のように定義します。アカウントID・ロールARN・バケット名は自分の環境に置き換えてください。
name: Deploy to S3
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: aws-actions/configure-aws-credentials@v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/example-github-oidc
aws-region: ap-northeast-1
allowed-account-ids: "123456789012"
- name: Verify identity
run: aws sts get-caller-identity
- name: Upload
run: aws s3 cp public/index.html s3://example-deploy-bucket/releases/index.html
contents: readはリポジトリを読む権限、id-token: writeはOIDCトークンを要求する権限です。後者はAWSへの無制限な書き込み権限ではありません。 allowed-account-idsは、想定外のAWSアカウントの認証情報が返されたときに処理を失敗させます。
コマンドaws sts get-caller-identityは利用中のAWSアカウントと権限主体を確認します。aws s3 cpは指定したローカルファイルをS3へコピーします。ファイルpublic/index.htmlがリポジトリに存在する想定です。
Actionのバージョンは2026年10月10日時点の公式リリースを確認しています。本番では更新内容をレビューし、必要ならコミットSHAで固定します。
よくある失敗と対処
Not authorized to perform sts:AssumeRoleWithWebIdentity:IAMの信頼ポリシーを確認してください。subのリポジトリ名・ID形式・ブランチ・Environment、audの不一致が典型的です。
Unable to locate credentials:id-token: writeやActionの入力値、実行環境を確認します。
認証は成功したのにS3でAccessDenied:IAM権限ポリシー、S3バケットポリシー、暗号化設定などを確認します。信頼ポリシーと操作権限は異なります。
不要なブランチまで許可している:repo:example-org/example-api:*のように広く許可すると、意図しないワークフローでもロールを利用できる可能性があります。必要なブランチ・環境のみに制限し、信頼できないPull Requestから強いAWS権限を利用しない設計にします。
長期アクセスキーとどう使い分ける?
OIDCは初期設定としてプロバイダー・IAMロールの作成が必要ですが、長期アクセスキーをGitHub Secretsへ保存する必要がありません。アクセスキー方式はセットアップが簡単な場合がある一方、漏えいや鍵の交換を継続して管理します。
新しいGitHub Actions→AWS連携では、公式ガイドでも案内されているOIDCを第一候補にすると運用しやすくなります。
まとめ
認証失敗の調査では、GitHubのid-token: write→IAM信頼ポリシーのsub→IAM操作権限の順に確認すると原因を切り分けやすくなります。特に2026年7月以降のsub形式変更に注意してください。
一次情報
GitHub Docs:AWS向けOIDC設定
GitHub Docs:OIDCリファレンス
AWS IAM:GitHub OIDC向けIAMロール
aws-actions/configure-aws-credentials公式
公式リリース:configure-aws-credentials
公式リリース:checkout
systemd-analyze verify はタイプミスでも exit 0|--recursive-errors でunitファイルの警告をCIで落とす
TL;DR
- systemd-analyze verify は、Restrat= のような未知のキーや Restart=on-failur のような不正な値を警告するだけで、終了コードは 0 です。これは不具合ではなく、systemd-analyze(1) に書かれた仕様です。
- systemd 250 以降は --recursive-errors=no を付けると、対象のunitに警告が出た時点で exit 1 になります。CIではまずこれを使います。
- ただし手元の実測では、=no にすると Requires= の先が存在しないエラーが出力ごと消えて exit 0 になりました。依存先の欠落も止めたいなら --recursive-errors=one を使います。=one なら依存先も検証されます。
- systemd 250 より古い環境(例: GitHub ホストランナーの ubuntu-22.04 は 249)や、出力を1行でも許したくない場合は、「出力が空でなければ失敗」にするラッパーを使います。
検証環境: Debian 13 のコンテナ、systemd 257 (257.13-1~deb13u1)。以下のログはすべてこの環境で実行した結果をそのまま貼っています。
再現:タイプミスがあっても exit 0
検証用のファイルはすべて /tmp/qiita-verify/units/ に置き、/tmp/qiita-verify から相対パスで実行しています。
[Unit]
Description=demo agent
[Service]
ExecStart=/bin/true
Restrat=on-failure
RestartSec=10s
$ cd /tmp/qiita-verify
$ systemd-analyze verify --man=no units/typo.service; echo exit=$?
/tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring.
exit=0
相対パスで渡しても、警告には絶対パスと行番号が出ます。Restart= は書かれていない扱いになるので、プロセスが落ちても再起動しません。それでも verify は成功扱いです。
再起動の設定はZennの記事を参照してください。
値の綴りを間違えた場合も同じです。
[Unit]
Description=demo agent
[Service]
ExecStart=/bin/true
Restart=on-failur
RestartSec=10s
$ systemd-analyze verify --man=no units/badvalue.service; echo exit=$?
/tmp/qiita-verify/units/badvalue.service:6: Failed to parse Restart=on-failur, ignoring: Invalid argument
exit=0
原因:オプションなしでは警告があっても 0 を返す仕様
systemd-analyze(1) の --recursive-errors=MODE の項には、次のように書かれています。
> If this option is not specified, zero is returned as the exit status regardless whether warnings arise during verification or not.
つまり、オプションを付けない限り、警告がいくつ出ても終了コードは 0 です。未知のキーや不正な値は「その行を無視して読み込みを続ける」扱いの警告なので、ここに当てはまります。
同じ項で、モードごとの意味は次のとおり説明されています(Added in version 250)。
| MODE | 非0で終わる条件(マニュアルの記述) |
|---|---|
| yes | 指定したunitか、その依存先のどれかで警告が出たとき |
| no | 指定したunitだけの検証で警告が出たとき |
| one | 指定したunitか、その直接の依存先で警告が出たとき |
一方で、ExecStart= のコマンドが存在しない場合は、unitとして読み込めない「エラー」になるので、オプションがなくても exit 1 になります。セクション名の誤りは、どのセクションかで結果が分かれます。[Service] のセクション名を誤ると、その中身がまるごと無視されて ExecStart= がない扱いになり exit 1 です。[Unit] や [Install] の誤りは、ほかの警告と同じく exit 0 で、--recursive-errors=no を付けて初めて exit 1 になります。
flowchart TD
A[unitファイル] --> B[systemd-analyze verify --man=no]
B -->|読み込めないエラー<br>ExecStart不在・Serviceのセクション名の誤り| E1[exit 1]
B -->|警告のみ<br>未知のキー・不正な値・Unit/Installのセクション名の誤り| C{--recursive-errors}
C -->|指定なし| E0[exit 0 で素通り]
C -->|=no / =one / =yes| E2[exit 1]
B -->|Requires= の先が存在しない| D{--recursive-errors}
D -->|指定なし / =one / =yes| E3[exit 1]
D -->|=no| E4[出力なしで exit 0]
E0 -.->|systemd 250 未満| W[ラッパー: 出力が空でなければ NG]
対策1:--recursive-errors=no を付ける(systemd 250 以降)
$ systemd-analyze verify --man=no --recursive-errors=no units/typo.service; echo exit=$?
/tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring.
exit=1
$ systemd-analyze verify --man=no --recursive-errors=no units/badvalue.service; echo exit=$?
/tmp/qiita-verify/units/badvalue.service:6: Failed to parse Restart=on-failur, ignoring: Invalid argument
exit=1
出力は同じで、終了コードだけが 1 に変わります。複数のファイルをまとめて渡しても、1つでも警告があれば 1 になりました。
$ systemd-analyze verify --man=no --recursive-errors=no units/good.service units/typo.service; echo exit=$?
/tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring.
exit=1
--man=no は、Documentation= に書いた man ページの存在確認をしないためのオプションです。この検証環境のように man コマンドが入っていない環境では、Documentation=man:demo-agent(8) のようなunitで付けないと Command 'man demo-agent(8)' failed with code 1 で exit 1 になりました。
=no では Requires= の欠落が見えなくなる
依存先のunitが存在しないケースを試すと、=no だけ結果が変わりました。
[Unit]
Description=demo agent
Requires=missing-dep.service
After=missing-dep.service
[Service]
ExecStart=/bin/true
Restart=on-failure
$ systemd-analyze verify --man=no units/requires.service; echo exit=$?
requires.service: Failed to create requires.service/start: Unit missing-dep.service not found.
exit=1
$ systemd-analyze verify --man=no --recursive-errors=no units/requires.service; echo exit=$?
exit=0
$ systemd-analyze verify --man=no --recursive-errors=one units/requires.service; echo exit=$?
requires.service: Failed to create requires.service/start: Unit missing-dep.service not found.
exit=1
オプションなしなら出るエラーが、=no では出力ごと消えて exit 0 になります。これは実装どおりの挙動で、systemd v257 の src/analyze/analyze-verify-util.c では、=no のときに MANAGER_TEST_RUN_IGNORE_DEPENDENCIES を付けて検証用のマネージャーを起動しており、依存先を読みに行きません。自分のunitの書き間違いだけを見たいなら =no、依存先の欠落も同時に止めたいなら =one を使うのが安全です。ただし CI で =one を使うなら、依存先のunitもランナー上に存在していることが前提です。たとえば Requires=postgresql.service と書いたunitは、ランナーに PostgreSQL のunitがなければ、記述が正しくても exit 1 になります。この環境では、Wants=network-online.target を持つunitでも =one・=yes で余計な警告は出ませんでした(exit 0)。
誤りの種類ごとの終了コード
systemd 257 で、誤りを1つだけ入れたunitを用意して測った結果です(--man=no は共通)。root と一般ユーザーの両方で実行し、結果は同じでした(/run/systemd が存在する環境での結果。systemd が動いていないコンテナで一般ユーザーが実行すると、Failed to create directory '/run/systemd/': Permission denied で exit 1 になることがあります。手元で /run を空の tmpfs に差し替えて uid 1000 で試したところ、正しいunitでもこのエラーで exit 1 になり、XDG_RUNTIME_DIR を設定しても変わらず、root で実行した場合だけ exit 0 でした)。
| 誤りの種類 | 出力 | オプションなしの終了コード | --recursive-errors=noの終了コード | (参考)=one |
|---|---|---|---|---|
| 未知のキー(Restrat=) | Unknown key 'Restrat' in section [Service], ignoring. | 0 | 1 | 1 |
| 不正な値(Restart=on-failur) | Failed to parse Restart=on-failur, ignoring: Invalid argument | 0 | 1 | 1 |
| 旧名のキー StartLimitInterval= を [Service] に書く | なし | 0 | 0 | 0 |
| 新名の StartLimitIntervalSec= を [Service] に書く | Unknown key 'StartLimitIntervalSec' in section [Service], ignoring. | 0 | 1 | 1 |
| [Service] のセクション名の誤り([Servcie]) | Unknown section 'Servcie'. Ignoring. と Service has no ExecStart=, ExecStop=, or SuccessAction=. Refusing. ほか1行 | 1 | 1 | 1 |
| [Unit] / [Install] のセクション名の誤り([Unti] / [Instal]) | Unknown section 'Unti'. Ignoring.([Instal] も同様) | 0 | 1 | 1 |
| ExecStart= のコマンドが存在しない | Command /opt/demo/bin/agent is not executable: No such file or directory | 1 | 1 | 1 |
| Requires= の先が存在しない | オプションなし: Unit missing-dep.service not found./=no: なし | 1 | 0 | 1 |
旧名と新名で結果が逆になる
表の3行目と4行目は紛らわしいので補足します。StartLimitIntervalSec= は [Unit] セクションのキー(systemd.unit(5))なので、[Service] に置くと未知のキーとして無視されます。一方、旧名の StartLimitInterval= は互換のため [Service] でも受け付けられ、systemd v257 のソース(src/core/load-fragment-gperf.gperf.in)でも Service.StartLimitInterval は [Unit] 側と同じ設定値に割り当てられています。つまり旧名は「警告なしで通る」のが正しい挙動で、verify では誤りとして検出できません。新しく書くunitは、新名を [Unit] に書くのに統一しておくと迷いません。
修正後の good.service
キーの綴りと値を直し、StartLimitIntervalSec= / StartLimitBurst= を [Unit] に置いたものです。
[Unit]
Description=demo agent
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
ExecStart=/bin/true
Restart=on-failure
RestartSec=10s
[Install]
WantedBy=default.target
$ systemd-analyze verify --man=no --recursive-errors=no units/good.service; echo exit=$?
exit=0
$ systemd-analyze verify --man=no --recursive-errors=one units/good.service; echo exit=$?
exit=0
何も出力されず、exit 0 です。
対策2:出力が空でなければ失敗にするラッパー(systemd 250 未満、または警告を厳しく扱いたい場合)
--recursive-errors= は systemd 250 で追加されたオプションです。GitHub のホストランナーのイメージ情報(actions/runner-images、2026-10-12 版)では、ubuntu-24.04 の systemd は 255.4、ubuntu-22.04 は 249.11 と記載されています。ubuntu-latest は現時点で ubuntu-24.04 を指していますが、向き先は新しいイメージの提供にあわせて順次変わるので、バージョンに依存するならラベルを固定しておくと安心です。249 の環境ではこのオプションが使えないので、終了コードに加えて「出力が1行でもあれば失敗」と判定します。警告の文言はバージョンで変わり得るので、文言ではなく出力が空かどうかで見ています。
#!/usr/bin/env bash
# systemd-analyze verify を unit ごとに実行し、終了コードが 0 以外か、出力が1行でもあれば失敗にする
set -uo pipefail
if [ "$#" -eq 0 ]; then
echo "usage: $0 UNIT_FILE..." >&2
exit 2
fi
rc=0
for f in "$@"; do
out="$(systemd-analyze verify --man=no "$f" 2>&1)"; st=$?
if [ "$st" -ne 0 ] || [ -n "$out" ]; then
echo "NG: $f (exit=$st)"; printf '%s\n' "$out" | sed 's/^/ /'; rc=1
else
echo "OK: $f"
fi
done
exit "$rc"
$ ./bin/verify-units.sh units/good.service units/typo.service units/requires.service; echo exit=$?
OK: units/good.service
NG: units/typo.service (exit=0)
/tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring.
NG: units/requires.service (exit=1)
requires.service: Failed to create requires.service/start: Unit missing-dep.service not found.
exit=1
typo.service は verify 自体は exit 0 ですが、出力があるので NG になります。オプションを付けていないので、Requires= の欠落も拾えます。
引数が0件のまま成功してしまうと、CIでファイルの指定を間違えたときに気づけません。そのため0件は使い方を表示して exit 2 にしています。
$ ./bin/verify-units.sh; echo exit=$?
usage: ./bin/verify-units.sh UNIT_FILE...
exit=2
deploy/*.service のようにglobで渡して1件も一致しなかった場合は、bash の既定ではパターンがそのまま渡り、verify 側が失敗するのでこちらも NG になります。
$ ./bin/verify-units.sh deploy/*.service; echo exit=$?
NG: deploy/*.service (exit=1)
Failed to prepare filename deploy/*.service: Invalid argument
exit=1
まとめ
- systemd-analyze verify は、オプションなしでは警告があっても exit 0。マニュアルに明記された仕様です。
- systemd 250 以降は --recursive-errors=no で、未知のキー・不正な値を exit 1 にできます。
- =no では Requires= の先の欠落が出力ごと消えます(依存先を読まない実装)。依存先も見るなら =one。
- セクション名の誤りは、[Service] なら exit 1、[Unit] や [Install] なら警告扱いで exit 0。
- 旧名の StartLimitInterval= は [Service] でも有効なキーなので、verify では検出されません。
- systemd 250 未満では、出力が空でなければ失敗にするラッパーで代用します。
参考
- systemd-analyze(1): freedesktop.org
- systemd.unit(5): freedesktop.org
- systemd v257 src/core/load-fragment-gperf.gperf.in: github.com/systemd/systemd/blob/v257/src/core/loa…
- systemd v257 src/analyze/analyze-verify-util.c: github.com/systemd/systemd/blob/v257/src/analyze/…
- actions/runner-images Ubuntu 24.04: github.com/actions/runner-images/blob/main/images…
- actions/runner-images Ubuntu 22.04: github.com/actions/runner-images/blob/main/images…
Mentions
CI for an ASP.NET MVC 5 app on .NET Framework 4.8 with GitHub Actions An ASP.NET MVC 5 application on .NET Framework 4.8 needs a repeatable build long before it needs automated production deployment. A good first workflow restores packages, compiles the solution, runs the existing tests and produces a web deployment package someone can inspect. This is a setup guide built from official documentation that I re-read on November 2, 2026, and the workflow below is not just a sketch: I ran it unchanged against a minimal public sample, legacy-mvc48-ci-sample, an MVC 5 app on .NET Framework 4.8 with two MSTest tests. That run on windows-2022 restored, built, passed 2 of 2 tests, produced the package and uploaded both artifacts. A real application will have more projects and dependencies, so the paths and names are still yours to adapt. The Windows runner GitHub's .NET build and test guide is written around the dotnet CLI, which fits SDK-style projects. A classic MVC 5 web project with packages.config is usually built with Visual Studio's MSBuild instead, so the runner matters. The Windows Server 2022 runner inventory, image version 20261026.347.1 when I checked, lists Visual Studio Enterprise 2022, the MSBuild component, vswhere and .NET Framework 4.7.2, 4.8 and 4.8.1. That inventory changes over time, so record the image version shown in each workflow run. Write down the repository contract first Before touching YAML, list the solution path, the web project, the test projects, the build configuration and what the package should be called. Also list everything that only exists on a developer's machine: private NuGet feeds, DLLs referenced from a local folder, generated files, custom build tasks. Each one either gets an authorized way into CI or becomes an explicit blocker. That list is usually where legacy builds fail, not in the YAML. Restore packages.config dependencies Microsoft's nuget restore reference accepts a solution file, so nuget restore LegacyMvc.sln restores every packages.config in it. Keep it as its own step so a restore failure is not confused with a compile error. NuGet/setup-nuget puts nuget.exe on the runner; its latest release on November 2 was v4.0. nuget-version: latest is convenient for a first run, and the README also accepts version ranges such as 5.x, so pin one once the build works. If you use private feeds, supply credentials through secrets and the feed's documented method, never by committing a developer's NuGet.Config. Build, test, package microsoft/setup-msbuild finds MSBuild with vswhere and adds it to PATH. Its README is explicit that it "does NOT add other Visual Studio tools (like VSTest…)" to PATH, so the test step below locates vstest.console.exe itself. The MSBuild switches used here (/m, /t, /p) are in the MSBuild command-line reference. For packaging, Microsoft's guide to building and packaging web application projects shows DeployOnBuild=true with DeployTarget=Package: after the build, the Web Publishing Pipeline runs its Package target and produces [project name].zip, plus a .deploy.cmd and a SetParameters.xml next to it. The template searches for that zip instead of assuming a folder. Tested on the sample repository above, on November 2, 2026. Save an adapted copy as .github/workflows/legacy-mvc.yml. The action versions are the current major tags from each project's README or releases page; for stricter supply-chain control, pin reviewed commit SHAs. name: Legacy MVC CI on: pull_request: push: branches: [main] workflow_dispatch: permissions: contents: read jobs: build: runs-on: windows-2022 defaults: run: shell: pwsh steps: - uses: actions/checkout@v7 - uses: microsoft/setup-msbuild@v3 - uses: NuGet/setup-nuget@v4 with: nuget-version: latest - name: Restore packages.config dependencies run: | nuget restore LegacyMvc.sln -NonInteractive if ($LASTEXITCODE -ne 0) { throw 'Restore failed' } - name: Build solution run: | msbuild LegacyMvc.sln /m /t:Build /p:Configuration=Release '/p:Platform=Any CPU' if ($LASTEXITCODE -ne 0) { throw 'Build failed' } - name: Run existing tests run: | $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' $vs = & $vswhere -latest -products '*' -property installationPath $vstest = Join-Path $vs 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' $tests = 'tests\LegacyMvc.Tests\bin\Release\LegacyMvc.Tests.dll' if (!(Test-Path $vstest)) { throw 'vstest.console.exe not found' } if (!(Test-Path $tests)) { throw 'Test assembly not found' } & $vstest $tests '/Logger:trx;LogFileName=tests.trx' '/ResultsDirectory:TestResults' if ($LASTEXITCODE -ne 0) { throw 'Tests failed' } [xml]$trx = Get-Content 'TestResults\tests.trx' -Raw if ([int]$trx.TestRun.ResultSummary.Counters.total -lt 1) { throw 'No tests were discovered' } - name: Create web deployment package run: | msbuild src\LegacyMvc\LegacyMvc.csproj /p:Configuration=Release /p:DeployOnBuild=true /p:DeployTarget=Package if ($LASTEXITCODE -ne 0) { throw 'Packaging failed' } $zip = Get-ChildItem src\LegacyMvc -Recurse -Filter 'LegacyMvc.zip' | Select-Object -First 1 if (!$zip) { throw 'LegacyMvc.zip not found' } New-Item -ItemType Directory -Force artifacts | Out-Null Get-ChildItem $zip.DirectoryName | Copy-Item -Destination artifacts - name: Upload package uses: actions/upload-artifact@v7 with: name: legacy-mvc-package path: artifacts/ if-no-files-found: error - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: legacy-mvc-test-results path: TestResults/ if-no-files-found: warn Make "tests ran" an actual check The VSTest.Console options cover the test assembly arguments, /Logger and /TestAdapterPath. MSTest, NUnit and xUnit each need their adapter available to the runner; if discovery fails on CI but works locally, /TestAdapterPath is the first thing to look at. In the sample, MSTest 4.5.2 installed through packages.config was discovered without it. The template reads the TRX file and fails when zero tests were reported, because a test step that exits cleanly after discovering nothing looks green and proves nothing. If the repository has no tests, say so in the handover instead of advertising coverage. And remember what unit tests don't cover: IIS configuration, the real database and browser behavior need their own checks. Inspect the package before trusting it Download the artifact and open it. The same Microsoft guide explains that SetParameters.xml is generated from the project and can include connection strings found in web.config, so this is exactly where a production connection string can leak into an artifact. A zip that exists is a good sign, not proof that it deploys. If the project already has reviewed publish settings, you can keep them in a profile under Properties/PublishProfiles/ and select it with PublishProfile, as Microsoft's command-line deployment tutorial does. That tutorial lists three command-line publish methods, MSDeploy, Package and FileSystem; for CI you want the profile to use Package, not publish straight to a server. It was written for Visual Studio 2012 and notes that building a single web project then also needed /p:VisualStudioVersion, so if MSBuild cannot find the web application targets, that property is worth checking. Keep production behind a separate gate No passwords, tokens or production connection strings in the YAML or in committed publish profiles. When you add a deployment job later, point it at a protected environment with required reviewers, which GitHub documents in its deployments and environments reference. Naming an environment in YAML does not create the approval rule by itself. Check your plan, too: the same page says that on GitHub Free, Pro and Team, required reviewers "are only available for public repositories", and most legacy business apps live in private ones. The first milestone is modest and useful: a package you inspected and test results you can read. Production deployment comes after staging works, access is approved and there is a rollback plan. If you have an older ASP.NET MVC or .NET Framework app that needs a build like this, or a focused fix, I offer a fixed-scope service on Upwork: upwork.com
初めてのCI/CD。pushしただけで公開まで流れる理由を、図で理解した(GitHub Actions + Cloudflare Workers) 1. はじめに CI/CDという言葉は知っていましたが、なかなか勉強するまでには至りませんでした。それが仕事で使うことになり、とうとう勉強を始めることにしました。 とはいえ、どこから手をつければいいのか分かりません。調べると記事はたくさんあり、手順も丁寧に書かれています。ただ、どれも「こう書けば動く」が中心で、なぜpushしただけで最後まで流れるのかは、読んでもいまいち分かりませんでした。 幸い、今はAIという優秀なパートナーがいます。そこで今回は、AI(Claude Code)に助けてもらいながら、自分の環境で1つずつ検証していきました。 まず作ったのは、GitHubにpushすると、GitHub ActionsがCloudflare Workersへ自動で公開してくれるしくみです。画面で操作したのは3つ、書いたファイルも3つだけで、pushすると公開まで勝手に進んでいきます。実際に動かしてみても、やはり疑問は「何で流れるの?」、これだけでした。 この記事では、手順に加えて、なぜ最後まで自動で流れるのかを図で説明します。同じところでもやもやしている人の参考になればうれしいです。 なお、記事の内容はすべて自分の環境で動かして確かめていますが、CI/CDは初心者です。間違いなどがあれば、コメントで教えていただけるとうれしいです。 2. 作ったもの GitHubのmainブランチにpushすると、約30秒で https://test.tobaru.net のページが更新されるしくみです。ページはHTMLファイル1つだけの、いちばん小さなものにしました。 ひとことで言うと、GitHubに置いたファイルを、GitHubのロボット(Actions)がCloudflareまで運んでくれます。運ぶときにCloudflareのドアを開けるので、ロボットに合鍵(APIトークン)を預けてあります。 !04_overview.png 自分で用意したサーバーはありません。DNSのレコードとhttpsの証明書も、手では作っていません。 3. 環境 | 項目 | 内容 | |---|---| | GitHub | Freeプラン、Privateリポジトリ | | Cloudflare | Freeプラン | | ドメイン | Cloudflare Registrarで取得したtobaru.net(DNSもCloudflare) | | デプロイの道具 | cloudflare/wrangler-action@v4、wrangler 4 | | 作業したPC | Windows 11、VS Code、Git | 自分のPCにNode.jsやwranglerは入れていません。wranglerはGitHub Actionsの中でだけ動きます。 ドメインのDNSがCloudflareにあることが、後で出てくる「1行でDNSと証明書ができる」の条件になります(5章の(5))。 4. 手順 画面で操作したのは(1)〜(3)の3つだけです。(4)はファイルを書くだけ、(5)はpushするだけです。 (1) GitHubにリポジトリを作る - 名前はtobaru-net-test、Privateにしました - README・.gitignore・ライセンスは付けず、空のリポジトリにします(ローカルで作ったファイルをそのままpushするため) (2) CloudflareでAPIトークンを作る 1. 右上の人のアイコン → プロフィール → APIトークン → トークンを作成する 2. テンプレート「Cloudflare Workers を編集する」を使う 3. 範囲を絞る - アカウントリソース: 自分のアカウントだけ - ゾーンリソース: 特定のゾーン → tobaru.netだけ 4. 作成する。トークンは一度しか表示されないので、そのまま(3)に貼る 範囲を絞っておくと、万一トークンが漏れても、できることは「Workersの更新」と「tobaru.netの設定」だけです。 (3) GitHubのSecretsに登録する リポジトリの Settings → Secrets and variables → Actions → New repository secret で、2つ登録します。 | Name | 値 | |---|---| | CLOUDFLARE_API_TOKEN | (2)で作ったトークン | | CLOUDFLARE_ACCOUNT_ID | CloudflareのアカウントID | (4) ファイルを3つ書く tobaru-net-test/ ├─ public/ │ └─ index.html … 見せるページそのもの ├─ wrangler.jsonc … Cloudflareへの注文書(何を・どこで出すか) └─ .github/ └─ workflows/ └─ deploy.yml … Actionsへの指示書(いつ・何をするか) public/index.htmlは、ふつうのHTMLです(中身は省略)。 wrangler.jsoncです。ふつうのJSONはコメントが書けませんが、jsonc(JSON with Comments)は//で書けます。 { // Cloudflare側でのWorkerの名前(名札)。 // この名前のWorkerがなければ新しく作り、あれば中身を置き換える "name": "tobaru-net-test", // Workersの動き方の基準日 "compatibility_date": "2026-10-01", // 静的なファイル(HTML・画像など)の置き場所。この中身がそのままサイトに並ぶ "assets": { "directory": "./public" }, // どのURLで出すか。custom_domain: true でDNSレコードと証明書も作ってくれる "routes": [ { "pattern": "test.tobaru.net", "custom_domain": true } ], // xxx.workers.dev というCloudflareのURLでは公開しない "workers_dev": false } .github/workflows/deploy.ymlです。 name: Deploy # いつ動くか(イベントの条件) on: push: branches: [main] # mainブランチにpushされたとき workflow_dispatch: # Actionsタブの「Run workflow」ボタンで手動でも動かせる jobs: deploy: runs-on: ubuntu-latest # GitHubが数十秒だけ貸してくれるLinux permissions: contents: read steps: # 1. このcommitのファイル一式をランナーに取り出す - uses: actions/checkout@v5 # 2. wranglerをインストールして動かす(commandを書かなければ wrangler deploy) - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} # 金庫の合鍵 accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # どのアカウントに送るか wranglerVersion: "4" (5) pushする git init -b main git add -A git commit -m "最初のページとデプロイ設定" git remote add origin https://github.com/<ユーザー名>/tobaru-net-test.git git push -u origin main pushしてから約30秒で、https://test.tobaru.netが開けるようになりました。10秒おきに確かめると、最初の3回はつながらず、4回目で200が返ってきました。 5. なぜ最後まで自動で流れるのか 答えを先に書くと、何かがずっと監視しているわけではありません。「イベントが起きたら、こう動く」という小さな決まりが、前の結果を受け取ってつながっているだけです。 pushしてから公開されるまでを、5つの段階に分けました。 !05_sequence.png 画像をクリックすると、大きく表示されます。 ここからは、私が分からなかったところを順に説明します。 (1) GitHubはフォルダを監視しているの? 監視はしていません。pushやプルリクエストなどのイベントが起きたときにだけ、GitHubがそのcommitの.github/workflows/を開きます。中のYAMLを全部読んで、on:の条件に合うものだけを動かします。 それが分かる例です。 | こんなとき | デプロイは動く? | |---|---| | README.mdだけ変えてpush | 動く。deploy.ymlは変わっていないが、「mainへのpush」というイベントが起きたから | | deploy.ymlを書き換えたが、pushしていない | 動かない。GitHubは自分のPCの中を見ていない | | deploy.ymlを書き換えてpush | 書き換えた後の内容で動く。そのcommitのファイルを読むから | | 別のブランチにpush | 動かない。on:がbranches: [main]だから | | プルリクエストをmainにマージ | 動く。マージするとmainにcommitが入り、mainへのpushと同じになるから | 「受付の人がマニュアルをずっと見張っている」のではありません。「呼び鈴が鳴ったらマニュアル棚を開いて、今回の用件に当てはまるページを探す」イメージです。マニュアル棚が.github/workflows/、ページの見出しがon:です。 (2) ファイルを指定していないのに、なぜwrangler.jsoncが使われるの? deploy.ymlのデプロイの手順には、「どのファイルを送るか」を書いていません。それでも動くのは、書かなかったときの決まり(既定値)が2段階あるからです。 1. wrangler-actionの決まり: commandを書かなければdeployを実行する(action.ymlに「Defaults to "deploy"」とあります)。workingDirectoryも書かなければ、リポジトリのいちばん上で実行する 2. wranglerの決まり: wrangler deployは、実行したフォルダでwrangler.jsonc / wrangler.json / wrangler.tomlを探して読む しかも、wrangler.jsoncそのものは送られません。「何を・どこに・どんな名前で」の指示として読まれ、送られるのはassets.directoryに書いたpublic/の中身です。 決まった名前・決まった場所に置けば何も書かなくても動き、変えたいときだけ書く。この考え方を「設定より規約(Convention over Configuration)」と呼ぶそうです。(1)の.github/workflows/も同じです。 (3) Cloudflareで何も作っていないのに、なぜWorkerができるの? wrangler.jsoncの"name": "tobaru-net-test"で、「この名前のWorkerを、この中身にして」と頼んでいるからです。Cloudflareは、その名前のWorkerがなければ新しく作り、あれば中身を置き換えます。上の図のaltのところです。 どのアカウントに作るかはaccountId、入っていいかはapiTokenで伝えています。どちらもSecretsから渡しているので、deploy.ymlには値そのものを書いていません。 (4) Secretsは何のため? Secretsは合鍵を入れておく金庫です。deploy.ymlには${{ secrets.CLOUDFLARE_API_TOKEN }}と、「金庫のこの引き出しを使って」とだけ書きます。合鍵そのものはどのファイルにも書かないので、コードを人に見せても漏れません。 (5) 1行でDNSと証明書までできるの? できました。routesに"custom_domain": trueと書くと、「このホスト名をまるごとこのWorkerにする」意味になり、CloudflareがDNSのレコードとhttpsの証明書を作ってくれます。 ただし、次の2つがそろっているからできることです。 - tobaru.netのDNSが、同じCloudflareアカウントにある(Cloudflareが自分でレコードを書き込める) - APIトークンにtobaru.netを触る権限がある(4章の(2)でゾーンを絞って許可した) DNSが別の会社にあるドメインでは、この1行だけでは済みません。 custom_domainを付けずに"pattern": "test.tobaru.net/api/*"のように書くと、「このURLに来たアクセスだけWorkerが受け取る」という別の使い方になります。この場合、DNSのレコードは自分で作ります。 6. 警告が出たところ 最初は、他の記事と同じくcloudflare/wrangler-action@v3を使っていました。デプロイは成功しましたが、Actionsの結果のAnnotationsに、警告1件とお知らせ1件が出ました。 !01_annotations.png (1) Node.js 20 is deprecated(警告) > Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: cloudflare/wrangler-action@v3. wrangler-action@v3はNode.js 20向けに作られていて、GitHub側がNode.js 20を廃止していく途中なので、Node.js 24で動かしているという知らせです(GitHubのお知らせ)。今は動いていますが、将来動かなくなるおそれがあります。 調べるとv4が出ていて、action.ymlにusing: "node24"とありました。入力の項目(apiToken・accountIdなど)は同じなので、@v3を@v4に変えるだけで警告が消えました。 この修正もdeploy.ymlを1文字直してpushしただけです。しくみそのものの設定を変えた場合も、ページを変えた場合と同じ流れで反映されます(5章の(1)の表の3行目)。 (2) ubuntu-latestがUbuntu 26に移る(お知らせ) > The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026. runs-on: ubuntu-latestの中身が、順に新しいUbuntuに切り替わるという案内です(runner-imagesのIssue)。今回のワークフローはwranglerを動かすだけなので、そのままにしています。 (3) nameを変えるときの注意 つまずいたわけではありませんが、しくみから考えると注意が必要だと分かったところです。 nameを変えてpushすると、Cloudflareは名前が変わったとは受け取りません。「その名前のWorkerがないので新しく作る」と受け取ります(5章の(3))。古いWorkerは残ったままになるので、変えるときは古いほうの片付けも必要です。 7. Cloudflareの画面で確かめる Cloudflareの画面では何も作っていませんが、pushしたあとに開くと、次の2つができていました。 (1) Workersの一覧 「Workers & Pages」を開くと、wrangler.jsoncのnameで付けたtobaru-net-testがあり、routesで頼んだtest.tobaru.netがつながっていました。 !02_workers.png 「デプロイメントを表示」を開くと、pushした回数ぶんの履歴が並んでいます。GitHubのcommitと1回ずつ対応しています。 (2) DNSのレコード tobaru.netのDNSのページには、自動で作られたtestのレコードがありました。自分では1つも作っていないのに、本当にレコードが出ていてびっくりしました。 !03_dns.png | 見えているもの | 意味 | |---|---| | 左端の鍵のアイコン | 自動で作られたレコードなので、ここからは編集できない | | タイプ「Worker」 | AやCNAMEではなく、「このWorkerに届けて」という特別なレコード | | コンテンツ「tobaru-net-test」 | wrangler.jsoncのname。名札でつながっている | | 「プロキシ済み」 | アクセスはいったんCloudflareが受け取る | 外のDNS(1.1.1.1)にtest.tobaru.netを聞いてみると、返ってくるのはCloudflareのIPアドレス(104.21.x.xなど)でした。「プロキシ済み」なので、サーバーの場所ではなくCloudflareのアドレスが返ります。応答のヘッダーにはServer: cloudflareとCF-RAY: …-NRTがあり、東京の拠点から返っていることも分かりました。 8. まとめ 最初は、なぜ自動で最後まで流れるのかが、いまいち理解できませんでした。でも、監視という大掛かりな仕組みではありませんでした。イベントが起きたときの動きが決まっていて、その決まりどおりに動くと、流れるように最後までつながる。それが分かったとき、美しいなーと思いました。DNSの画面に、自分では作っていないレコードが出ていたときも感動しました。 今回分かったことを並べると、次の3つです。 - GitHubは監視していない。イベントが起きたときに.github/workflows/を読み、on:に合うものだけを動かす - 書いていないことは既定値で動く。wrangler-actionはdeployを実行し、wranglerは決まった名前のwrangler.jsoncを探す - CloudflareはnameでWorkerを見分け、なければ作り、あれば置き換える。custom_domain: trueならDNSと証明書も作る 次は、プルリクエストを作ったらチェックだけを動かし、mainにマージしたらデプロイする形(CIとCDの分離)を試す予定です。チェックも、pull_requestというイベントに決まりを足すだけで作れるはずです。
CIを最大4.6倍高速化する方法 ! この記事は2023年11月1日に執筆されたものです。 こんにちは、InflabのDevOpsエンジニアのJakeです。 皆さんは、CIパイプラインの実行に時間がかかりすぎて、PRレビューに影響が出たことはありませんか? プロジェクトの規模が大きくなるにつれて長くなっていくCI/CDパイプラインの所要時間に、頭を悩ませたことはありませんか? 本記事では、CIを最大4.6倍まで高速化できたノウハウを共有します。 依存関係とビルドのキャッシュがヒットした場合の数値 最適化の結果 この記事のポイント CI(継続的インテグレーション)の所要時間を最大79%削減 Jenkins Pipelineの最適化: ビルド、テストを実行するDockerコンテナの依存関係キャッシュのパスを設定 モノレポのCIにインクリメンタルビルドを適用: turborepo remote cache シンプルながら非常に効果的で、簡単に適用できるCI/CDパイプライン最適化のノウハウを共有します。 最適化の背景 Inflabの開発チームではモノレポを導入し、パッケージ単位でコードを管理しています。プロジェクトの規模が大きくなるにつれてコードベースとパッケージが増え、CIの所要時間が急増しました。 プロジェクト初期: パッケージ1個、4分14秒 プロジェクト後期: パッケージ9個、12分4秒 プロジェクトの成長とともにコードで使うライブラリがどんどん増え、依存関係(dependency)のインストール時間が長くなりました。 また、ビルドとテストを順番に実行していたため、所要時間はどんどん積み上がっていきました。ビルドパイプライン側で直接並列実行させるなどの手も試しましたが、依存するパッケージ間の複雑な関係や並行性の問題があり、別の解決策を探す必要がありました。 そこで pnpm と turborepo を導入し、モノレポプロジェクトの生産性を改善することになりました。 Yarnとpnpmの比較 次のグラフは、npm、pnpm、yarn、yarn PnPのベンチマークです。 画像出典: pnpm.io グラフのとおり、依存関係のインストールでは条件によって約2倍速くなっています。私たちがまとめたそれぞれの特徴は次のとおりです。 yarn 依存関係のインストール時間: 最大120秒 複雑な node_modules の構造により、ディスクI/OとCPUリソースを消費 pnpm 依存関係のインストール時間: 最大63秒 symlinkでグローバルキャッシュのパス(.pnpm-store)にリンク -> 不要なファイルコピーが減少 -> ディスクI/Oが減少 node_modules └── .pnpm ├── bar@1.0.0 │ └── node_modules │ └── bar -> <store>/bar │ ├── index.js │ └── package.json └── foo@1.0.0 └── node_modules └── foo -> <store>/foo ├── index.js └── package.json これまで使っていた yarn には、依存関係をインストールすると複雑な node_modules ディレクトリ構造のせいでディスクI/OとCPUリソースが無駄に消費されるという欠点がありました。そのため、より高スペックなビルドサーバーが必要になり、所要時間も長くなっていました。 pnpm の導入後は、すべての依存パッケージを .pnpm-store にインストールし、各パッケージ内の node_modules ディレクトリにはsymlinkを張るだけの形になりました。これによりディスクへの書き込みが減り、依存関係のインストール時間を約60秒短縮できました。 Turborepo Turborepoを説明する前に、モノレポについて少しおさらいしておきます。 Monorepoとは? 画像出典: monorepo.tools モノレポ(Monorepo)とは? A monorepo is a single repository containing multiple distinct projects, with well-defined relationships. モノレポとは、関係が明確に定義された複数の個別プロジェクトを含むリポジトリを指す。 モノレポを導入すると、毎回多数のリポジトリを作成・管理する手間を減らせます。詳しくはリンク先をご参照ください。 関係が明確に定義されておらず、カプセル化もされていない単なるコードの寄せ集めはモノレポではなく、Monolithとも異なると説明されています。 このようにモノレポでは、複数のアプリやライブラリを1つのリポジトリで管理するため、同じ作業を多数のリポジトリに繰り返し適用する必要がありません。 しかし、モノレポのコードベースの規模が大きくなるとどうなるでしょうか? ビルドとテストを順次実行することによる所要時間の増加 変更されていない部分まで、不要なビルド & テストを繰り返し実行 次の例は、モノレポのCIパイプラインで、変更されたパッケージのビルドだけを並列実行するよう構成したものです。 stage('Docker Build') { parallel { stage('Package A') { when { anyOf { changeset "cmd/package-alpha//*" changeset "docker/package-alpha//*" } } steps { sh 'docker build -t package-alpha -f docker/package-alpha/Dockerfile .' } } stage('Package B') { when { anyOf { changeset "cmd/package-beta//*" changeset "docker/package-beta//*" } } steps { sh "docker build -t package-beta -f docker/package-beta/Dockerfile ." } } ...(中略)... プロダクトや組織の規模に応じて適切にリポジトリを分ける方法もありますが、結局のところ、モノレポを専用のツールなしで上記の例のようにCIパイプラインで直接管理しようとすると、かなりの工数がかかるでしょう。 パッケージが追加されるたびに手作業でCIパイプラインを修正するのは、本当に大変です。(とにかく手間がかかります) パッケージ同士が複雑な依存関係で絡み合っている場合、それをすべてCIパイプラインで実装するのは困難です。 こんなときに必要になるのが、turborepo や nx のようなモノレポツールです。 さまざまなモノレポツールがありますが、本記事では私たちが導入した turborepo を中心に説明します。 主な機能 turborepo は、JavaScriptおよびTypeScriptのコード向けの高性能なビルドシステムです。公式ページで説明されている特徴は次のとおりです。 Incremental Builds Content-aware hashing Parallel execution Remote Caching Zero runtime overhead Pruned subsets Task pipelines Meets you where you’re at Profile in your browser ここで強調したいのは、Incremental Builds、Content-aware hashing、Parallel execution、Remote Cachingです。順番に見ていきましょう。 Incremental Builds 日本語に直訳すると「増分ビルド」です。 追加・変更された部分だけをテスト & ビルドすることで、大規模なモノレポのCI所要時間を大幅に短縮します。 Detecting affected projects/packages Content-aware hashing コードの内容からハッシュ値を計算し、変更を検知します。 Gitに依存するchangesetとは対照的です。 Parallel execution 従来はビルドを順番に実行していたため、CPUリソースに無駄がありました。 ビルドを並列で実行することで、CPUコアを効率的に使い、ビルドをより早く終わらせることができます。 Local Task Orchestration Remote Caching Local Computation Caching taskの処理結果をローカルに保存して再利用する機能です。同じマシンでは、同じ状態のコードに対するビルドやテストを2回以上行いません。 Distributed Computation Caching Local Computation Cachingを拡張した概念で、taskの処理結果を1台のマシンではなく複数の環境にまたがって共有します。 CIエージェントのいずれかがすでにそのコードの状態をビルド・テストしていれば、ほかのエージェントがそれを再実行することは決してありません。 Remote Cachingは、Distributed Computation Cachingと似た概念です。 同じコードベースを修正する作業者が複数人いると仮定しましょう。 作業者Aが先にビルドとテストを実行していた場合、その結果は共用のキャッシュサーバーに保存されます。 続いて作業者Bがビルドとテストを実行するときは、共用キャッシュサーバーにキャッシュされた作業者Aの処理結果を読み込み、不要なビルドの実行をスキップします。 共用キャッシュサーバーにはビルドの成果物ファイルもあわせて保存されるため、それをすぐにデプロイに活用できます。同じ作業が繰り返し実行されるCIパイプラインを効果的に改善できます。詳しい数値は後ほど共有します。 上記の4つの機能をうまく活用すれば、大規模なプロジェクトもぐっと楽に管理できます。 ところで、なぜ本記事のタイトルはTurborepoの導入ではなく、CIの改善なのでしょうか? いくつか見落としていた点があり、これらのメリットを活かしきれていなかったからです。ここから説明していきます。 CI所要時間の最適化 Inflabでは、Jenkinsを使ってCI/CDパイプラインを構築しています。 CIジョブがトリガーされると、JenkinsのEC2エージェント上でDockerイメージを起動し、次の順序でジョブを実行します。 依存関係のインストール(Install Dependencies) テスト(Test) ビルド(Build) 静的解析(Static Analysis) 依存関係のインストール 依存関係のインストール処理をまとめると、次のとおりです。 pnpm i --frozen-lockfile 必要な依存ライブラリのバージョンを確認 --frozen-lockfile: 必要な依存関係のマニフェストファイル(pnpm-lock.yaml)が存在するかを確認し、記載されたバージョンに従って依存関係をインストール npmjs.com、GitHub Registry のようなパッケージリポジトリから依存関係をダウンロードしてインストール post-installスクリプトを実行 先ほど、pnpm を導入しただけで依存関係のインストール時間を約60秒短縮できたとお伝えしました。 実行環境が毎回変わるCI環境では、依存関係のキャッシュを導入すれば、さらに多くの時間を節約できます。 環境ごとの依存関係インストールの結果 ローカル環境では、pnpm i を実行するとグローバルな依存関係キャッシュのディレクトリからキャッシュを読み込み、不要なインストールをスキップします。一方、CI環境では実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定する必要があります。 ローカル環境: インストールしたすべての依存関係はグローバルキャッシュのディレクトリ(~/Library/pnpm/store/*)に蓄積 一度インストールした依存関係をほかのプロジェクトでも参照し、不要なインストールを省略(キャッシュヒット時は3秒前後) CI環境: 実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定しなければ毎回インストールが走る(約60秒) CI環境での依存関係のキャッシュ CI環境で依存関係をキャッシュする方法には、主に次のようなものがあります。 圧縮: node_modules のパスをtar & gzipで圧縮 CI用のキャッシュイメージを作る: 依存関係をあらかじめインストールしたDockerイメージを作成し、それをビルドに活用する方法 キャッシュボリュームのマウント: build agentに残っているglobal cacheを活用する方法 圧縮 GitHub Actions actions/cache@v3 の例 ... - id: cache uses: actions/cache@v3 with: path: '/node_modules' key: ${{ runner.os }}-node-${{ hashFiles(format('/{0}', steps.hash-file.outputs.hash-file)) }} ... Jenkins Job Cacherプラグインの活用例 cache(maxCacheSize: 250, defaultBranch: 'develop', caches: [ arbitraryFileCache(path: 'node_modules', cacheValidityDecidingFile: 'package-lock.json') ]) { // ... } 依存関係が入った node_modules ディレクトリをtarballにまとめ、gzipやxzなどで圧縮する方法です。 キャッシュは pnpm-lock.yaml のようなマニフェストファイルのハッシュで区別されるため、必要な依存関係が変わるたびにインストールし直す必要があるのが欠点です。 また、node_modules ディレクトリの圧縮は基本的にシングルスレッドで行われるため、時間がかかります。 プロジェクトの規模が大きくなると node_modules ディレクトリも大きく複雑になるため、キャッシュファイルのダウンロードと展開だけで2分以上かかることもあります。 これでは本末転倒なので、いっそ圧縮しないほうが良い場合もあります。 プロジェクトの規模が大きい場合 pnpm install の所要時間: 60秒 キャッシュファイルのダウンロードと展開の所要時間: 120秒以上 場合によっては、実際に所要時間を計測・比較したうえで判断する必要あり GitHub Actionsを使う場合は、pnpmの公式ドキュメントのガイドに従い、cache: 'pnpm' を設定するだけで簡単に適用できます。 pnpm公式ドキュメントで推奨されているGitHub Actionsパイプラインの例 name: pnpm Example Workflow on: push: jobs: build: runs-on: ubuntu-20.04 strategy: matrix: node-version: [15] steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 with: version: 8 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - name: Install dependencies run: pnpm install Jenkinsパイプラインを使う場合は、次のように設定することが推奨されています。 pipeline { agent { docker { image 'node:lts-bullseye-slim' args '-p 3000:3000' } } stages { stage('Build') { steps { sh 'corepack enable' sh 'corepack prepare pnpm@latest-8 --activate' sh 'pnpm install' } } } } 上記の例ではpnpmのstore pathを別途指定していないため、JENKINS_WORKSPACE の配下に .pnpm-store のパスを作成して依存関係をキャッシュします。 しかし、Jenkins Multibranch Pipelineの場合は、ブランチやPull Requestごとに別々のWorkspaceのパスを持ちます。 つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。 これを解決する方法は、キャッシュボリュームのマウントで後ほど説明します。 CI用のキャッシュイメージを作る 次に紹介するのは、CI用のキャッシュイメージを作る方法です。 次の例は、pnpm fetch を使って依存関係のキャッシュイメージを作る Dockerfile です。 FROM node:18-slim pnpmのインストール手順は省略 pnpm fetch does require only lockfile COPY pnpm-lock.yaml ./ RUN pnpm fetch --prod 上記のイメージをpullしてから依存関係をインストールすれば、キャッシュを活用してすぐにビルドできます。 イメージタグにはマニフェストファイル(pnpm-lock.yaml)のハッシュ値を指定します。そのため先ほどの方法と同じく、このファイルが変更されたらイメージを再ビルドする必要があります。 when cache miss, build and push into image registry like ECR docker build -t rallit-frontend-dep:02d93ab24 -f dep.Dockerfile . docker push rallit-frontend-dep:02d93ab24 when build docker run -i --rm -v $WORKSPACE:/workspace rallit-frontend-dep:02d93ab24 'pnpm i --frozen-lockfile' ただし、pnpm fetch --prod コマンドでインストールした依存関係をイメージとしてビルドしてpullするため、その過程でディスクのオーバーヘッドが発生します。 社内で所要時間を計測した結果、実際のCI環境ではイメージをpullして実行するのに約168秒かかることが分かりました。 キャッシュイメージを作る方法も、プロジェクトの規模が大きいほど時間がかかり、通常の pnpm install より約108秒も遅くなりました。 pnpm-lock.yaml が変更されるたびにこうしたキャッシュイメージをビルドし、コンテナイメージレジストリにアップロードするのは非効率だと判断しました。 Dockerのマルチステージビルドを使ってイメージをビルドしている方は、pnpmの公式ドキュメントを参考にcache type mountingを適用することをおすすめします。 FROM base AS prod-deps RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile FROM base AS build RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN pnpm run build DockerのRUN専用キャッシュを活用する パッケージをインストールするたびに、毎回インターネットからすべてのパッケージを取得する必要はありません。 --mount フラグとともに明示的なキャッシュを使うと、ビルド間で対象ディレクトリの内容が保持されます。 このレイヤーを再ビルドする必要がある場合は、そのパスにあるキャッシュを活用します。 私たちはDockerのマルチステージビルドを使わず、ビルドの段階ごとに個別に最適化したイメージを使っているため、この方法は適用できませんでした。 続いて、依存関係のインストール時間を大きく短縮できた、キャッシュボリュームのマウントについて説明します。 キャッシュボリュームのマウント 先ほど、Jenkinsパイプラインでpnpmを使って依存関係をインストールする場合は、キャッシュを別途設定する必要があると述べました。 つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。 実際の .pnpm-store のパスを比較しながら見ていきましょう。 従来(AS-IS) CACHE_PATH のパスが JENKINS_WORKSPACE の中にある 異なるプロジェクト、ブランチ、PR間で依存関係のキャッシュを共有できない パスの比較 JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242 CACHE_PATH=/workspace/rallit_frontend_PR_15242/.pnpm-store 例えば、rallit_frontendのPR#15242のCIジョブが完了したとしましょう。 /workspace/rallit_frontend_PR_15242/.pnpm-store には、先ほどインストールした依存関係が残っています。 続いてPR#15243が作成されると、どうなるでしょうか? PR#15243は /workspace/rallit_frontend_PR_15243/.pnpm-store を参照することになります。 このフォルダは実際には存在しないので、依存関係を最初からインストールすることになり、その分時間がかかります。 先にインストールしたPR#15242の依存関係キャッシュは、まったく使えません。 たった1行の変更でも約60秒余計に待たされるので、かなり不便です。 変更後(TO-BE) エージェント内にGlobal Dependency Cacheのパスを設定 同じエージェントで実行するビルドはDependency Cacheを活用 パスの比較 JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242 CACHE_PATH=/tmp/.pnpm-store CIジョブを実行するすべてのコンテナで、pnpm store-path オプションに同じパスを指定しました。 これにより、rallit_frontendのPR#15242、PR#15243はどちらも同じパス(/tmp/.pnpm-store)を参照するようになります。 PR#15243のCIジョブでは、PR#15242ですでにインストールされた依存関係を再利用することになり、約60秒かかっていた依存関係のインストール時間をわずか3秒に短縮できました。 pnpm store-path オプションは、ビルド用のベースイメージを作る Dockerfile で次のように指定しました。 set global node cache directory ARG CACHE_PATH=/cache/node RUN yarn config set cache-folder ${CACHE_PATH}/.yarn RUN pnpm config set store-dir ${CACHE_PATH}/.pnpm-store パッケージマネージャーにYarnを使っているプロジェクトでも依存関係のキャッシュパスを共有できるよう、Yarnにも同様の設定を入れています。 続いて、このDockerイメージを実行する Jenkinsfile 側でも CACHE_PATH 環境変数を設定します。 env.DOCKER_OPTION = '-v /tmp:/cache/node -e CI=1' (中略) stage('Install') { steps { script { sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm i --frozen-lockfile'" } } } JenkinsfileのDocker拡張機能を使わず、コマンドで直接Dockerコンテナを実行する場合 pnpmがCI環境かどうかの判定に使う環境変数の1つである BUILD_NUMBER が渡されないため、CI=1 のデフォルト値が適用されません。 -e BUILD_NUMBER=$BUILD_NUMBER で渡すか、-e CI=1 環境変数を直接設定するのがおすすめです。 詳しくは公式ドキュメントをご参照ください。 CI=trueの場合、pnpmはロックファイル(pnpm-lock.yaml)を生成せず、ロックファイルがマニフェストと同期していない場合、更新が必要な場合、または存在しない場合はインストールに失敗します。 この対応によって、直前のビルドで使われた依存関係のキャッシュを、ほかのリポジトリのCIでも再利用できるようになりました。 適用前 - pnpm i --frozen-lockfile Scope: all 7 workspace projects Lockfile is up to date, resolution step is skipped Progress: resolved 1, reused 0, downloaded 0, added 0 Packages: +3357 (中略) Progress: resolved 3357, reused 1253, downloaded 2025, added 3357, done Done in 1m 28.6s 適用後 - pnpm i --frozen-lockfile Scope: all 7 workspace projects Lockfile is up to date, resolution step is skipped Packages: -11 ----------- Done in 2.6s 依存関係インストール処理の最適化結果 依存関係キャッシュMiss: 1分28秒 依存関係キャッシュHit: 2.6秒 依存関係のインストール時間を最大97%短縮 依存関係のインストールは従来の88秒から2.6秒へと85.4秒減り、約97%短縮できました。 もちろん、ビルドエージェントが入れ替わって依存関係のキャッシュパスが初期化されると、最初の1回だけは1分28秒のインストールが必要です。 それでも、ビルドのたびに依存関係をインストールしていた従来とは違い、エージェント内で依存関係のキャッシュを共有できるので、後続のビルドではインストール時間を97%以上短縮できます。 複数のリポジトリが同じライブラリを使っていても、それぞれバージョンが異なる場合があります。 この場合、使用するバージョンを1つに統一するとキャッシュヒット率(Cache Hit Rate)が上がり、上記と同様に依存関係のインストール時間を大幅に短縮できました。 例: inflearn-frontend と rallit-frontend で使っている Datadog RUM(Real User Monitoring) エージェントのバージョンが一致している場合、npm registryからの依存関係のダウンロードとインストールをスキップし、ビルドエージェントにあるグローバルな依存関係キャッシュを活用して時間を短縮できる。 (rallit-frontend で先にインストールしていれば inflearn-frontend でそれを利用でき、その逆も可能) テストとビルドの所要時間の短縮 先に述べたとおり、モノレポでは一部のパッケージを修正しただけでも、すべてのパッケージをテスト・ビルドしなければなりません。 Turborepoを使えば、変更されたパッケージと、それに依存するパッケージだけをテスト・ビルドするので、CIの所要時間を大幅に短縮できます。これをTask Cacheと呼びます。 Inflabのフロントエンドチームは早くからTurborepoをローカル開発環境に導入していたため、実際に変更されたコードだけをテスト・ビルドして、作業内容を素早く検証できていました。 これはコンテキストスイッチの頻度を減らし、生産性の向上につながりました。(実験:遅いビルド時間の隠れたコスト in GeekNews) Turborepo Task Caching package.json にある build、test、lint などをtaskと呼びます。 Turborepoはtaskの実行結果とログをキャッシュし、遅いtaskを高速化します。 Content-aware hashingでソースコードの変更を検知し、変更されたパッケージに対してのみ build、test、lint などのtaskを実行します。 テストとビルドの結果は、まずLocal Filesystem Cacheとして ./node_modules/.cache/turbo/78awdk123.tar.zst のようなパスに保存されます。 Taskキャッシュがヒット(hit)したパッケージはビルドをスキップし、変更されたパッケージだけを新たにテスト・ビルドします。 実行結果をもとにTaskキャッシュを更新し、常に最新の状態に保ちます。 Taskキャッシュがミス(miss)した場合は、次のようにtaskを実行し、その結果でLocal Filesystem cacheを更新します。 /lib:build: cache miss, executing 86fb10d5583f361c /app:build: cache miss, executing 7804de963ab93a0e (中略) Tasks: 5 successful, 5 total Cached: 0 cached, 5 total Time: 4m36.208s ...writing to cache... Taskキャッシュがヒット(hit)した場合は、キャッシュ作成時のログを再生(replay)し、ビルド結果をキャッシュサーバーからダウンロードします。 /lib:build: cache hit, replaying logs 86fb10d5583f361c /ds:build: cache hit, replaying logs 7804de963ab93a0e /a:build: cache hit, replaying logs 8be7f0056074cbc3 /b:build: cache hit, replaying logs d01733e33b71a814 Tasks: 5 successful, 5 total Cached: 5 cached, 5 total Time: 4.114s >>> FULL TURBO Taskキャッシュの適用結果 TaskキャッシュMiss: 4分36秒 TaskキャッシュHit: 4.114秒 Taskキャッシュの導入で所要時間を約98.5%改善 Taskキャッシュも依存関係のキャッシュと同じく、キャッシュがない初回は時間がかかります。しかしそれ以降のビルドでは変更されたパッケージに対してのみTaskを実行するため、かなりの時間を節約できました。 問題は、別途設定をしなければLocal Filesystem Cacheしか使えないことです。 Jenkinsエージェントではworkspaceをブランチ・PR単位で分けているため、検証済みのPRを develop ブランチにマージすると、テストとビルドを最初からやり直す必要がありました。 CIパイプラインで一度ビルドしたものは再ビルドせずに済ませたいところです。どうすれば改善できるでしょうか? Turborepo Remote Caching Turborepo Remote Caching機能を使えば、この問題を解決できます。 Taskキャッシュを複数のビルドエージェント間で共有でき、インクリメンタルビルドを積極的に活用できるようになります。 Remote Cachingを使うには、追加費用が発生するVercelのクラウドを使うか、Vercelが公開しているAPI仕様に従ってTurborepo custom remote cache serverを自前で実装する必要があります。 私たちは、オープンソースとして公開されているducktors/turborepo-remote-cacheを使いました。 ducktors/turborepo-remote-cache Turborepo custom remote cache serverのオープンソース実装で、self-hosted環境で使うのに適しています。 シンプルなWebサーバーアプリケーションで、次の実行方法をサポートしています。 Dockerコンテナとして実行 AWS Lambdaなどのサーバーレス関数として実行 ストレージについては、次のようなさまざまなオプションが用意されています。 Amazon S3(または互換ストレージ) ファイルシステム Lambda + S3の組み合わせで完全にサーバーレスで構築することもできます。それぞれのメリット・デメリットを踏まえて、環境に合った構成を選べば良いでしょう。 ここでは、Docker + ファイルシステムでカスタムキャッシュサーバーを手軽に構築する手順を紹介します。 次のように compose.yaml ファイルを作成します。${} の中に入る変数は .env ファイルで定義します。 services: turborepo-remote-cache: image: ducktors/turborepo-remote-cache:1.14.2 ports: - 3000:3000 volumes: - ./tmp:/tmp environment: - NODE_ENV=production - PORT=3000 - TURBO_TOKEN=${TURBO_TOKEN} - LOG_LEVEL=info - STORAGE_PROVIDER=local - AWS_REGION=${AWS_REGION} 続いてガイドに従い、Turborepoのカスタムキャッシュサーバーのアドレスといくつかのパラメーターを、Dockerの環境変数として渡します。 TURBO_API: キャッシュサーバーのアドレス TURBO_TOKEN: 認証トークン TURBO_TEAM: キャッシュを使うteam(主にプロジェクト単位で指定) stage('Build') { steps { script { env.DOCKER_OPTION = "$DOCKER_OPTION -e TURBO_API=$TURBO_API -e TURBO_TOKEN=$TURBO_TOKEN -e TURBO_TEAM=$TURBO_TEAM" sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm build'" } } } Dockerの環境変数ではなくturbo.jsonなどの設定ファイルに直接書く方法もありますが、今回はCI専用のキャッシュサーバーとの連携なので、上記のようにパイプライン側に記述しました。 適用後にビルドを何度か実行すると Remote caching enabled というログが出力され、Taskキャッシュが蓄積・再利用されていることが確認できます。 - pnpm build:development • Packages in scope: ...... • Running build:development in 6 packages • Remote caching enabled a:build: cache hit, replaying logs 83da8d8fb24dd1a9 b:build: cache hit, replaying logs 249bb66c4e03301b c:build: cache hit, replaying logs 8be7f0056074cbc3 (中略) Tasks: 5 successful, 5 total Cached: 5 cached, 5 total Time: 4.114s >>> FULL TURBO ハッシュはコードの状態をもとに計算されるので、ブランチやPRが違っても、コードを修正していなければTaskが無駄に再実行されることはありません。 では、Continuous Deploymentのパイプラインでビルドキャッシュを使う場合、ビルド成果物はどうデプロイすれば良いでしょうか? turbo.jsonでコマンドごとにoutputsのパスを設定すると、そのパスがビルド成果物として扱われ、Turborepoのリモートキャッシュサーバーに保存されます。キャッシュヒット時には、それがダウンロードされます。 そのため、デプロイ時の混乱を防ぐには、outputs オプションを正しく設定しておく必要があります。(関連する公式ドキュメント) turbo.json { "$schema": "turbo.build", "pipeline": { "app#build": { "outputs": ["dist/"] }, "util#build": { "outputs": ["lib/"] } } } このオプションを正しく設定していないと、デプロイパイプラインを経由しない緊急ロールバックのあとにパイプラインを再実行しても、本番環境に反映されないことがあるので注意してください。 便利さの裏には慎重さと正確さが欠かせないことを、心に留めておきましょう。 ソースコードの静的解析 依存関係のインストールと、テスト・ビルド。この2つを最適化した結果、すべてのキャッシュがヒットすれば、ここまでの処理は10秒前後で終わります。 静的解析では、コードのセキュリティ脆弱性から、重複やパフォーマンス上の問題が起こりうる箇所まで見つけられるため、SonarQubeを積極的に導入・活用しています。 しかし、CIのたびに静的解析を実行していたため、コードの規模によっては40秒から最大2分以上かかることもありました。 これを短縮するため、解析が不要なファイルを sonar-project.properties で除外しました。 sonar.exclusions=packages//public/mockServiceWorker.js, packages//src/@types//, packages//src/mocks//, packages//vite.config.ts, sonar.analysis.mode=incrementalは使用不可 以前は -Dsonar.analysis.mode=incremental パラメーターを追加して変更箇所だけを解析する機能がありましたが、現在はサポートが終了しています。 代わりに、変更されたディレクトリだけを解析する仕組みをパイプラインに自前で実装する方法や、CIのポリシーを変更して develop ブランチへのマージ時にのみ解析する方法があります。 銀の弾丸(silver-bullet)は存在しないので、皆さんの環境に合った方法を選んで改善してください。 詳しくはSonarQubeのドキュメントをご参照ください。 私たちのContinuous Deploymentパイプラインには、コンテナのデプロイ完了を待つ処理がありました。その待ち時間にSonarQubeの解析を並行して実行することで、約40秒〜最大2分かかっていた静的解析の時間を短縮できました。 CI最適化の結果 キャッシュHitを想定(install、buildがともにヒットした場合) pnpmの依存関係キャッシュとTurborepoのTaskキャッシュを適用したことで、CIの所要時間を最大79%短縮できました。 モノレポの中でも、パッケージ数とコード量が多いプロジェクトほど最適化の効果が際立ちました。パッケージが多いほど、修正による影響範囲が小さくなるためです。 さらに、turborepo もモノレポも導入していないプロジェクトE、Fでも、依存関係のインストールを最適化しただけでCIが約1.6〜2倍速くなりました。 調査対象の平均CI所要時間は6分3秒から2分4秒になり、最大66%改善できました。静的解析まで最適化すれば、さらに時間を節約できると期待しています。 おわりに 以上、簡単な修正でCIの所要時間を劇的に改善した経験を共有しました。皆さんもぜひ試してみて、得られた成果やより良い方法をコメントで教えていただけるとうれしいです。 最適化を進めるなかで私を信頼し、込み入った質問にも丁寧に答えてくださったInflab開発チームの皆さんのおかげで、無事に最適化を終えることができました。ありがとうございました。 次回の記事では、JenkinsのEC2エージェントのコンピューティングコストを最大70%節約できたスポットインスタンスの導入をテーマにお届けします。 Inflab DevOpsチームの最適化はまだまだ続きます。今後もご期待ください! 参考資料 monorepo.tools pnpm.io
macOS runners are vulnerable to the eMudhra intermediate CA compromise · Issue #15228 · actions/runner-images : 👀 --- runner-images#15228
macOS + GitHub Actions: eMudhra fixes require ongoing curation. Runner images can retain vulnerable CA bundles; updating Homebrew doesn't fix Apple's curl/Python. Apple Python can also ignore CA env overrides. Test each client--and recheck after OS updates. runner-images#15228
Github Action 镜像仓库中包含了大量的开发环境安装脚本,满足了我的收集癖。 github.com/actions/runner-images/tree/main/images… 在逛windows blogs的时候看到 python3.15版本的tier2 (jit) 支持了winonarm平台。 blogs.windows.com 然后在文章中看到了Github Action runner的镜像仓库。 进一步发现这些脚本。 t.co
GitHub Actions : le tutoriel pas à pas pour tester et publier une API Node.js Les six notions de GitHub Actions Tout workflow GitHub Actions se lit avec six mots, définis dans la page Understanding GitHub Actions de la documentation officielle (consultée le 31 octobre 2026) : | Notion | Ce que c'est | Dans notre exemple | |---|---|---| | Workflow | Un processus automatisé décrit par un fichier YAML dans .github/workflows/ | ci.yml | | Événement | L'activité du dépôt qui déclenche le workflow | push et pull_request sur main | | Job | Une suite d'étapes exécutées sur un même runner ; les jobs tournent en parallèle sauf dépendance | test et image | | Step | Une commande shell ou une action, exécutées dans l'ordre | npm ci, npm test | | Runner | La machine qui exécute un job, un seul job à la fois | ubuntu-latest, hébergé par GitHub | | Action | Une extension réutilisable, souvent prise sur le GitHub Marketplace | actions/checkout, docker/login-action | Chaque exécution part d'une machine virtuelle neuve, et chaque action du Marketplace est du code tiers qui tourne avec vos droits, d'où l'étape 6. !Schéma du workflow GitHub Actions de ce tutoriel : un événement push ou pull_request déclenche deux jobs de test en parallèle sur Node.js 24 et 26, puis un job qui publie l'image Docker sur ghcr.io --- Étape 1 : un premier workflow pour l'API Node.js Le point de départ : récupérer le code, installer Node.js et les dépendances, lancer les tests. L'API du tutoriel déclare "test": "node --test" dans son package.json et exige Node.js 24 ou plus. Créez .github/workflows/ci.yml : name: CI on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: test: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 - run: npm ci - run: npm test npm ci installe exactement ce que fixe package-lock.json, que le dépôt doit contenir, et npm test lance node --test. Le timeout-minutes évite qu'un test bloqué tourne jusqu'à la limite de 6 heures par job des runners hébergés, fixée par la page des limites de GitHub Actions. Pourquoi checkout v7 et pas v4 ? Selon son README, la v5 est passée au moteur node24, la v6 range les identifiants Git hors de .git/config, et la v7 refuse par défaut le code d'un fork sous pull_request_target ou workflow_run. Les dernières versions au 31 octobre 2026 sont actions/checkout v7.0.1 et actions/setup-node v7.1.0. L'onglet Actions affiche ensuite chaque exécution ; pour lire les journaux d'un runner Ubuntu, voyez nos commandes Linux de base. --- Étape 2 : tester sur Node.js 24 et 26 avec une matrice Une matrice exécute le même job plusieurs fois avec des valeurs différentes. Pour l'API, deux versions comptent : Node.js 24, minimum exigé par le package.json, et Node.js 26, passé en LTS avec la version 26.13.0 du 28 octobre 2026 selon l'index des versions de Node.js. Remplacez le job test : test: runs-on: ubuntu-latest timeout-minutes: 10 strategy: fail-fast: false matrix: node-version: [24, 26] steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm test Les deux jobs tournent en parallèle. fail-fast: false laisse finir le second si le premier échoue. La page des limites plafonne une matrice à 256 jobs, et chaque job est facturé séparément. --- Étape 3 : le cache, plus rapide mais pas sans risque Le cache de GitHub Actions évite de retélécharger les paquets npm à chaque exécution : ajoutez cache: npm sous with. D'après le README de setup-node, la clé dérive du hachage de package-lock.json et l'action met en cache le dossier global de npm, pas node_modules. - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node-version }} cache: npm Les limites, lues dans la référence du cache de dépendances le 31 octobre 2026 : - 10 Go par dépôt par défaut. Une entrée non lue depuis plus de 7 jours est supprimée. - Aucun secret dans le cache. Toute personne ayant un accès en lecture peut ouvrir une pull request et lire un cache. Le mot-clé cache-mode (read, write, write-only ou none) fixe les droits d'un job sur le cache. Il se place en tête du workflow ou dans un job, par exemple cache-mode: read sous test:, d'après la référence de syntaxe. Selon la documentation du cache consultée le 31 octobre 2026, un déclencheur peu fiable comme pull_request_target n'obtient par défaut que la lecture, et y déclarer write réintroduit le risque d'empoisonnement du cache. --- Étape 4 : secrets, variables et environnements Un jeton n'entre jamais dans le YAML : il se range dans Settings, Secrets and variables, Actions, et se lit avec ${{ secrets.NOM }}. Une valeur non sensible, comme une URL, devient une variable lue avec ${{ vars.NOM }}, au niveau du dépôt, de l'organisation ou d'un environnement, selon la page sur les variables. Pour rattacher le job image de l'étape 5 à un environnement production, avec une variable PRODUCTION_URL créée dans cet environnement, ajoutez au job : environment: name: production url: ${{ vars.PRODUCTION_URL }} La référence de syntaxe autorise le contexte vars dans url. Mais la page de gestion des environnements réserve les environnements aux dépôts publics sur GitHub Free ; en privé, il faut Pro, Team ou Enterprise. C'est pourquoi le fichier complet plus bas s'en passe. Notre workflow n'a besoin d'aucun secret ajouté à la main. Les tests de l'API fixent eux-mêmes JWT_SECRET et DB_PATH=':memory:' dans test/api.test.js, et le job image construit l'image sans démarrer le conteneur. Quant au GITHUB_TOKEN, créé pour chaque job, il suffit à publier sur le registre de GitHub. --- Étape 5 : construire et publier l'image Docker sur GHCR GHCR, le registre de conteneurs de GitHub (ghcr.io), accepte un push authentifié par GITHUB_TOKEN. L'image reprend le Dockerfile multi-étapes de notre tutoriel Docker pour débutants, basé sur node:24-alpine, placé à la racine du projet. Ajoutez ce job à ci.yml : image: needs: test if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read packages: write steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=sha - uses: docker/build-push-action@v7 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max Ce que fait chaque bloc : - needs et if. L'image n'est construite que si les deux tests réussissent, et seulement pour un push sur main. - docker/metadata-action. L'image prend le nom du dépôt, ghcr.io/<compte>/<dépôt>, soit ghcr.io/<compte>/api-taches si le dépôt s'appelle comme le dossier du tutoriel. L'action produit les tags main et sha- suivi du commit abrégé, plus les labels OCI. Son README précise qu'il met le nom d'image en minuscules, ce qu'exige le registre. - context: . avec checkout. Sans checkout, build-push-action lit le dépôt par Git et ignore les modifications des étapes précédentes, selon son README. - type=gha. Les couches Docker vont dans le cache de GitHub Actions. Avec plusieurs images, fixez un scope distinct, sinon chaque build écrase le cache du précédent, prévient la documentation de Docker. Deux pièges documentés par GitHub sur la page du Container registry. Un paquet publié pour la première fois est privé par défaut. Et le GITHUB_TOKEN ne peut pas pousser si une image du même nom a déjà été poussée à la main sans être reliée au dépôt. Côté coût, la facturation de GitHub Packages indique le 31 octobre 2026 que le stockage du Container registry est gratuit pour l'instant, avec un mois de préavis avant tout changement. L'image se déploie ensuite derrière notre reverse proxy Nginx avec Docker Compose. --- Étape 6 : GITHUB_TOKEN minimal et actions épinglées Cette étape a deux volets : limiter ce que le jeton peut faire, et figer le code des actions exécutées. Le principe du moindre privilège Le GITHUB_TOKEN est limité au dépôt et valable le temps du job. Ses droits dépendent du réglage du dépôt, puis du bloc permissions. La référence de syntaxe fixe la règle utile : dès que vous citez une permission, toutes celles que vous ne citez pas passent à none. D'où notre schéma : contents: read au niveau du workflow, packages: write dans le seul job image. C'est ce que récompense le test Token-Permissions d'OpenSSF Scorecard, qui classe ce risque comme élevé. L'option persist-credentials: false de checkout évite en plus de conserver le jeton pour les commandes Git suivantes. Épingler les actions sur un SHA Un tag comme @v7 peut être déplacé. Selon l'avis de sécurité GitHub GHSA-mrrh-fwg8-r2c3 (CVE-2025-30066), des attaquants ont redirigé en mars 2025 plusieurs tags de tj-actions/changed-files vers un commit malveillant, qui affichait les secrets dans les journaux entre le 14 et le 15 mars. La référence de sécurité de GitHub Actions recommande donc d'épingler chaque action sur l'empreinte complète d'un commit, en vérifiant qu'elle vient du dépôt de l'action et non d'un fork. La forme est uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 : l'empreinte fige le code, le commentaire garde la version lisible. Le fichier complet plus bas épingle ainsi les six actions sur les versions publiées au 31 octobre 2026. Nous avons lu ces empreintes sur l'API de GitHub ; vérifiez-les sur la page des versions de chaque action. Pour ne pas figer des versions vulnérables, laissez Dependabot proposer les mises à jour avec .github/dependabot.yml, selon la documentation de Dependabot : version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" Selon la référence de sécurité, les alertes Dependabot ignorent les actions épinglées par SHA ; les mises à jour de version, elles, fonctionnent. GitHub va plus loin avec les fichiers de verrouillage des workflows : .github/workflows/actions.lock, généré par l'extension gh actions-lock, fige le commit de chaque action, dépendances indirectes comprises, et vérifie l'identité du dépôt source. La référence de sécurité le présente comme la meilleure protection, qui remplace l'épinglage par SHA pour les dépôts qui l'adoptent. Ce tutoriel garde les SHA : la documentation ne dit pas si le dispositif est en préversion ou disponible pour tous, et il exige une extension de GitHub CLI. --- Étape 7 : concurrence et workflows réutilisables Deux mots-clés évitent le gaspillage et la copie de YAML. Le premier, concurrency, se place en tête de ci.yml : concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} D'après la page sur la concurrence, un seul workflow s'exécute à la fois par groupe, et une exécution en attente est remplacée par la plus récente. L'expression annule aussi l'exécution en cours, mais seulement pour une pull request. Sur main, l'exécution en cours va à son terme, mais une exécution en attente reste remplacée par la plus récente : seul le dernier push en file est publié. Le second, workflow_call, transforme un workflow en brique réutilisable. Déplacez le job de test dans .github/workflows/tests-node.yml : name: Tests Node.js on: workflow_call: inputs: node-versions: description: Versions de Node.js, au format JSON type: string default: '[24, 26]' jobs: test: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read strategy: fail-fast: false matrix: node-version: ${{ fromJSON(inputs.node-versions) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - run: npm test Dans ci.yml, le job test devient un simple appel, et image garde son needs: test : jobs: test: uses: ./.github/workflows/tests-node.yml permissions: contents: read with: node-versions: '[24, 26]' La forme ./.github/workflows/{filename} est celle que la documentation sur la réutilisation donne pour un workflow du même dépôt. La même page, consultée le 31 octobre 2026, mentionne aussi une forme $/.github/workflows/{filename}, qu'elle dit recommandée et indisponible sur GitHub Enterprise Server ; nous ne l'avons pas testée. La référence des workflows réutilisables fixe dix niveaux d'imbrication au plus et 50 workflows réutilisables distincts par fichier ; les variables env de l'appelant ne sont pas transmises, et le workflow appelé ne peut pas élever les permissions reçues. --- Le fichier ci.yml complet Voici .github/workflows/ci.yml une fois les étapes 1 à 7 assemblées, avec le job test écrit en entier (sans le workflow réutilisable de l'étape 7 ni l'environnement optionnel de l'étape 4) : déclencheurs, permissions minimales, concurrence, matrice avec cache, publication sur GHCR, et chaque action épinglée sur son SHA. Sa syntaxe a été vérifiée avec le module yaml de Python ; il n'a pas été exécuté sur GitHub. Avec le workflow réutilisable de l'étape 7, le job test ci-dessous est remplacé par l'appel à tests-node.yml. name: CI on: push: branches: [main] pull_request: branches: [main] permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: runs-on: ubuntu-latest timeout-minutes: 10 strategy: fail-fast: false matrix: node-version: [24, 26] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - run: npm test image: needs: test if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read packages: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=sha - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max --- Combien coûte GitHub Actions en octobre 2026 GitHub Actions reste gratuit pour les dépôts publics sur les runners standards, et sur vos propres machines. Pour les dépôts privés, la page de facturation de GitHub Actions, lue le 31 octobre 2026, exprime désormais le quota en dollars de calcul inclus, et non plus en minutes, déduits selon le tarif de chaque runner : | Offre | Calcul inclus par mois | Soit, sur Linux 2 cœurs | Stockage d'artefacts | Cache par dépôt | |---|---|---|---|---| | GitHub Free | 12 $ | 2 000 minutes | 500 Mo | 10 Go | | GitHub Pro | 18 $ | 3 000 minutes | 1 Go | 10 Go | | GitHub Team | 18 $ | 3 000 minutes | 2 Go | 10 Go | | GitHub Enterprise Cloud | 300 $ | 50 000 minutes | 50 Go | 10 Go | La conversion est notre calcul à 0,006 $ la minute. Les 2 000 minutes souvent citées, y compris dans notre guide CI/CD, correspondent donc aux 12 $ de l'offre Free sur Linux 2 cœurs ; la page de prix de GitHub affiche bien 2 000, 3 000 et 50 000 minutes pour Free, Team et Enterprise, avec Team à 4 $ et Enterprise à 21 $ par utilisateur et par mois. Le quota en dollars change la donne dès que vous quittez Linux. La grille des runners facture 0,002 $ la minute sur Linux 1 cœur, 0,005 $ sur Linux arm64 2 cœurs, 0,010 $ sur Windows 2 cœurs et 0,062 $ sur macOS 3 ou 4 cœurs. Les 12 $ de l'offre Free couvrent donc environ 193 minutes de macOS seulement. !Graphique en barres horizontales GitHub Actions : selon notre calcul, les 12 dollars inclus dans GitHub Free couvrent 6 000 minutes sur Linux 1 cœur, 2 400 sur Linux arm64 2 cœurs, 2 000 sur Linux 2 cœurs, 1 200 sur Windows 2 cœurs et 193 sur macOS 3 ou 4 cœurs > Notre calcul. Chaque job étant arrondi à la minute supérieure, notre ci.yml coûte au moins 3 minutes par push sur main et 2 par pull request. À 50 de chaque par mois sur un dépôt privé, cela fait au moins 250 minutes, bien dans le quota Free. Une matrice sur trois systèmes d'exploitation changerait tout : une minute de macOS coûte plus de dix minutes de Linux 2 cœurs. Le stockage se paie à part : 0,25 $ par Go et par mois au-delà de l'allocation d'artefacts, 0,07 $ pour le cache si vous avez relevé sa limite de 10 Go, selon la même page. --- Runners auto-hébergés : gratuits, pas sans danger Un runner auto-hébergé est une machine à vous qui exécute les jobs à la place des machines de GitHub. La page de facturation les range, le 31 octobre 2026, parmi les usages gratuits qui ne réduisent pas le calcul inclus. GitHub avait annoncé en décembre 2025 une redevance de 0,002 $ la minute sur les runners auto-hébergés à partir du 1er mars 2026, puis a écrit la reporter « pour prendre le temps de réévaluer notre approche » (notre traduction), sur sa page consacrée aux changements de prix de 2026. Selon la page sur les runners auto-hébergés, vous payez la machine et mettez à jour vous-même système et outils. Checkout et setup-node, passées au moteur node24, exigent le runner v2.327.1 ou plus selon leurs README. Un job peut y tourner 5 jours d'après la page des limites, mais le GITHUB_TOKEN ne se renouvelle que pendant 24 heures, précise la page GITHUB_TOKEN. Le vrai risque est ailleurs. Selon la référence de sécurité, un runner auto-hébergé ne garantit pas une machine propre à chaque job et peut être compromis durablement : il ne devrait presque jamais servir à un dépôt public, où n'importe qui peut ouvrir une pull request. Elle mentionne aussi des runners éphémères, dits JIT, qui n'exécutent qu'un job. Verdict : pour cette API, les runners hébergés suffisent. Notre comparatif de l'hébergement cloud pour développeurs aide à choisir la machine. --- Ce que nous n'avons pas pu vérifier Ce tutoriel est une synthèse documentaire. Restent incertains : - L'exécution des workflows. Leur syntaxe YAML a été vérifiée avec un analyseur Python, mais aucun n'a tourné sur GitHub. Testez-les d'abord sur une branche. - Le sort de la redevance des runners auto-hébergés. Reportée, mais nous n'avons trouvé aucune annonce qui l'annule. - Le prix de GitHub Pro. La page de prix lue ne l'affiche pas, ni aucun prix en euros. - Le statut exact des fichiers de verrouillage. La documentation les décrit sans préciser s'ils sont en préversion ou disponibles pour tous. - La durée réelle des jobs. Nos chiffres de minutes sont des minimums dus à l'arrondi, pas des mesures. --- Questions fréquentes Combien de minutes GitHub Actions gratuites pour un dépôt privé ? Sur l'offre Free, 12 $ de calcul par mois, soit 2 000 minutes sur le runner Linux 2 cœurs à 0,006 $ la minute, mais 193 seulement sur macOS, selon la page de facturation de GitHub lue le 31 octobre 2026. Les dépôts publics sur les runners standards et les runners auto-hébergés ne consomment rien. Quelle version d'actions/checkout utiliser ? La v7 : au 31 octobre 2026, la dernière version publiée est la 7.0.1. Épinglez-la sur son SHA complet, avec la version en commentaire. Quelle différence entre une action et un workflow ? Le workflow est votre fichier YAML, déclenché par un événement, qui contient des jobs. Une action est une brique appelée dans un step avec uses, comme actions/setup-node. Un workflow réutilisable s'appelle, lui, au niveau d'un job. Comment publier une image Docker avec GitHub Actions ? Connectez-vous à ghcr.io avec docker/login-action et le GITHUB_TOKEN, puis construisez et poussez avec docker/build-push-action, dans un job qui déclare packages: write (étape 5). --- À propos de l'auteur Abdelilah Ourti est ingénieur IA et architecte logiciel. Il conçoit et déploie des applications web et des systèmes d'IA, et publie sur NeurauTech des guides et des comparatifs destinés aux développeurs francophones. Ses autres articles sont réunis sur sa page auteur. Méthode. Synthèse documentaire fondée exclusivement sur des sources publiques consultées le 31 octobre 2026 : documentation de GitHub Actions, de GitHub Packages et de Dependabot, pages de facturation et de prix de GitHub, README et pages de versions des actions officielles et des actions Docker, documentation de Docker, OpenSSF Scorecard, avis de sécurité de GitHub, index des versions de Node.js. Les empreintes SHA ont été lues sur l'API de GitHub. La syntaxe YAML a été vérifiée localement, mais aucun workflow n'a été exécuté. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs mentionnés. Une erreur ou une version périmée ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée. Dernière mise à jour : 31 octobre 2026. Image : Alberto Rodríguez (Unsplash License).
GitHub Actions Node 20 Deprecation: What Broke on 23 September (and Why node20 Isn't the Problem) GitHub removed Node 20 from the Actions runners on 23 September 2026, and an action that declares using: node20 still doesn't fail for that: since 16 June the runner rewrites node20 to node24 before it executes anything, and it does the same with node12 and node16. What went away on the 23rd was the escape hatch: the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION variable no longer brings Node 20 back. The next date with real consequences is 2 November, when macos-14 is retired. That inverts who is at risk. If your CI has been green since June without that variable, the 23rd changed nothing for you on GitHub-hosted runners. The ones that break are precisely the teams that already hit an action that can't handle Node 24, set the variable to keep moving, and filed the real fix under later. And around that date there are four more with real consequences: the self-hosted runner minimum version (25 September), retention for checks and runs (1 October), the macos-14 brownouts (October) and its retirement (2 November). I checked this against the runner's source code and against this site's own CI, which uses two node20 actions. Everything below comes with its source. The calendar, by date | Date | What changes | What breaks | How to detect it | |---|---|---|---| | 14, 16 and 18 Sep (11:00-15:00 ET) | Brownouts ahead of the self-hosted runner minimum version | Outdated runners cannot register or run jobs during the window | Runner deprecations API (below) | | 17 Sep | ubuntu-22.04 and ubuntu-22.04-arm enter deprecation | Nothing fails yet; queues may be longer | grep for the label | | 21-30 Sep | windows-11-arm moves to the Visual Studio 2026 image | The toolchain underneath changes | grep windows-11-arm | | 23 Sep | Node 20 leaves the runners | ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION is ignored; Linux ARM32 runners lose support | grep for the variable | | 25 Sep | Self-hosted runner minimum version is enforced | Registering requires ≥ 2.329.0; a runner that doesn't install each release within 30 days stops receiving jobs | Deprecations API | | 1 Oct | Actions retention now covers checks, workflow runs and statuses | Anything past your retention period (90 days by default) is deleted | Repo/org retention setting | | 5-31 Oct | Eight macos-14 brownouts | Jobs scheduled inside the window fail | grep macos-14 | | 2 Nov | macos-14 retired | Jobs with that label terminate with an error | grep macos-14 | | 17 Apr 2027 | ubuntu-22.04 retired (brownouts before, in March and April) | Jobs terminate with an error | grep for the label | 1. Node 20: what actually happens on 23 September What the notice says GitHub's changelog post from 19 September 2025, with an editor's note dated 25 August 2026, sets two dates: from 16 June 2026 runners use Node 24 by default, and the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true opt-out back to Node 20 "will only work until we upgrade the runner and remove Node20 on September 23rd, 2026." It adds two platform limits: Node 24 is incompatible with macOS 13.4 and lower, and it has no official ARM32 support. What the notice does not explain is what happens to an action whose action.yml still says using: node20. That lives in the code. What the runner code says In HandlerFactory.cs (identical at tag v2.337.0, the current release), the runner does two things before launching a JavaScript action: 1. If the action declares node12 or node16, it bumps it to node20. 2. If it ends up on node20, it asks NodeUtil.DetermineActionsNodeVersion which version to use. That function has three phases, driven by feature flags on GitHub's side: // Phase 3: Always use Node 24 regardless of environment variables if (requireNode24) { return (Constants.Runner.NodeMigration.Node24, null); } // ... // Phase 2: Node 24 is the default if (useNode24ByDefault) { if (allowUnsecureNode) { return (Constants.Runner.NodeMigration.Node20, null); } return (Constants.Runner.NodeMigration.Node24, null); } PR #3948, which introduced this, is blunt about it: in phase 3, "All actions use Node 24" and "No opt-out options available." No branch returns an error for declaring node20. The action runs on a different Node, which is very different from not running. Note the order too: node12 → node20 → node24. An old action with using: node16 (for example actions/checkout@v3) ends up on Node 24 as well. The proof: this site's CI This blog's CI workflow uses actions/checkout@v4 and actions/setup-node@v4. The official action.yml files at those tags declare using: node20; from v5 onward they declare node24 (I checked tags v4 through v7 of both). The run on 11 September, on runner 2.337.0 with the ubuntu-24.04 image, finished green with this annotation: Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: actions/checkout@v4, actions/setup-node@v4. And in the log of those two actions' steps, this line: Node 20 is being deprecated. This workflow is running with Node 24 by default. If you need to temporarily use Node 20, you can set the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true environment variable. The same annotation appears on the very first run of that workflow, on 23 August. In other words, my two node20 actions have never run on Node 20 in this CI, and I didn't touch anything to make that happen. 23 September doesn't change their runtime, because that change already happened in June. Bumping them to v5 or later clears the annotation, and it's worth doing, but there's no clock on it. What it looks like when it does break There are three real ways to fail, and none of them is a GitHub message saying "Node 20 no longer exists." You had the variable set. Some action of yours tripped on Node 24 after 16 June, you set ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true, and it stayed on Node 20. In phase 3 the runner ignores the variable without telling you: the requireNode24 branch returns null as its message. The action goes back to Node 24, and the error you see is its own code's error, the same one you saw the first time. One hint that phase 3 is live: with the current runner code, the "If you need to temporarily use Node 20…" line disappears from the log, because it is only printed while Node 24 is the default but not yet mandatory. The action uses something Node 24 removed. Per the Node 24 deprecations table and the 24.0.0 release notes, these reached end-of-life in that release: | API | Status in Node 24 | How it fails | |---|---|---| | tls.createSecurePair() | Removed (DEP0064) | TypeError: ... is not a function when called | | fs.truncate() with a file descriptor | Removed (DEP0081) | Throws ERR_INVALID_ARG_TYPE: it expects a path. Use fs.ftruncate() | | dirent.path | Removed (DEP0178) | Doesn't throw on its own: the property is gone and you read undefined. Use dirent.parentPath | | OutgoingMessage.prototype._headers / _headerNames | Removed (DEP0066) | You read undefined. Use getHeaders() | dirent.path is the sneakiest one on the list, because reading it throws nothing: the failure shows up later, wherever that path gets used, or never, if it's only concatenated into a string and comes out as undefined/file. If you maintain an action that walks directories, search for that property before you trust the green check. Your self-hosted runner can't run Node 24. On Linux ARM32, the same NodeUtil.cs has a kill switch that fails the step with: Linux ARM32 runners are no longer supported. Please migrate to a supported platform. Before it's flipped, the runner keeps those actions on Node 20 with a warning whose default date is the Node 20 removal date: "Linux ARM32 runners are deprecated and will no longer be supported after September 23rd, 2026." GitHub hasn't published the exact day the switch flips; the changelog only says ARM32 loses support after the Node 20 deprecation. On macOS, Node 24's BUILDING.md requires macOS 13.5 or later. 2. The audit: what to look for and where In one repository Run this from the root: # 1. The switch that stops working on 23 September grep -rn 'ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION' .github/ # 2. Image labels with a date. No "runs-on:" on purpose, # so matrices show up too (os: [ubuntu-22.04, ...]) grep -rnE 'ubuntu-22\.04|macos-14|windows-11-arm' .github/ # 3. Your own or local actions that declare an old Node grep -rnE --include=action.yml --include=action.yaml \ "using:[[:space:]]*['\"]?node(12|16|20)" . Search 2 has a blind spot: runs-on: ${{ vars.RUNNER }} or a label passed as an input to a reusable workflow won't show up in any grep. For those, the log is the source of truth. The "Set up job" step prints the real image: gh run view <run-id> --log | grep -m1 'Image:' # Image: ubuntu-24.04 If the matrix is long or nested, converting it to JSON and filtering it with jq is usually faster than reading the YAML by eye: > Free tool: JSON and YAML Converter — Convert JSON to YAML and YAML to JSON instantly, with configurable indentation, download and copy. All in your browser, nothing uploaded. To find out which runtime each third-party action you use declares, including SHA-pinned ones, you have to read its action.yml at that exact ref. This script does it with gh: grep -rhoE "uses:[[:space:]]*['\"]?[^[:space:]'\"#]+@[^[:space:]'\"#]+" .github/ \ | sed -E "s/uses:[[:space:]]*['\"]?//" | sort -u \ | while read -r ref; do spec=${ref%@*}; ver=${ref#*@} case "$spec" in ./*|docker://*) continue ;; esac repo=$(cut -d/ -f1-2 <<<"$spec"); sub=$(cut -d/ -f3- <<<"$spec") for f in action.yml action.yaml; do rt=$(gh api "repos/$repo/contents/${sub:+$sub/}$f?ref=$ver" --jq .content 2>/dev/null \ | base64 -d | grep -E '^[[:space:]]*using:' | tr -d " '\"" | cut -d: -f2) [ -n "$rt" ] && { echo "$rt $ref"; break; } done done On this repo it prints: node20 actions/checkout@v4 node20 actions/setup-node@v4 It handles subpaths (github/codeql-action/init@v3) and SHAs. A SHA pointing at a node20 release doesn't fail on the 23rd, for the same reason @v4 doesn't: the runner forces it onto Node 24. What's different with a SHA is how it gets updated: nothing moves on its own, so Dependabot or a human has to bump it. One warning before bumping a major: read the release notes. actions/setup-node v5 turned on automatic caching when package.json has a packageManager field, and v6 limited it to npm. If you already pass cache: npm explicitly, as my CI does, it doesn't affect you. Across an organization GitHub code search reaches every repo at once: gh search code --owner YOUR_ORG 'ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION' gh search code --owner YOUR_ORG 'ubuntu-22.04 path:.github/workflows' gh search code --owner YOUR_ORG 'macos-14 path:.github/workflows' gh search code --owner YOUR_ORG node20 --filename action.yml Two limits of the code search API that will bite you: it only searches the default branch (a workflow on a release branch won't show up) and it allows 10 requests per minute. While preparing this post I hit HTTP 403: API rate limit exceeded as soon as I chained a handful of searches; add a sleep between them if you automate it. 3. Self-hosted runners: two dates in three days If you only use GitHub-hosted runners, skip this section. If you run your own, 25 September matters more than the 23rd. The 12 June changelog post resumes enforcement of two rules on github.com: - To register (or re-register) a runner you need version 2.329.0 or later. - To keep running jobs, the runner must install each new release within 30 days of its publication. "A runner pinned to 2.329.0 that never updates again will not pick up jobs." Full enforcement begins on 25 September 2026 (the notice's table labels that date as GitHub Enterprise Cloud, and the same notice states the change applies to github.com), with brownouts first: on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern, out-of-date runners can neither register nor run jobs. What you see, per the notice, is that "workflows targeting unsupported runners may remain queued or fail." The way to find out when your version expires is an endpoint GitHub added on 3 September. I queried it today: gh api repos/OWNER/REPO/actions/runners/deprecations/2.335.1 # {"runner_version":"2.335.1","runtime_deprecates_at":"2026-09-24T15:30:55Z"} gh api repos/OWNER/REPO/actions/runners/deprecations/2.337.0 # {"runner_version":"2.337.0","runtime_deprecates_at":null} A runner on 2.335.1 falls out of support on 24 September, so by the time full enforcement starts on the 25th it no longer gets jobs. You get the installed version on the machine itself with ./config.sh --version. The self-hosted checklist, in order: 1. Update the runner to the current release and leave auto-update on. If it's off, you need a manual cadence shorter than 30 days. 2. Rebuild the images, VM templates and containers you create runners from. The changelog asks you to recreate runners built from older cached images or templates: if the image ships a runner older than 2.329.0, it won't register. 3. Linux ARM32: move to arm64. Node 24 has no official support there, the runner's linux-arm package only bundles the Node 20 binary (externals.sh), and the code that fails the step is already in the runner. 4. macOS 13.4 or earlier: upgrade the OS. GitHub lists Node 24 as incompatible with those versions. 5. Actions that already declare node24 (such as actions/checkout@v5) require runner v2.327.1 at minimum. If you've done step 1, you already have it. 4. Retention, 1 October: history gets deleted This change breaks no build, but it deletes data. According to the 27 August changelog post, starting 1 October 2026 checks, workflow runs and statuses follow the same retention setting that already governed artifacts and logs, 90 days by default. Until now they were kept for "400+ days" regardless of your configuration. - For public repositories the maximum is 90 days. - The change is not retroactive: raising retention later won't restore anything. - Check and workflow run metadata isn't billed as storage; the associated artifacts and logs are. What breaks is anything that reads that history beyond your period: deployment metrics computed from gh run list, audits that link to a specific run, quarterly reports. Export what you need before 1 October. What the changelog does not say: what happens to a pull request open for more than 90 days whose required check expires. I couldn't find it documented. If you have long-lived PRs under branch protection, look at them after 1 October before assuming anything. 5. Images: macos-14 has a date, ubuntu-22.04 doesn't yet macos-14 (actions/runner-images#13518): deprecation started on 6 July and support ends on 2 November 2026. Before that there are eight brownouts during which jobs fail: 5, 12, 16, 19, 23, 26, 29 and 30 October, each from 14:00 UTC to 00:00 UTC the next day. It covers macos-14, macos-14-large and macos-14-xlarge. The replacement is macos-15, macos-26 or macos-latest, which started pointing to macOS 26 with the migration that began on 15 June. ubuntu-22.04 (actions/runner-images#14254): on 17 September deprecation only begins. The issue warns about longer queue times at peak hours, not failures. Retirement is on 17 April 2027, with brownouts that the issue schedules for March and April (no year given, but they fall before the retirement). It also covers ubuntu-22.04-arm. The replacement is ubuntu-24.04, ubuntu-26.04 or ubuntu-latest. If someone tells you ubuntu-22.04 breaks this week, it doesn't. Migrate calmly, but don't leave it for March. What's in the changelog and breaks nothing - cache-mode (10 September): a new key to limit cache access per workflow or job. It's opt-in: workflows that don't set it "continue to use the existing secure defaults." - The separate Code Quality path (20 August): only affects you if you have reports filtering on dynamic/github-code-scanning/codeql or on the github-advanced-security actor. - actions/checkout and pull_request_target (18 June): this one does break, but it already happened. Since 20 July the floating tags, @v4 included, refuse to check out fork code in pull_request_target. If your workflow depended on that, it's already red. It's the same family of risk I covered in the worm that got in through npm trusted publishing. What I'm doing with my CI Nothing urgent. actions/checkout@v4 and actions/setup-node@v4 have been running on Node 24 since June, the workflow doesn't use the opt-out variable, it runs on ubuntu-latest (currently ubuntu-24.04) and on GitHub-hosted runners. 23 September changes nothing for it. I am going to bump both actions to their current major, for two reasons unrelated to the date: a permanent yellow annotation trains you to ignore annotations, and an action its authors no longer test on the runtime it actually runs on is debt. If you're also thinking about your own project's Node rather than the actions', I covered that in what breaks when upgrading to Node 26. Frequently asked questions What happens to GitHub Actions on 23 September 2026? GitHub removes Node 20 from the runners. Since 16 June 2026 runners had already been running actions on Node 24 by default, and ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true let you go back to Node 20. From 23 September that variable stops working and every JavaScript action runs on Node 24. Self-hosted runners on Linux ARM32 lose support, because Node 24 has no official support for that platform. Does an action that declares using: node20 fail after 23 September? Not for declaring it. The runner code (HandlerFactory.cs and NodeUtil.cs in actions/runner) rewrites node12 and node16 to node20, and node20 to node24; in the final phase it ignores the environment variables and always picks Node 24, without returning an error. The action fails if its code uses something Node 24 removed, such as tls.createSecurePair or fs.truncate with a file descriptor, or if it runs on a self-hosted Linux ARM32 runner or on macOS 13.4 or earlier. The logs show the annotation: The following actions target Node.js 20 but are being forced to run on Node.js 24. Do I have to update actions/checkout@v4 and actions/setup-node@v4? It's a good idea, but no date forces it. The v4 tags of both declare node20 and v5 onward declare node24. On GitHub-hosted runners, the v4 tags have already been running on Node 24 since June and keep working. When bumping setup-node, read the release notes: v5 turned on automatic caching when package.json has packageManager, and v6 limited it to npm. How do I find affected workflows across my organization? With GitHub code search: gh search code --owner YOUR_ORG with ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION, with ubuntu-22.04 path:.github/workflows, with macos-14 path:.github/workflows, and with node20 --filename action.yml for your own actions. The API only searches the default branch and allows 10 requests per minute. Labels that come from variables or expressions don't show up in any search: for those, check the Image: line of the Set up job step in the log. Does ubuntu-22.04 break on 17 September 2026? No. On 17 September deprecation only begins, and the notice talks about longer queue times at peak hours. Jobs start failing during the brownouts that issue 14254 in actions/runner-images schedules for March and April, before the image is retired on 17 April 2027. The one with a date this fall is macos-14: brownouts in October and retirement on 2 November 2026. What do self-hosted runners need to do before 25 September? Update the runner. From 25 September 2026 GitHub requires version 2.329.0 to register a runner, and each new release must be installed within 30 days to keep receiving jobs. There are brownouts first on 14, 16 and 18 September, from 11:00 AM to 3:00 PM Eastern. The endpoint GET /repos/OWNER/REPO/actions/runners/deprecations/VERSION returns when your version stops running jobs. Also rebuild the images you create runners from. What changes with GitHub Actions retention on 1 October 2026? Checks, workflow runs and statuses start following the artifact and log retention setting, which defaults to 90 days; before, they were kept for more than 400 days. For public repositories the maximum is 90 days, and the change is not retroactive. If you have metrics or audits that read old runs, export the data before that date. --- Originally published on ortamarco.me.
GitHub ActionsからAWSへ安全に接続する:OIDCとIAMロールの実装手順 GitHub ActionsからAWSへデプロイする際、AWSアクセスキーをSecretsに保存する方法だけが選択肢ではありません。OIDC(OpenID Connect)を使えば、GitHubが発行する身元証明用トークンをAWSに渡し、短期間だけ有効な認証情報を受け取れます。 この記事では、GitHub ActionsからS3へファイルをアップロードする例で、実装と失敗しやすい箇所を整理します。2026年10月時点の公式ドキュメントを確認しています。 仕組みを理解する GitHub Actions → GitHubからOIDCトークンを取得 → AWS STSでIAMロールを引き受ける → 一時的なAWS認証情報を使ってS3にアクセス OIDC:異なるサービス間で実行元の身元を証明するための仕組み。 STS:AWSの一時的な認証情報を発行するサービス。 IAMロール:AWSで引き受けられる権限のまとまり。 信頼ポリシー:そのロールを誰が引き受けられるか決める設定。 長期アクセスキーの保管とローテーションを減らせる一方で、ロールの権限は引き続き最小限に設定する必要があります。 1. GitHubのOIDCプロバイダーをAWSへ登録する AWS IAMでIDプロバイダーを作成し、次の値を設定します。 項目 設定値 プロバイダーURL token.actions.githubusercontent.com Audience sts.amazonaws.com Audience(オーディエンス)はトークンの利用先を表します。一般的なAWSの構成で公式Actionを使う場合の値です。既存のIDプロバイダーがある場合は重複して作らず設定を確認します。 2. 信頼ポリシーで実行元を限定する IAMロールの信頼ポリシー例です。AWSアカウントIDやリポジトリ名はダミー値です。 { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:example-org/example-api:ref:refs/heads/main" } } }] } sub(subject)はトークンの実行元を識別する値です。この例は特定のリポジトリのmainブランチだけを許可します。AWS公式ドキュメントも、組織・リポジトリ・ブランチなどで信頼対象を絞ることを推奨しています。 2026年7月15日以降に作成したGitHubリポジトリは要注意です。 GitHub公式リファレンスによると、新規リポジトリのデフォルトsubには変更されない所有者IDとリポジトリIDが含まれます。 従来形式 repo:example-org/example-api:ref:refs/heads/main 新形式(IDは説明用の値) repo:example-org@111111/example-api@222222:ref:refs/heads/main 実際に使用する形式とIAMの信頼ポリシーを一致させてください。従来から存在するリポジトリも設定変更や移転で形式が変わる場合があります。またGitHub Environmentを使う場合は、ブランチ参照の代わりに環境名を含むsubを使うため、環境のデプロイ保護ルールも設定します。 3. AWS操作権限を最小にする 信頼ポリシーは「誰が使えるか」、権限ポリシーは「何をできるか」で、別々の設定です。S3の特定の場所にファイルをアップロードするだけなら、次のように絞れます。 { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:PutObject"], "Resource": ["arn:aws:s3:::example-deploy-bucket/releases/*"] }] } s3:PutObjectはS3へのファイル書き込み権限です。s3:*やAdministratorAccessを最初から付けないようにします。S3バケットの設定やKMS暗号化などによっては追加権限が必要です。 4. GitHub Actionsの設定例 .github/workflows/deploy.ymlに次のように定義します。アカウントID・ロールARN・バケット名は自分の環境に置き換えてください。 name: Deploy to S3 on: push: branches: [main] permissions: contents: read id-token: write jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.1 - uses: aws-actions/configure-aws-credentials@v6.3.0 with: role-to-assume: arn:aws:iam::123456789012:role/example-github-oidc aws-region: ap-northeast-1 allowed-account-ids: "123456789012" - name: Verify identity run: aws sts get-caller-identity - name: Upload run: aws s3 cp public/index.html s3://example-deploy-bucket/releases/index.html contents: readはリポジトリを読む権限、id-token: writeはOIDCトークンを要求する権限です。後者はAWSへの無制限な書き込み権限ではありません。 allowed-account-idsは、想定外のAWSアカウントの認証情報が返されたときに処理を失敗させます。 コマンドaws sts get-caller-identityは利用中のAWSアカウントと権限主体を確認します。aws s3 cpは指定したローカルファイルをS3へコピーします。ファイルpublic/index.htmlがリポジトリに存在する想定です。 Actionのバージョンは2026年10月10日時点の公式リリースを確認しています。本番では更新内容をレビューし、必要ならコミットSHAで固定します。 よくある失敗と対処 Not authorized to perform sts:AssumeRoleWithWebIdentity:IAMの信頼ポリシーを確認してください。subのリポジトリ名・ID形式・ブランチ・Environment、audの不一致が典型的です。 Unable to locate credentials:id-token: writeやActionの入力値、実行環境を確認します。 認証は成功したのにS3でAccessDenied:IAM権限ポリシー、S3バケットポリシー、暗号化設定などを確認します。信頼ポリシーと操作権限は異なります。 不要なブランチまで許可している:repo:example-org/example-api:*のように広く許可すると、意図しないワークフローでもロールを利用できる可能性があります。必要なブランチ・環境のみに制限し、信頼できないPull Requestから強いAWS権限を利用しない設計にします。 長期アクセスキーとどう使い分ける? OIDCは初期設定としてプロバイダー・IAMロールの作成が必要ですが、長期アクセスキーをGitHub Secretsへ保存する必要がありません。アクセスキー方式はセットアップが簡単な場合がある一方、漏えいや鍵の交換を継続して管理します。 新しいGitHub Actions→AWS連携では、公式ガイドでも案内されているOIDCを第一候補にすると運用しやすくなります。 まとめ 認証失敗の調査では、GitHubのid-token: write→IAM信頼ポリシーのsub→IAM操作権限の順に確認すると原因を切り分けやすくなります。特に2026年7月以降のsub形式変更に注意してください。 一次情報 GitHub Docs:AWS向けOIDC設定 GitHub Docs:OIDCリファレンス AWS IAM:GitHub OIDC向けIAMロール aws-actions/configure-aws-credentials公式 公式リリース:configure-aws-credentials 公式リリース:checkout
systemd-analyze verify はタイプミスでも exit 0|--recursive-errors でunitファイルの警告をCIで落とす TL;DR - systemd-analyze verify は、Restrat= のような未知のキーや Restart=on-failur のような不正な値を警告するだけで、終了コードは 0 です。これは不具合ではなく、systemd-analyze(1) に書かれた仕様です。 - systemd 250 以降は --recursive-errors=no を付けると、対象のunitに警告が出た時点で exit 1 になります。CIではまずこれを使います。 - ただし手元の実測では、=no にすると Requires= の先が存在しないエラーが出力ごと消えて exit 0 になりました。依存先の欠落も止めたいなら --recursive-errors=one を使います。=one なら依存先も検証されます。 - systemd 250 より古い環境(例: GitHub ホストランナーの ubuntu-22.04 は 249)や、出力を1行でも許したくない場合は、「出力が空でなければ失敗」にするラッパーを使います。 検証環境: Debian 13 のコンテナ、systemd 257 (257.13-1~deb13u1)。以下のログはすべてこの環境で実行した結果をそのまま貼っています。 再現:タイプミスがあっても exit 0 検証用のファイルはすべて /tmp/qiita-verify/units/ に置き、/tmp/qiita-verify から相対パスで実行しています。 [Unit] Description=demo agent [Service] ExecStart=/bin/true Restrat=on-failure RestartSec=10s $ cd /tmp/qiita-verify $ systemd-analyze verify --man=no units/typo.service; echo exit=$? /tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring. exit=0 相対パスで渡しても、警告には絶対パスと行番号が出ます。Restart= は書かれていない扱いになるので、プロセスが落ちても再起動しません。それでも verify は成功扱いです。 再起動の設定はZennの記事を参照してください。 値の綴りを間違えた場合も同じです。 [Unit] Description=demo agent [Service] ExecStart=/bin/true Restart=on-failur RestartSec=10s $ systemd-analyze verify --man=no units/badvalue.service; echo exit=$? /tmp/qiita-verify/units/badvalue.service:6: Failed to parse Restart=on-failur, ignoring: Invalid argument exit=0 原因:オプションなしでは警告があっても 0 を返す仕様 systemd-analyze(1) の --recursive-errors=MODE の項には、次のように書かれています。 > If this option is not specified, zero is returned as the exit status regardless whether warnings arise during verification or not. つまり、オプションを付けない限り、警告がいくつ出ても終了コードは 0 です。未知のキーや不正な値は「その行を無視して読み込みを続ける」扱いの警告なので、ここに当てはまります。 同じ項で、モードごとの意味は次のとおり説明されています(Added in version 250)。 | MODE | 非0で終わる条件(マニュアルの記述) | |---|---| | yes | 指定したunitか、その依存先のどれかで警告が出たとき | | no | 指定したunitだけの検証で警告が出たとき | | one | 指定したunitか、その直接の依存先で警告が出たとき | 一方で、ExecStart= のコマンドが存在しない場合は、unitとして読み込めない「エラー」になるので、オプションがなくても exit 1 になります。セクション名の誤りは、どのセクションかで結果が分かれます。[Service] のセクション名を誤ると、その中身がまるごと無視されて ExecStart= がない扱いになり exit 1 です。[Unit] や [Install] の誤りは、ほかの警告と同じく exit 0 で、--recursive-errors=no を付けて初めて exit 1 になります。 flowchart TD A[unitファイル] --> B[systemd-analyze verify --man=no] B -->|読み込めないエラー<br>ExecStart不在・Serviceのセクション名の誤り| E1[exit 1] B -->|警告のみ<br>未知のキー・不正な値・Unit/Installのセクション名の誤り| C{--recursive-errors} C -->|指定なし| E0[exit 0 で素通り] C -->|=no / =one / =yes| E2[exit 1] B -->|Requires= の先が存在しない| D{--recursive-errors} D -->|指定なし / =one / =yes| E3[exit 1] D -->|=no| E4[出力なしで exit 0] E0 -.->|systemd 250 未満| W[ラッパー: 出力が空でなければ NG] 対策1:--recursive-errors=no を付ける(systemd 250 以降) $ systemd-analyze verify --man=no --recursive-errors=no units/typo.service; echo exit=$? /tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring. exit=1 $ systemd-analyze verify --man=no --recursive-errors=no units/badvalue.service; echo exit=$? /tmp/qiita-verify/units/badvalue.service:6: Failed to parse Restart=on-failur, ignoring: Invalid argument exit=1 出力は同じで、終了コードだけが 1 に変わります。複数のファイルをまとめて渡しても、1つでも警告があれば 1 になりました。 $ systemd-analyze verify --man=no --recursive-errors=no units/good.service units/typo.service; echo exit=$? /tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring. exit=1 --man=no は、Documentation= に書いた man ページの存在確認をしないためのオプションです。この検証環境のように man コマンドが入っていない環境では、Documentation=man:demo-agent(8) のようなunitで付けないと Command 'man demo-agent(8)' failed with code 1 で exit 1 になりました。 =no では Requires= の欠落が見えなくなる 依存先のunitが存在しないケースを試すと、=no だけ結果が変わりました。 [Unit] Description=demo agent Requires=missing-dep.service After=missing-dep.service [Service] ExecStart=/bin/true Restart=on-failure $ systemd-analyze verify --man=no units/requires.service; echo exit=$? requires.service: Failed to create requires.service/start: Unit missing-dep.service not found. exit=1 $ systemd-analyze verify --man=no --recursive-errors=no units/requires.service; echo exit=$? exit=0 $ systemd-analyze verify --man=no --recursive-errors=one units/requires.service; echo exit=$? requires.service: Failed to create requires.service/start: Unit missing-dep.service not found. exit=1 オプションなしなら出るエラーが、=no では出力ごと消えて exit 0 になります。これは実装どおりの挙動で、systemd v257 の src/analyze/analyze-verify-util.c では、=no のときに MANAGER_TEST_RUN_IGNORE_DEPENDENCIES を付けて検証用のマネージャーを起動しており、依存先を読みに行きません。自分のunitの書き間違いだけを見たいなら =no、依存先の欠落も同時に止めたいなら =one を使うのが安全です。ただし CI で =one を使うなら、依存先のunitもランナー上に存在していることが前提です。たとえば Requires=postgresql.service と書いたunitは、ランナーに PostgreSQL のunitがなければ、記述が正しくても exit 1 になります。この環境では、Wants=network-online.target を持つunitでも =one・=yes で余計な警告は出ませんでした(exit 0)。 誤りの種類ごとの終了コード systemd 257 で、誤りを1つだけ入れたunitを用意して測った結果です(--man=no は共通)。root と一般ユーザーの両方で実行し、結果は同じでした(/run/systemd が存在する環境での結果。systemd が動いていないコンテナで一般ユーザーが実行すると、Failed to create directory '/run/systemd/': Permission denied で exit 1 になることがあります。手元で /run を空の tmpfs に差し替えて uid 1000 で試したところ、正しいunitでもこのエラーで exit 1 になり、XDG_RUNTIME_DIR を設定しても変わらず、root で実行した場合だけ exit 0 でした)。 | 誤りの種類 | 出力 | オプションなしの終了コード | --recursive-errors=noの終了コード | (参考)=one | |---|---|---|---|---| | 未知のキー(Restrat=) | Unknown key 'Restrat' in section [Service], ignoring. | 0 | 1 | 1 | | 不正な値(Restart=on-failur) | Failed to parse Restart=on-failur, ignoring: Invalid argument | 0 | 1 | 1 | | 旧名のキー StartLimitInterval= を [Service] に書く | なし | 0 | 0 | 0 | | 新名の StartLimitIntervalSec= を [Service] に書く | Unknown key 'StartLimitIntervalSec' in section [Service], ignoring. | 0 | 1 | 1 | | [Service] のセクション名の誤り([Servcie]) | Unknown section 'Servcie'. Ignoring. と Service has no ExecStart=, ExecStop=, or SuccessAction=. Refusing. ほか1行 | 1 | 1 | 1 | | [Unit] / [Install] のセクション名の誤り([Unti] / [Instal]) | Unknown section 'Unti'. Ignoring.([Instal] も同様) | 0 | 1 | 1 | | ExecStart= のコマンドが存在しない | Command /opt/demo/bin/agent is not executable: No such file or directory | 1 | 1 | 1 | | Requires= の先が存在しない | オプションなし: Unit missing-dep.service not found./=no: なし | 1 | 0 | 1 | 旧名と新名で結果が逆になる 表の3行目と4行目は紛らわしいので補足します。StartLimitIntervalSec= は [Unit] セクションのキー(systemd.unit(5))なので、[Service] に置くと未知のキーとして無視されます。一方、旧名の StartLimitInterval= は互換のため [Service] でも受け付けられ、systemd v257 のソース(src/core/load-fragment-gperf.gperf.in)でも Service.StartLimitInterval は [Unit] 側と同じ設定値に割り当てられています。つまり旧名は「警告なしで通る」のが正しい挙動で、verify では誤りとして検出できません。新しく書くunitは、新名を [Unit] に書くのに統一しておくと迷いません。 修正後の good.service キーの綴りと値を直し、StartLimitIntervalSec= / StartLimitBurst= を [Unit] に置いたものです。 [Unit] Description=demo agent StartLimitIntervalSec=300 StartLimitBurst=5 [Service] ExecStart=/bin/true Restart=on-failure RestartSec=10s [Install] WantedBy=default.target $ systemd-analyze verify --man=no --recursive-errors=no units/good.service; echo exit=$? exit=0 $ systemd-analyze verify --man=no --recursive-errors=one units/good.service; echo exit=$? exit=0 何も出力されず、exit 0 です。 対策2:出力が空でなければ失敗にするラッパー(systemd 250 未満、または警告を厳しく扱いたい場合) --recursive-errors= は systemd 250 で追加されたオプションです。GitHub のホストランナーのイメージ情報(actions/runner-images、2026-10-12 版)では、ubuntu-24.04 の systemd は 255.4、ubuntu-22.04 は 249.11 と記載されています。ubuntu-latest は現時点で ubuntu-24.04 を指していますが、向き先は新しいイメージの提供にあわせて順次変わるので、バージョンに依存するならラベルを固定しておくと安心です。249 の環境ではこのオプションが使えないので、終了コードに加えて「出力が1行でもあれば失敗」と判定します。警告の文言はバージョンで変わり得るので、文言ではなく出力が空かどうかで見ています。 #!/usr/bin/env bash # systemd-analyze verify を unit ごとに実行し、終了コードが 0 以外か、出力が1行でもあれば失敗にする set -uo pipefail if [ "$#" -eq 0 ]; then echo "usage: $0 UNIT_FILE..." >&2 exit 2 fi rc=0 for f in "$@"; do out="$(systemd-analyze verify --man=no "$f" 2>&1)"; st=$? if [ "$st" -ne 0 ] || [ -n "$out" ]; then echo "NG: $f (exit=$st)"; printf '%s\n' "$out" | sed 's/^/ /'; rc=1 else echo "OK: $f" fi done exit "$rc" $ ./bin/verify-units.sh units/good.service units/typo.service units/requires.service; echo exit=$? OK: units/good.service NG: units/typo.service (exit=0) /tmp/qiita-verify/units/typo.service:6: Unknown key 'Restrat' in section [Service], ignoring. NG: units/requires.service (exit=1) requires.service: Failed to create requires.service/start: Unit missing-dep.service not found. exit=1 typo.service は verify 自体は exit 0 ですが、出力があるので NG になります。オプションを付けていないので、Requires= の欠落も拾えます。 引数が0件のまま成功してしまうと、CIでファイルの指定を間違えたときに気づけません。そのため0件は使い方を表示して exit 2 にしています。 $ ./bin/verify-units.sh; echo exit=$? usage: ./bin/verify-units.sh UNIT_FILE... exit=2 deploy/*.service のようにglobで渡して1件も一致しなかった場合は、bash の既定ではパターンがそのまま渡り、verify 側が失敗するのでこちらも NG になります。 $ ./bin/verify-units.sh deploy/*.service; echo exit=$? NG: deploy/*.service (exit=1) Failed to prepare filename deploy/*.service: Invalid argument exit=1 まとめ - systemd-analyze verify は、オプションなしでは警告があっても exit 0。マニュアルに明記された仕様です。 - systemd 250 以降は --recursive-errors=no で、未知のキー・不正な値を exit 1 にできます。 - =no では Requires= の先の欠落が出力ごと消えます(依存先を読まない実装)。依存先も見るなら =one。 - セクション名の誤りは、[Service] なら exit 1、[Unit] や [Install] なら警告扱いで exit 0。 - 旧名の StartLimitInterval= は [Service] でも有効なキーなので、verify では検出されません。 - systemd 250 未満では、出力が空でなければ失敗にするラッパーで代用します。 参考 - systemd-analyze(1): freedesktop.org - systemd.unit(5): freedesktop.org - systemd v257 src/core/load-fragment-gperf.gperf.in: github.com/systemd/systemd/blob/v257/src/core/loa… - systemd v257 src/analyze/analyze-verify-util.c: github.com/systemd/systemd/blob/v257/src/analyze/… - actions/runner-images Ubuntu 24.04: github.com/actions/runner-images/blob/main/images… - actions/runner-images Ubuntu 22.04: github.com/actions/runner-images/blob/main/images…