pull_request_target blocked: fix workflows by Dec 2
From December 2, 2026, pull_request_target is blocked, at least in public repositories where nobody has set up a matching rule. GitHub announced the change on November 2 and pushed the date back by one month at the same time. That sounds like plenty of time. However, two trial runs in which the block is briefly enforced already take place on November 9 and 16.
I care about this because pull_request_target is one of those triggers almost everyone has copied from some example at some point without really knowing what it does. That is exactly why I think the block is the right call. In this article I show you how to find affected workflows, how to decide for each one, and what the safe alternatives look like in practice, including a small scanner written in TypeScript.
> In short
>
> In public repositories, pull_request_target is blocked by default from December 2, 2026, because the trigger can bring fork code and secrets together. Most workflows can move to pull_request or be split with workflow_run, and the rest gets an explicit allowance through an Actions policy. The biggest pitfall: the trial blocks on November 9 and 16, when unchecked workflows already fail.
What you will learn in this article
- What changes on December 2 and which repositories are affected
- Why pull_request_target can lead to “pwn requests”
- How to find affected workflows with policy insights, gh and a scanner
- Moving workflows to pull_request or splitting them with workflow_run
- Allowing individual workflows through an Actions policy
pull_request_target blocked from December 2: what changes
First, the term: a trigger defines which event starts a GitHub Actions workflow. Both pull_request and pull_request_target react to pull requests. However, they differ in context. With pull_request_target, the workflow runs as if it came from the target repository itself, with its secrets and a GITHUB_TOKEN that may write. For a pull request from a fork, on the other hand, pull_request only gets a read-only token and no secrets.
To get pull_request_target blocked by default, GitHub introduces a default rule. It belongs to workflow execution protections, a kind of allowlist for who may start workflows and on which events. Here are the dates:
| Date | What happens |
| --- | --- |
| until December 2 | default rule runs in “evaluate” mode: nothing is blocked, but runs are recorded |
| November 9, 2026, 16:00–18:00 UTC | first trial block: affected runs fail with an error annotation |
| November 16, 2026, 16:00–18:00 UTC | second trial block, same behavior |
| December 2, 2026 | permanent block for pull_request_target without a matching policy |
GitHub gives the windows in UTC. In Central Europe that is 17:00 to 19:00 in November.
Who is affected
Affected are public repositories on GitHub Free, Team and Enterprise Cloud. Private and internal repositories are out of scope, and so is GitHub Enterprise Server. In addition, the default rule does not apply if you have already created your own event policy for the repository. It does not replace that policy.
Why GitHub has pull_request_target blocked
On its own, the trigger is not evil. As long as the workflow only runs code from your own repository, nothing bad happens. Under pull_request_target, actions/checkout without extra inputs also fetches only the state of the default branch. Things get dangerous when someone checks out the code from the pull request and then does something with it: npm install, make test, a build script. As a result, foreign code runs with your secrets.
What a pwn request is
This pattern has its own name: pwn request. Anyone who creates a fork can, for example, add a postinstall script to package.json, change a Makefile or tamper with a test file. If they then open a pull request and your workflow builds the code, deploy keys, npm tokens or cloud credentials end up with the attacker. In fact, several well-known attacks on open source supply chains worked exactly like this.
> 🎬 Pwn request and safe rework compared – this animated diagram only runs on my site: watch it on sascha-ms.de
What makes it tricky is that the code does not have to come through actions/checkout. A gh pr checkout in a run step, a git fetch of the PR ref or a downloaded artifact from a fork have the same effect. By the way, since version 7, actions/checkout refuses on its own to check out fork code under pull_request_target or workflow_run. If you want it anyway, you have to set allow-unsafe-pr-checkout: true. That name is deliberately uncomfortable.
Step 1: Find affected workflows
Before pull_request_target is blocked and runs start failing, you need a list. In general, there are three ways to build it, and they complement each other well. Prerequisite: admin rights in the repository or owner rights in the organization for the policy pages, otherwise just read access and the GitHub CLI for searching.
Policy insights in the settings
1. Open Settings in the repository or organization.
2. Open the Actions section in the sidebar and choose Policies.
3. Right below it you will find Policy insights. It lists the runs a rule in evaluate mode would have blocked.
On the upside, you see real runs instead of guesses. However, a workflow that has not run for weeks does not show up there. That is why a look at the code is worth it, too.
Search all repositories of an organization
With the GitHub CLI you can search all repositories of an organization for the trigger. In this example the fictional organization is called velo-verleih, a bike rental with a public app:
gh search code "pull_request_target path:.github/workflows" \
--owner velo-verleih --limit 100 \
--json repository,path \
--jq '.[] | "\(.repository.nameWithOwner) \(.path)"'
As a result, you get one line per match, with repository and file path. Code search only covers the default branch and does not return everything when there are many hits. As a first overview it is good enough, though.
Check workflows locally with a small scanner
On its own, however, a match does not tell you whether a workflow is dangerous. A labeler that only reads metadata is harmless. A preview build that checks out the PR code is not. That is why I wrote a small scanner for this distinction. First, it reads all YAML files in a folder; then it looks for the trigger and checks typical pwn request patterns.
// src/scan-workflows.ts
import { parse } from "yaml";
export type Severity = "ok" | "review" | "pwn-risk";
export interface Finding {
file: string;
severity: Severity;
reasons: string[];
}
type Step = { uses?: string; run?: string; with?: Record<string, unknown> };
type Job = { steps?: Step[]; permissions?: unknown };
type Workflow = { on?: unknown; permissions?: unknown; jobs?: Record<string, Job> };
// Expressions that point to code from the pull request (possibly a fork)
const PR_HEAD_REF =
/github\.event\.pull_request\.head\.(sha|ref|repo)|github\.head_ref|refs\/pull\//;
export function usesPullRequestTarget(on: unknown): boolean {
if (typeof on === "string") return on === "pull_request_target";
if (Array.isArray(on)) return on.includes("pull_request_target");
if (on && typeof on === "object") return "pull_request_target" in on;
return false;
}
export function scanWorkflow(file: string, source: string): Finding {
const wf = (parse(source) ?? {}) as Workflow;
if (!usesPullRequestTarget(wf.on)) {
return { file, severity: "ok", reasons: [] };
}
const reasons: string[] = ["uses pull_request_target"];
let pwnRisk = false;
if (wf.permissions === undefined) {
reasons.push("no top-level permissions block (token may have broad rights)");
}
for (const [jobName, job] of Object.entries(wf.jobs ?? {})) {
for (const step of job.steps ?? []) {
const uses = step.uses ?? "";
const withArgs = step.with ?? {};
if (uses.startsWith("actions/checkout")) {
const ref = String(withArgs.ref ?? "");
const repo = String(withArgs.repository ?? "");
if (PR_HEAD_REF.test(ref) || PR_HEAD_REF.test(repo)) {
pwnRisk = true;
reasons.push(`job "${jobName}": checkout of pull request code`);
}
if (withArgs["allow-unsafe-pr-checkout"] === true) {
pwnRisk = true;
reasons.push(`job "${jobName}": allow-unsafe-pr-checkout is enabled`);
}
}
const run = step.run ?? "";
if (/gh pr checkout|git fetch[^\n]*(pull\/|head)/.test(run)) {
pwnRisk = true;
reasons.push(`job "${jobName}": fetches pull request code in a run step`);
}
}
}
return { file, severity: pwnRisk ? "pwn-risk" : "review", reasons };
}
In addition, the command line script for it is short. It also sets an exit code, so you can use the scanner in a pipeline later:
// src/cli.ts
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { scanWorkflow } from "./scan-workflows.ts";
const dir = process.argv[2] ?? ".github/workflows";
const files = (await readdir(dir)).filter((f) => /\.ya?ml$/.test(f));
let worst = 0;
for (const name of files) {
const finding = scanWorkflow(name, await readFile(join(dir, name), "utf8"));
if (finding.severity === "ok") continue;
worst = Math.max(worst, finding.severity === "pwn-risk" ? 2 : 1);
console.log(`${finding.severity.toUpperCase()} ${name}`);
for (const reason of finding.reasons) console.log(` - ${reason}`);
}
if (worst === 0) console.log(`No pull_request_target workflows found in ${dir}.`);
// Exit code 2 = pwn risk, 1 = review needed, 0 = nothing found
process.exitCode = worst;
You need Node.js 22.18 or later, because the project runs TypeScript files directly. This is how you start it:
mkdir pull-request-target-check && cd pull-request-target-check
npm init -y && npm pkg set type=module
npm install yaml@2.9.1
mkdir src # put scan-workflows.ts and cli.ts from this article here
node src/cli.ts ~/code/fahrradverleih-app/.github/workflows
For a repository with a risky preview workflow and a harmless labeler, the output looks like this:
PWN-RISK preview.yml
- uses pull_request_target
- no top-level permissions block (token may have broad rights)
- job "preview": checkout of pull request code
REVIEW labeler.yml
- uses pull_request_target
To be fair, the scanner is a heuristic. It does not catch shell tricks across several lines or composite actions that check something out internally. For a quick assessment of a few dozen workflows it still does the job. If you want more, also enable CodeQL for Actions, which GitHub recommends for such checks.
Step 2: Decide for each workflow
Next, go through every workflow on the list one by one. The question is always the same: does this workflow really need secrets or write access while it reacts to a pull request? Most of the time the answer is no.
| What the workflow does | My recommendation |
| --- | --- |
| Tests, linting, build without secrets | move to pull_request |
| Build followed by a comment, status or upload with secrets | split: pull_request plus workflow_run |
| Set labels, post a welcome message, check metadata | keep pull_request_target and allow it explicitly |
| Preview deployment with cloud access for fork PRs | rethink it: only after manual approval, or not for forks at all |
Step 3: Move to pull_request
First of all, the simplest case is also the most common one. Many workflows only use pull_request_target because at some point someone wanted them to run for forks as well. However, pull_request does that too, just without secrets. For tests and linting that is exactly right:
name: lint
on:
- pull_request_target:
+ pull_request:
branches: [main]
permissions:
contents: read
Afterwards, check whether the workflow reads a secret anywhere. If it does, that step will fail for fork PRs from now on. Then it belongs in the privileged part from step 4.
Step 4: Split workflows with workflow_run
It gets trickier when a workflow needs both: build foreign code and then do something with privileges, such as posting a comment with the test coverage. My solution: separate these two jobs into two workflows. First, one workflow runs on pull_request, builds and tests without secrets and only stores data as an artifact. After that, a second one starts via workflow_run once the first has finished, and it has the required permissions. It reads the data but never executes code from it.
!Flow: fork PR, pr-build.yml on pull_request without secrets, artifact with data, pr-comment.yml on workflow_run
The build runs without secrets, the comment workflow only reads data.
For example, here is the unprivileged part for the bike rental:
# .github/workflows/pr-build.yml
# Runs untrusted pull request code: read-only token, no secrets
name: pr-build
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
- run: npm test -- --coverage > coverage.txt
- name: Save results as data
run: |
mkdir -p result
echo "${{ github.event.pull_request.number }}" > result/pr-number
tail -n 5 coverage.txt > result/summary.txt
- uses: actions/upload-artifact@v7
with:
name: pr-result
path: result/
retention-days: 3
The privileged part
And here is the privileged part. Watch for three details: the artifact goes to runner.temp instead of the workspace, the PR number is validated as an integer, and the comment text is only read and truncated, never executed.
# .github/workflows/pr-comment.yml
# Privileged part: may write comments, but never executes PR code
name: pr-comment
on:
workflow_run:
workflows: [pr-build]
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
comment:
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: pr-result
path: ${{ runner.temp }}/pr-result
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Validate and comment
env:
GH_TOKEN: ${{ github.token }}
RESULT_DIR: ${{ runner.temp }}/pr-result
run: |
PR=$(cat "$RESULT_DIR/pr-number")
if ! [[ "$PR" =~ ^[0-9]+$ ]]; then
echo "Invalid PR number" >&2
exit 1
fi
{
echo "### Test coverage"
echo '```'
head -c 2000 "$RESULT_DIR/summary.txt"
echo '```'
} > "$RUNNER_TEMP/comment.md"
gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/comment.md"
One important point: everything in the artifact comes from the attacker. The PR number could be forged, and the text can contain anything. Still, for a comment that is acceptable. Running a binary from the artifact, on the other hand, would be the same mistake as before, just one detour later. If you want to be completely safe, look up the PR number through the API based on the head commit instead of reading it from the artifact.
Step 5: Allow pull_request_target explicitly
Nevertheless, some workflows really need the trigger. The classic case is a labeler that tags pull requests based on the changed paths. It has to write to fork PRs, but it only reads metadata:
# .github/workflows/labeler.yml
# Legitimate use: only reads metadata, never checks out PR code
name: labeler
on:
pull_request_target:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v7
Create the event policy
For workflows like this, create your own event policy and limit it to exactly this file. In a repository it works like this:
1. Open Settings, choose Actions and then Policies in the sidebar, and create a new policy.
2. Give it a descriptive name, for example “labeler may use pull_request_target”.
3. As the target, enter only the workflow path .github/workflows/labeler.yml, not all workflows.
4. Under Restrict events, allow every event this workflow reacts to, here pull_request_target.
5. Set enforcement to Active and save. On Enterprise Cloud you can test with Evaluate first.
In particular, step four is easy to underestimate. An event policy is an allowlist: anything not listed is blocked for the targeted workflows. If your workflow also reacts to workflow_dispatch, add it to the list. You can also see existing policies in the repository through the API:
gh api repos/velo-verleih/fahrradverleih-app/actions/policies \
--jq '.policies[] | "\(.name) \(.enforcement)"'
For this you need a token with administration rights for the repository. In other words, better make the policy too narrow than too broad. A rule that allows pull_request_target for all workflows makes the new block pointless.
Step 6: Use the trial block on November 9
You can put the two trial blocks to good use. On November 9 between 16:00 and 18:00 UTC, open a pull request from a fork or a test account and watch what happens. When pull_request_target is blocked, the runs show up as failed with an error annotation, and the workflow itself does not start. After the window, workflows run again, just with warnings.
It also helps to give a short heads-up to everyone reviewing pull requests during those two hours. Otherwise someone spends an hour hunting a bug that is not one. On November 16 you repeat the test with the reworked workflows.
Common mistakes during the rework
Above all, the most common mistake is a policy that allows too much. If you enable pull_request_target for all workflows under time pressure, you will have peace on December 2 and the same hole as before. Better a workflow that does not run for a week than a deploy key that ends up with strangers.
Second, people tend to assume workflow_run is safe by design. It is not, because secrets are available there too, and if you unpack and run the artifact from the first workflow, you simply rebuild the pwn request. Third, old pull requests stay open that still contain the vulnerable workflow version. Close those PRs or ask for them to be rebased onto the current state.
A final point concerns permissions. Set a permissions block in every workflow and grant only what is really needed. Together with pinned actions from my article on the GitHub Actions lockfile, that is a solid foundation. The policy page can also enforce lockfiles, so the two topics belong closely together.
My take
Having pull_request_target blocked by default is overdue in my view. pull_request_target has been a known trap for years, and documentation alone was clearly not enough. Secure by default, with exceptions only on purpose: to me that is the right direction, whether for cloud permissions or CI pipelines.
On the other hand, I see the short lead time for small open source projects critically. If you maintain a project in your spare time, you do not read every changelog entry. For teams in companies it is the opposite: there is no excuse. Take an hour, go through the list, and you have made your pipeline safer along the way. If your team is thinking about AI agents in GitHub Actions anyway, now is a good moment to clean up the permissions of all workflows once.
FAQ
Are private repositories affected by the block?
No. The default rule only applies to public repositories on GitHub Free, Team and Enterprise Cloud. Private and internal repositories as well as GitHub Enterprise Server are not affected.
What is the difference between pull_request and pull_request_target?
For fork PRs, pull_request runs with a read-only token and without secrets. pull_request_target executes as if it belonged to the target repository, with secrets and write access, and uses the workflow file from the default branch.
What happens during the trial blocks in November?
On November 9 and 16, each from 16:00 to 18:00 UTC, affected runs fail with an error annotation and the workflows do not start. Afterwards, evaluate mode with warnings applies again.
Is upgrading to actions/checkout v7 enough?
It helps, because v7 no longer checks out fork code under these triggers by default. It does not protect against gh pr checkout, git fetch or executed artifacts, though, and it changes nothing about the block from December 2.
Your next step
Run the search from step 1 for your organization this week and put every hit into a small table with one of the four recommendations. After that you know whether November 9 affects you at all. And I am curious: did you find a workflow that really needs pull_request_target, or was it convenience all along? Let me know in the comments.
Sources
- GitHub Changelog: pull_request_target blocked by default December 2 (November 2, 2026)
- GitHub Docs: Securely using pull_request_target
- GitHub Docs: Governing execution of GitHub Actions workflows
- GitHub Docs: About Actions policies
- GitHub Docs: REST API endpoints for GitHub Actions policies
- GitHub Security Lab: Preventing pwn requests
- actions/checkout: README and action.yml (v7, allow-unsafe-pr-checkout)
- Tested with Node.js 22.23, TypeScript 7.0.2 (tsc --noEmit), yaml 2.9.1 and the Node test runner (5 tests). The workflow files were parsed for valid YAML syntax and the action inputs were checked against each action.yml. The workflows themselves were not run on GitHub.
This post was created with AI assistance.
---
👉 Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de
Give Your Coding Agent a Bundle Budget: size-limit Fails the Branch Before You Land It
- Book: Event-Driven Architecture Pocket Guide
- The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
You asked your coding agent for a debounce on the search box. It came back with seven lines and green tests. One of those lines was import _ from "lodash", and one line in package.json added lodash to get a single function.
Nothing in your test suite cares. The diff looks small. But the main lodash package has no module field and no type: "module" in its package metadata, and a default import is the whole lodash object. Webpack's tree-shaking guide puts the rule plainly: dead-code elimination "relies on the static structure of ES2015 module syntax". So the bundler keeps all of it, and every visitor downloads a utility belt to run one function.
Nobody notices until the bundle has grown by a few of these. The fix is to make bundle size a check that fails, the same way a test fails, so the agent hears about it before you do.
Step 1: put a budget on each entry point
Size Limit calls itself "a performance budget tool for JavaScript". It bundles a file with all its dependencies, minifies and compresses it (Brotli by default), and "throws an error if the cost exceeds the limit". The current release is 14.3.0, which needs Node.js 22.19+, 24.5+ or 26+. Since 14.0.0 the small-library preset bundles with rolldown (changelog).
Install it with the preset and the plugin that powers --why for that preset:
npm install --save-dev size-limit \
@size-limit/preset-small-lib \
@size-limit/rolldown-why
Then give every entry point its own budget in package.json. Here the app has a main entry and a search widget that loads on its own:
{
"scripts": {
"size": "size-limit",
"size:why": "size-limit --why"
},
"size-limit": [
{
"name": "app",
"path": "src/index.js",
"limit": "12 kB",
"message": "Do not raise this limit. Fix the import."
},
{
"name": "search",
"path": "src/search.js",
"limit": "2 kB",
"message": "Do not raise this limit. Fix the import."
}
]
}
This uses the small-lib preset because it bundles each entry straight from source, with no build step. The README recommends that preset for libraries under 10 kB; for a full app it points to @size-limit/preset-app or @size-limit/file, which measure your built files. The budget-and-gate workflow below is the same with any of them.
The numbers are examples. Run npm run size once and read the current sizes. The README suggests about a kilobyte of headroom for a small library and 25% for an app. For a gate an agent has to pass, go tighter than that: a loose budget lets several heavy imports land before it says anything.
name labels each check in the report. message is, in the README's words, "an optional custom message to display additional information", and it is the line your agent will read when the check fails, so write it for the agent. If you ship a library rather than an app, the import option measures what a consumer pays for one export, for example "import": "{ formatPrice }".
!A parcel on a weighing scale with a burnt-orange budget line, as a robotic arm lowers a heavy crate onto it
One budget per entry point, so a heavy import shows up on the check it belongs to.
Step 2: see what grew with --why
A red check tells you a budget broke. npm run size:why tells you why. With @size-limit/rolldown-why installed, size-limit --why analyses the bundle and writes an HTML report, rolldown-why.html, to the current directory. Pass --save-bundle <DIR> to put it somewhere else.
In the report for the search check, look for lodash: a default import brings the whole package along, so expect it to dwarf your few lines of widget code. That is the evidence to put in front of the agent, or in front of yourself before you accept the change.
The fix is one of two edits. A named import from lodash-es, which is published as ES modules with "sideEffects": false (package metadata), so the bundler can drop what you don't call:
import { debounce } from "lodash-es";
Or no dependency at all, because a debounce fits in a few lines:
export function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
Either way, run npm run size again.
Step 3: block the push, then block the merge
A check only helps if it runs without anyone remembering to run it. Put it in two places.
First, a pre-push hook. Git runs pre-push before every push, and per the githooks docs, "if this hook exits with a non-zero status, git push will abort without pushing anything". Husky keeps the hook in the repo, so every clone that runs npm install gets it:
npm install --save-dev husky
npx husky init
echo "npm run size" > .husky/pre-push
husky init creates a pre-commit hook and a prepare script. Keep them or edit them; the line that matters here is the pre-push file.
The hook runs on the machine that pushes, and anyone can skip it. The git-push docs say that with --no-verify, "the hook is bypassed completely". An agent with shell access can type that flag as easily as you can. So the second place is CI, where the check runs whether or not anyone remembers it:
# .github/workflows/size.yml
name: size
on:
pull_request:
push:
branches: [main]
jobs:
size:
runs-on: ubuntu-latest
env:
HUSKY: 0
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
- run: npm run size
HUSKY: 0 follows Husky's advice to keep hooks from installing on CI servers. The version tags match the current setup-node README. If you want the size change posted on each pull request as a comment, andresz1/size-limit-action does that and fails the check when a limit is exceeded; its example workflow grants pull-requests: write. To actually block the merge, mark the size job as a required status check in your branch protection rules. Without that, a red check is only a warning.
!Three gates in a row on one path: a turn checkpoint, a push checkpoint and a CI checkpoint, with a heavy crate stopped at the first
The same check at three gates: after the agent's turn, before the push, and in CI.
Step 4: hand the failure back to the agent
The pre-push hook stops the push, but by then the agent has finished and moved on. The cheaper moment is the end of the agent's turn, while it still has the change in context.
Without any tooling, that is a habit: after each task, run npm run size, and if it fails, paste the output back with a prompt that closes the easy exit:
npm run size fails on the "search" check.
Do not change the size-limit section in package.json.
Run npm run size:why, find the import that grew,
and switch to a named ES-module import or drop the
dependency. Run npm run size again until it passes.
Put the "never raise a limit" rule in the instructions file your agent reads, too. The message field repeats it in the failure output.
In Hermes IDE, your own checks can decide when a task is done, so npm test and npm run size can both sit on that list. I build Hermes, so weigh this with that in mind. The per-repository setup lives in a worktree recipe; the recipe docs show how to list the checks. Whichever agent you run, Claude Code or another, the prompt above is what you send back when the size check fails.
Then read the change before it lands. The Review Desk (⌘G, Ctrl+Shift+G on Windows and Linux) shows everything the agent changed, by turn or by file, with risk flags. A new dependency shows up as a package.json and package-lock.json change in the turn that added it, and the package.json line shows exactly which package came in. If the agent quietly raised a limit instead of fixing the import, that is one line in the same diff. A line comment there goes back to the agent that wrote the line.
!The Hermes Review Desk in By turn view, with two turns, a diff of src/db.ts and a drafted commit message
The Review Desk (⌘G) in By turn view: two turns listed, turn 1's diff of src/db.ts, the changed-files list, and a commit message drafted from the turns.
Once the size check and the tests pass, Land commits, opens a pull request or merges locally in one step, into the branch the task started from, and it can be undone. CI still runs the same npm run size on the pull request.
What a budget won't catch
A size budget measures bytes. It won't tell you a dependency is unmaintained, has a bad license or pulls in a postinstall script; those need their own review, which is why reading the lockfile change matters as much as the number.
It also only measures what you point it at. If an agent adds a new entry point, it has no budget until you add one, so add "new entry point means a new size-limit section" to the same instructions file.
Hermes IDE is free to download, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Download it, and if it helps you catch a heavy import before it lands, star it on GitHub.
---
If this was useful
A bundle budget is a small version of a bigger habit: decide what "done" means before work starts, and make a check enforce it. If your agents end up running as workers off a queue instead of in your terminal, the Event-Driven Architecture Pocket Guide covers the retries, idempotency and outbox patterns that keep that kind of system honest. It is one of 8 short books in the Pocket Guides for Developers series, on agents, RAG, prompts, observability, events, databases and system design: the whole series is here.

dbt v2はどこで動かす?GitHub Actions・Workflows・Airflowの使い分け
はじめに
こんにちは、青木です!
dbt World Tour Tokyoも開催され、dbtがだいぶ普及してきた印象があります。
皆さんdbtを使うなら、まず試すのはdbt v2(旧dbt Core)ですよね?
そのときに、「どこでどう動かすのがいいんだろう」と迷ったことはありませんか。
dbt v2は手元でも試せますが、チームで定期的に動かすなら、実行する場所も考えたいところです。
GitHub Actions、Workflows + Cloud Run Jobs、Airflow。候補はありますが、何を基準に選べばよいかは悩むところです。
個人的には、Google Cloudでdbtを動かすなら、Workflows + Cloud Run Jobsをまず検討します。
処理の順序を管理でき、監視もGoogle Cloudの仕組みにそろえやすいからです。
詳細は後ほど説明します!
まず小さく試すならGitHub Actions、すでにAirflowを運用しているならその基盤を使うのもよい選択です。
dbt単独の定期実行なら、Cloud SchedulerからCloud Run Jobsを直接起動するだけでも始められます。
次のような方を想定しています。
BigQueryでdbtを使い始めたい方
チームで定期実行する環境を用意したい方
既存の実行基盤を、再実行や費用も含めて見直したい方
dbt platform(旧dbt Cloud)のスケジューラ、Dagster、Dataformは扱いません。
コード例と詳しい説明は、各構成の節の折りたたみにまとめています。
まずは構成の違いをつかむ
比較表には、3構成に加えて、dbt単独の定期実行に使えるCloud Scheduler + Cloud Run Jobsも載せています。
構成
利点
運用上の注意点
向いているチーム
GitHub Actions
コードと実行をGitHubにまとめ、少ない構成で始められる
定期起動の遅延への備え、対象期間を指定する再実行の仕組み
GitHubで運用をまとめたいチーム
Cloud Scheduler + Cloud Run Jobs
dbtの定期実行をGoogle Cloudの少ないサービスで始められる
起動と実行結果の監視は別。前後処理の連携は自分たちで用意する
dbt単独のバッチを動かしたいチーム
Workflows + Cloud Run Jobs
常設のAirflow環境を持たず、Google Cloudの処理をつなげられる
コンテナの配布、サービス間の権限、過去分の再処理の仕組み
Google Cloudで処理をつなぎ、コンテナを管理できるチーム
Airflow(Cloud Composer)
日付ごとの履歴や再実行を、取り込みと一緒に管理できる
環境の維持費、DAGや実行環境の保守
既存のAirflowがある、または日付ごとの再処理をまとめて管理したいチーム
最初に見るのは、すでに使っている基盤、処理のつながり、失敗時の確認・再実行の3つです。
同じサービスでも、新しく導入する場合と、使い慣れた既存環境へ追加する場合では、運用にかかる手間や費用が変わります。
dbtが動く場所と、実行を管理する場所
どの構成でも、dbtがモデルのSQLをBigQueryに送り、集計やテーブルの作成はBigQuery側で行います。
変わるのは、dbtをどこで動かし、その実行をどう管理するかです。
WorkflowsはCloud Run Jobsを呼び出し、そこでdbtを動かします。
AirflowはDAGで実行を管理し、dbtはworker上でも、外部のCloud Run Jobsでも動かせます。
GitHub Actions
利点
dbtのコードがGitHubにあるなら、workflowを追加して定期実行を始められます。
GitHub-hosted runnerを使えば、実行用のサーバーを自分たちで維持する必要がありません。
コードの修正と実行結果を、普段使っているGitHubで確認できるのが手軽です。
立ち上げ直後のスタートアップなどで、まずはdbtを動かしてみたいなら、新しい運用基盤を増やさずに始められます。
料金は公開リポジトリの標準runnerなら無料で、プライベートリポジトリはプランに応じた無料枠と実行時間で変わります。
運用上の注意点
schedule は混雑時に遅れたり、ジョブがキューから落ちたりする場合があります。
決まった時刻に必ず起動する前提では組めないため、更新の締切に間に合うかを考えます。
schedule だけでは、失敗したdbtを自動リトライしてくれません。
この例では失敗したジョブを手動で再実行します。
自動リトライが必要なら、workflowに別途組み込む必要があります。
過去7日分を作り直すなら、対象期間を入力で受け取り、dbtへ渡す仕組みも必要です。
ジョブの依存関係や条件分岐はworkflowに書けます。
外部処理の完了確認や複雑な連携が増えると、Pythonなどのスクリプトも含めて、自分たちで実装・保守する範囲が広がります。
こういうときに選ぶ
GitHub中心で運用し、まずは独立したdbtのバッチを少ない構成で始めたいチームに向いています。
起動の遅れを許容でき、再実行の手順もチームで扱えるなら、有力な候補です。
データを使う人が増え、「朝9時までに更新してほしい」「取り込みが終わってから動かしたい」といった要望が出てきたら、実行基盤を見直すタイミングです。
Google Cloud上の取り込みや監視とまとめて運用したくなったら、次のWorkflows + Cloud Run Jobsを検討します。
GitHub Actions:定期実行のworkflow例
コード例はdbt v2の 2.0.9 向けです。
BigQueryへの接続設定と必要な権限を用意したdbtプロジェクトを前提にしています。
接続設定はdbtのBigQuery接続ガイド、GitHub ActionsからGoogle Cloudへの認証設定はgoogle-github-actions/auth の資料を参照してください。
dbtはcurlでインストールし、バージョンを固定します。
BigQueryに繋ぐドライバは初回の実行時にダウンロードされるので、actions/cacheでキャッシュしておくと毎回取りに行かずに済みます。
.github/workflows/dbt-daily.yml
name: dbt-daily
on:
schedule:
- cron: "0 6 * * *" # 毎朝6時(JST)
timezone: "Asia/Tokyo"
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: dbt-daily-prod
cancel-in-progress: false
jobs:
dbt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: google-github-actions/auth@v3
with:
workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github
service_account: dbt-runner@sample-project.iam.gserviceaccount.com
- name: Cache dbt drivers
uses: actions/cache@v6
with:
path: ~/.cache/com.getdbt
key: dbt-drivers-${{ runner.os }}-${{ runner.arch }}-2.0.9
- name: Install dbt
run: |
curl -fsSL public.cdn.getdbt.com | sh -s -- --update
~/.local/bin/dbt system update --version 2.0.9
- name: Check BigQuery connection
run: ~/.local/bin/dbt debug --profiles-dir .
- name: dbt build
run: ~/.local/bin/dbt build --profiles-dir .
schedule のcronはデフォルトではUTCですが、timezone に Asia/Tokyo を指定すればJSTのまま書けます。
workflow_dispatch も付けておくと、画面から手動でも起動できて便利です。
キャッシュはOS・CPUアーキテクチャ・dbtのバージョンごとに分けています。
concurrency は、同じ出力先を更新するジョブが同時に走らないようにする設定です。
実行中のジョブを途中で止めないよう、cancel-in-progress は false にしています。
GitHub-hosted runnerの1ジョブの実行時間は最大6時間です。
workflowはデフォルトブランチに置く必要があり、公開リポジトリでは60日間活動がないと定期実行が無効になります。
定期実行の通知はcronを最後に変更したユーザーに届くため、チーム用の通知先も決めておくと運用しやすいです。
Workflows + Cloud Run Jobs
利点
dbtはCloud Run Jobsのコンテナで動かし、Workflowsが処理の順序や完了待ちを管理します。
常設のAirflow環境を用意せずに、Google Cloudのサービスをつなげられます。
GitHub Actionsと同じようにYAMLをGitで管理でき、処理の順序や分岐をレビューしながら運用できます。
図のように取り込みの完了を待てるので、取り込みとdbtの起動時刻をずらして調整せずに済みます。
実行履歴から、どの処理で止まったかもまとめて追えます。
Cloud Monitoringには実行状況のメトリクスが送られます。
アラートポリシーとSlackなどの通知先を設定すれば、失敗をチームへ知らせることもできます。
Google Cloudのほかの処理と監視の仕組みをそろえられるのも、私がこの構成を推す理由です。
Cloud Scheduler
dbtだけを定期実行するなら、Cloud SchedulerからCloud Run Jobsを直接起動する構成も選べます。
前後の処理をつなぎ、完了待ちや通知までまとめたくなったら、Workflowsを加える理由になります。
運用上の注意点
コンテナのビルド・配布と、サービス間の権限を管理します。
また、過去分の対象日を列挙して流し直す仕組みや、どこまで再処理できたかを確認する方法は自分たちで用意します。
Workflowsは実行ステップ数、Cloud Run Jobsはコンテナの実行時間やCPU・メモリに応じて課金されます。
常設のAirflow環境の費用をかけずに始められますが、Cloud Schedulerやイメージの保存なども含めて見積もります。
定期起動にはCloud Schedulerを使います。
更新の締切がある場合は、予定した時刻に起動したかに加えて、締切までにdbtが完了したかを確認する仕組みも用意します。
こういうときに選ぶ
取り込みからdbt、後続の処理までをGoogle Cloud上でつなぎたいチームに向いています。
データを使う人や運用する人が増え、実行結果や失敗時の対応をチームで管理したくなったときに、私ならまず検討する構成です。
コンテナの配布や権限の管理に慣れているなら、既存の運用にも合わせやすいです。
Workflows + Cloud Run Jobs:コンテナの配布と完了待ち
BigQueryへの接続設定を用意したdbtプロジェクトを、コンテナに含める構成例です。
BigQueryへのアクセスにはCloud Run Jobsに設定したサービスアカウントを使います。
1. dbtプロジェクトをコンテナに入れる
ビルド時にdbt system install-driversでドライバまでイメージに入れておきます。
こうしておくと、ジョブが起動するたびにドライバを取りに行かなくて済み、起動も安定します。
Dockerfile
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL public.cdn.getdbt.com | sh -s -- --update \
&& /root/.local/bin/dbt system update --version 2.0.9 \
&& /root/.local/bin/dbt system install-drivers
ENV PATH="/root/.local/bin:${PATH}"
WORKDIR /app
COPY . .
ENTRYPOINT ["dbt"]
CMD ["build", "--profiles-dir", "."]
COPY . . に認証情報や不要なファイルが入らないよう、ビルド前に .dockerignore を用意します。
.dockerignore
.git
.env
.env.*
gha-creds-*.json
target
logs
2. ジョブを作る
Artifact Registryの dbt リポジトリと、実行用サービスアカウント dbt-runner は作成済みとします。
必要なAPIとビルド・配布の権限を用意してから、イメージをビルドしてジョブを作ります。
Cloud Run Jobsのタスクのタイムアウトはデフォルトで10分なので、dbtの実行時間に合わせて --task-timeout で延ばしておきます。
失敗したときのリトライ回数を決めるのは --max-retries です。
gcloud builds submit \
--project sample-project \
--tag asia-northeast1-docker.pkg.dev/sample-project/dbt/dbt:2.0.9
gcloud run jobs deploy dbt-daily \
--project sample-project \
--image asia-northeast1-docker.pkg.dev/sample-project/dbt/dbt:2.0.9 \
--region asia-northeast1 \
--service-account dbt-runner@sample-project.iam.gserviceaccount.com \
--tasks 1 \
--task-timeout 1h \
--max-retries 1
--tasks 1 はdbtプロジェクトを1つのタスクで動かす指定です。
--max-retries 1 ではdbt全体を1回やり直すため、途中まで更新されたモデルを再実行しても問題がないかを確認しておきます。
3. Workflowsから起動して、完了を待つ
WorkflowsのCloud Run Admin APIコネクタでジョブを実行すると、ジョブが終わるか失敗するまでWorkflowsが待ってくれます。
この待ち時間はデフォルトで30分までなので、dbtが30分を超えそうなら connector_params の timeout を延ばしておきます。
workflow.yaml
main:
steps:
- run_dbt:
call: googleapis.run.v2.projects.locations.jobs.run
args:
name: projects/sample-project/locations/asia-northeast1/jobs/dbt-daily
body: {}
connector_params:
timeout: 10800 # 起動とリトライを含め、3時間まで待つ
connector_params.timeout は、Workflowsが完了を待つ時間です。
Cloud Run Jobsのタスクのタイムアウトとは別なので、起動やリトライも含めて待てる長さにします。
この例では1時間のタスクに1回のリトライを許しているため、起動時間も含めて3時間にしています。
取り込みからdbtとSlack通知までつなぐなら、2つのジョブの実行をtry/exceptで包みます。
WebhookのURLはSecret Managerに置き、accessStringで読み出してhttp.postで送ります。
通知のあとに raise でエラーを投げ直すので、実行は失敗のままです。
WorkflowsのサービスアカウントにはSecret Managerのシークレットアクセサー(roles/secretmanager.secretAccessor)を付けておきます。
なお、呼び出しのログを log-all-calls にしていると、accessString で読み出したWebhookのURLがそのままログに残ります(公式ドキュメントの警告)。
取り込み → dbt → Slack通知をつなぐworkflow.yaml
取り込み用の load-orders ジョブは作成済みとします。
Workflowsのサービスアカウントに付けるロールと範囲は、次の手順の表にまとめています。
取り込みが失敗したらdbtへ進まず、両方成功したときだけ完了通知を送ります。
workflow.yaml
main:
steps:
- get_webhook_url:
call: googleapis.secretmanager.v1.projects.secrets.versions.accessString
args:
project_id: sample-project
secret_id: slack-webhook-url
result: webhook_url
- run_pipeline:
try:
steps:
- load_orders:
call: googleapis.run.v2.projects.locations.jobs.run
args:
name: projects/sample-project/locations/asia-northeast1/jobs/load-orders
body: {}
connector_params:
timeout: 10800
- run_dbt:
call: googleapis.run.v2.projects.locations.jobs.run
args:
name: projects/sample-project/locations/asia-northeast1/jobs/dbt-daily
body: {}
connector_params:
timeout: 10800
except:
as: e
steps:
- notify_failure:
call: http.post
args:
url: ${webhook_url}
body:
text: ${"注文データの取り込みかdbtが失敗しました。" + e.message}
- fail:
raise: ${e}
- notify_success:
call: http.post
args:
url: ${webhook_url}
body:
text: "注文データの取り込みとdbtが完了しました。"
Webhook URLの取得に失敗した場合は、取り込みを始めずに終了します。
この場合はSlackへ通知できないため、Workflows自体の失敗を拾うCloud Monitoringのアラートも設定してください。
4. Workflowsを配布して、Cloud Schedulerから定期起動する
起動には用途の異なるサービスアカウントを使います。
dbtがBigQueryに接続する権限と、ジョブを起動する権限を分けて考えると整理しやすいです。
サービスアカウント
役割
必要なアクセス
scheduler
Workflowsを定期起動する
roles/workflows.invoker
workflow-runner
Cloud Run Jobsを起動し、完了を確認する
対象ジョブに roles/run.invoker、プロジェクトに roles/run.viewer
dbt-runner
コンテナ内のdbtを動かす
dbtに必要なBigQueryへのアクセス
Cloud Run Invokerにはジョブを起動する run.jobs.run、Cloud Run Viewerには完了待ちに使う run.operations.get が含まれます。
この例では load-orders と dbt-daily にInvokerを付け、Viewerはプロジェクトに付けます。
Secret Managerの roles/secretmanager.secretAccessor は、Webhook URLを保存したシークレットに付けます。
Workflowsはサービスアカウントを指定して配布します。
上記のサービスアカウントと権限を用意したら、次のように設定します。
gcloud workflows deploy dbt-daily \
--project sample-project \
--location asia-northeast1 \
--source workflow.yaml \
--service-account workflow-runner@sample-project.iam.gserviceaccount.com
最後に、Cloud SchedulerからWorkflowsを起動します。
タイムゾーンに Asia/Tokyo を指定して、毎朝6時(JST)に動かします。
gcloud scheduler jobs create http dbt-daily \
--project sample-project \
--location asia-northeast1 \
--schedule "0 6 * * *" \
--time-zone "Asia/Tokyo" \
--uri "workflowexecutions.googleapis.com" \
--http-method POST \
--headers "Content-Type=application/json" \
--message-body '{}' \
--oauth-service-account-email scheduler@sample-project.iam.gserviceaccount.com
Cloud Schedulerの起動成功と、dbtの実行成功は別です。
dbtまで成功したかはWorkflowsとCloud Run Jobsの実行結果で確認します。
Cloud Schedulerは少なくとも1回の配信を行う仕組みなので、同じ予定時刻に複数回起動される場合があります。
同じ対象日を処理し直しても重複データを作らないようにします。
Airflow(Cloud Composer)
Airflowの利点は、取り込みからdbt、その後の通知までを、一連のワークフローとして管理できることです。
利点
こうした処理の順序や依存関係を、DAGというワークフローに定義します。
どの取り込みで止まったのか、dbtまで進んだのかを同じ画面で確認でき、失敗したタスクの再実行や過去期間の再処理もまとめて管理できます。
すでにAirflowで取り込みを管理しているなら、その後ろにdbtを組み込めるのが便利です。
Workflowsでも処理はつなげられますが、Airflowは複数のデータパイプラインを、日付ごとの実行履歴や再処理まで含めて運用したいときに向いています。
用途の違いはGoogle Cloudの比較資料にもまとまっています。
運用上の注意点
Cloud Composerには環境の稼働時間に応じた費用がかかり、DAGが動いていない時間も環境の費用が発生します。
環境の更新や、DAG・パッケージの保守も必要です。
こういうときに選ぶ
すでにAirflowを運用していて、取り込みからdbtまでの流れを同じ基盤にまとめたいチームに向いています。
使い慣れた監視や再実行の手順も生かせます。
dbtの定期実行だけのためにAirflowを新設するなら、私はまずWorkflows + Cloud Run Jobsを検討します。
dbt単独ならSchedulerから直接起動する構成、小さく試す段階ならGitHub Actionsも候補です。
Airflowを新設する場合は、複数のパイプラインや再処理をまとめて管理する必要があり、その便利さが費用と保守の手間に見合うかで判断します。
Airflow:dbtの実行場所の違いとDAG例
dbtを動かす場所は、Cloud Run JobsとAirflowのworker(タスクを実行する環境)の2通りで考えてみます。
どちらも、取り込みの完了を待ってdbtを動かす順序はAirflowのDAGで管理します。
DAG例はAirflow 3向けです。
Cloud Run Jobsで動かす
図の上側はDAGからCloudRunExecuteJobOperatorでCloud Run Jobsを呼ぶ構成です。
コンテナと dbt-daily ジョブはWorkflowsの節と同じものを使え、Composer側にdbtを入れる必要はありません。
dbt_daily_cloud_run.py
import pendulum
from airflow.providers.google.cloud.operators.cloud_run import CloudRunExecuteJobOperator
from airflow.sdk import DAG
with DAG(
dag_id="dbt_daily_cloud_run",
schedule="0 6 * * *",
start_date=pendulum.datetime(2026, 11, 1, tz="Asia/Tokyo"),
catchup=False,
max_active_runs=1,
) as dag:
dbt_build = CloudRunExecuteJobOperator(
task_id="dbt_build",
project_id="sample-project",
region="asia-northeast1",
job_name="dbt-daily",
deferrable=True,
timeout_seconds=10800,
retries=0,
)
ジョブが成功したら、後続のタスクへ進みます。
deferrable=True では、待機中の状態確認をTriggererが担うため、workerの実行枠を使い続けずに済みます。
利用するComposerイメージのGoogle providerの対応と、Triggererの設定を確認します。
Composer側にはジョブの実行・状態参照の権限を付け、BigQueryへの接続はCloud Run Jobs側のサービスアカウントで行います。
retries=0 とし、Airflowの再試行で同じジョブを二重に起動するのを避けています。
ジョブ内の再試行はCloud Run Jobs側で設定し、手動で流し直すときも元の実行状態を先に確認します。
dbtの依存関係や実行リソースをComposerから分けたい場合に、この構成を検討します。
GoogleのDAG作成ガイドも、CPUやメモリを多く使う処理をAirflowの構成要素から分離する方針を示しています。
Airflowのworker上で直接動かす
図の下側はworkerにdbtを入れ、BashOperatorで dbt build を実行する構成です。
コンテナの配布を増やさずに済みますが、dbtの依存関係や必要なメモリはComposer側で管理します。
Composer環境のPyPIパッケージに dbt==2.0.9 を追加し、BigQueryへの接続設定を用意したプロジェクトを dags/dbt/ に置く例です。
ドライバは初回実行時に取得されます。
ComposerのサービスアカウントにBigQueryへのアクセス権限を付け、Python・Airflowと追加パッケージの依存関係を合わせておきます。
dbt_daily.py
import pendulum
from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import DAG
with DAG(
dag_id="dbt_daily",
schedule="0 6 * * *",
start_date=pendulum.datetime(2026, 11, 1, tz="Asia/Tokyo"), # 毎朝6時(JST)
catchup=False,
max_active_runs=1,
default_args={"retries": 1},
) as dag:
dbt_build = BashOperator(
task_id="dbt_build",
bash_command="""
set -euo pipefail
DBT_WORK_DIR="$(mktemp -d /tmp/dbt.XXXXXX)"
trap 'rm -rf "$DBT_WORK_DIR"' EXIT
cp -R /home/airflow/gcs/dags/dbt/. "$DBT_WORK_DIR/"
cd "$DBT_WORK_DIR"
dbt build --profiles-dir .
""",
)
/tmp に作った作業ディレクトリは終了時に削除するので、run_results.json などが必要なら、その前にCloud Storageなどへ保存します。
Cosmosを使うと、dbtのリネージ(モデル間の依存関係)をAirflowのDAGに反映し、モデルごとの実行結果を確認できます。
この記事のdbt v2(旧dbt Core)をworker上で使うなら、Cosmos 1.11.0以降の ExecutionMode.LOCALを使います。
ExecutionConfig の invocation_mode に InvocationMode.SUBPROCESS、dbt_executable_path にdbtの実行ファイルへのパスを指定します。
dbtのコマンドを別プロセスで動かす方式です。
DAGの生成にも dbt ls を使う場合は、RenderConfig にも invocation_mode=InvocationMode.SUBPROCESS を指定します。
Slackへの成功・失敗通知はDAGのコールバックにSlackNotifierなどを設定できます。
チームで無理なく運用できるか
同じ構成でも、チームが使い慣れているかで、導入や運用にかかる手間は変わります。
GitHub Actionsに慣れているのか、すでにAirflowを運用しているのかも、選ぶときの判断材料になります。
失敗したときにログを確認し、コードを直して再実行するところまで、運用するメンバーと一緒に考えてみてください。
例えば、コンテナの配布を別チームに頼む必要があるなら、その依頼も復旧の手順に含めます。
データを使う人とは更新の締切や許容できる遅れを、基盤を管理する人とは権限や費用の条件を確認します。
おわりに
選ぶときに持ち帰ってほしいのは、次の3点です。
小さく試すならGitHub Actions。Google Cloudで動かすなら、私はWorkflows + Cloud Run Jobsをまず検討します。dbt単独ならSchedulerからの直接起動も候補です。
すでにAirflowを使っているなら、その基盤を生かすのもありです。新設するなら、日付ごとの履歴や再処理をまとめて管理する便利さと、費用・保守の手間を比べます。
最初に動かす手間だけでなく、失敗したときの確認・再実行まで、チームで扱えるかを考えます。
まずは、チームに合いそうな構成で小さく試してみてください。
この記事がdbtをどこで動かすか迷ったときの参考になれば嬉しいです!
補足:コード例を試すときの権限・認証
dbt v2では、通常のクエリ実行・データ読み書きに加え、Storage Read APIの権限も必要です。
実行用サービスアカウントへ、BigQuery Read Session User(roles/bigquery.readSessionUser)をプロジェクト単位で付与します。
鍵なし認証を使う場合は、実際にdbtを動かす各環境で dbt debug --profiles-dir . を実行し、BigQueryへ接続できるか確認してください。
GitHub ActionsでTerraformのCIを改善する
はじめに
これまで、GitHub Actionsを使ってTerraformの品質チェックやセキュリティチェックを自動化する方法を試して見ました!
これまでに書いた記事は以下の2つです。
GitHub ActionsとTrivyでTerraformの設定ミスをPR時に検出する
GitHub ActionsでTerraformの品質チェックを自動化する
これまで作成したWorkflowをさらに改善するために、以下の3つに取り組みました。
Reusable WorkflowsによるWorkflowの再利用
Path FilterによるWorkflowの実行条件の制御
Terraform Provider CacheによるProviderのキャッシュ
1. Reusable WorkflowsでWorkflowを再利用する
これまでのWorkflowでは、Terraformの品質チェックを実行する処理を1つのYAMLファイルにまとめていました。
しかし、今後セキュリティチェックやテストなどを追加していくことを考えると、Workflowごとに処理を分離しておく方が管理しやすいかなと思いました。
そこで、Reusable Workflowsを利用してみました!
Reusable Workflowsは、別のWorkflowから呼び出して再利用できる仕組みです。
今回の構成では、以下のように役割を分けています。
呼び出し側:Workflowを実行する条件を定義
呼び出される側:Terraformの品質チェックを実行
呼び出し側のWorkflow
.github/workflows/pr-check.yml
name: Terraform PR Check
on:
pull_request:
paths:
- '**/*.tf'
permissions:
contents: read
jobs:
terraform-quality:
uses: ./.github/workflows/terraform-quality.yaml
uses に呼び出したいWorkflowのパスを指定します。
これにより、Terraformの品質チェック処理を別のWorkflowに切り出すことができます。
呼び出される側のWorkflow
.github/workflows/terraform-quality.yaml
name: Terraform Quality
on:
workflow_call:
permissions:
contents: read
jobs:
terraform-quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
- name: Check formatting
run: terraform fmt -check -recursive
- name: Initialize Terraform
run: terraform init -backend=false
- name: Validate Terraform
run: terraform validate
呼び出される側では、on: workflow_call を指定します。
これにより、別のWorkflowから呼び出せるようになります。
Terraformの品質チェック処理を独立させることで、今後ほかのWorkflowからも再利用しやすい構成にしました。
2. Path FilterでWorkflowの実行条件を制御する
次に、Workflowが実行される条件を見直しました。
Pull Requestを作成するたびにTerraformの品質チェックを実行するのではなく、Terraformファイルに変更があった場合だけ実行するようにします。
そこで、呼び出し側のWorkflowに以下を設定しました。
on:
pull_request:
paths:
- '**/*.tf'
paths を指定することで、対象となるファイルに変更があった場合のみWorkflowが実行されます。
今回の設定では、.tf ファイルが変更されたPull Requestが対象になります。
実際に動作を確認したところ、WorkflowのYAMLファイルだけを変更した場合は実行されず、Terraformファイルを変更した場合は実行されることを確認できました。
これにより、Terraformに関係のない変更で不要なCIが実行されることを抑えられます。
3. Terraform Providerをキャッシュする
次に、Terraform Providerのキャッシュを導入してみました。
Terraformでは、terraform init を実行すると、必要なProviderがダウンロードされます。
毎回ダウンロードが発生するため、Providerをキャッシュして再利用できるようにします。
今回は、Terraformの環境変数 TF_PLUGIN_CACHE_DIR とGitHub Actionsの actions/cache を利用しました。
Provider Cacheの設定
まず、Jobの環境変数にキャッシュ先を指定します。
env:
TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache
TF_PLUGIN_CACHE_DIR は、Terraform Providerのキャッシュ先を指定するための環境変数です。
次に、WorkflowのStepにキャッシュ処理を追加します。
- name: Create Terraform plugin cache directory
run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
- name: Cache Terraform providers
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.terraform-plugin-cache
key: ${{ runner.os }}-terraform-providers-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-terraform-providers-
それぞれの設定について説明します。
path
キャッシュ対象のディレクトリを指定します。
今回は、TF_PLUGIN_CACHE_DIR で指定したディレクトリと同じ場所にしています。
key
キャッシュを識別するためのキーです。
今回は、実行環境のOSと .terraform.lock.hcl のハッシュ値を組み合わせています。
ロックファイルの内容が変わるとハッシュ値も変わるため、Providerの依存関係に変更があった場合に、別のキャッシュキーを利用できます。
restore-keys
完全に一致するキャッシュが見つからなかった場合に、前方一致するキャッシュを探すための設定です。
今回はOSとProviderキャッシュのプレフィックスを指定しています。
Workflow全体
最終的なReusable Workflowは以下のようになりました。
name: Terraform Quality
on:
workflow_call:
permissions:
contents: read
jobs:
terraform-quality:
runs-on: ubuntu-latest
env:
TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
- name: Create Terraform plugin cache directory
run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
- name: Cache Terraform providers
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.terraform-plugin-cache
key: ${{ runner.os }}-terraform-providers-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-terraform-providers-
- name: Check formatting
run: terraform fmt -check -recursive
- name: Initialize Terraform
run: terraform init -backend=false
- name: Validate Terraform
run: terraform validate
キャッシュの復元処理は terraform init より前に実行する必要があります。
これにより、Terraformの初期化時にキャッシュされたProviderを利用できるようになります。
4. Provider Cacheの動作を確認する
設定したキャッシュが実際に動作するか、GitHub Actions上で確認しました。
今回は、同じPull RequestでWorkflowを2回実行し、キャッシュの保存と復元を確認しています。
1回目の実行
初回実行ではキャッシュが存在しないため、Providerのダウンロードが行われます。
実行ログでは、キャッシュが見つからないことを示すメッセージが出力されました。
Cache not found for input keys:
その後、Workflowの最後に以下のログが出力されました。
Cache saved with key: ...
これにより、Providerのキャッシュが保存されたことを確認できました。
2回目の実行
次に、同じPull Requestで Re-run all jobs を実行しました。
2回目の実行では、以下のログが出力されました。
Cache restored successfully
これにより、1回目の実行で保存したキャッシュを復元できることを確認できました。
今回の検証では、キャッシュの保存と復元の両方を確認できました。
なお、キャッシュの復元によってProviderの再ダウンロードを抑制できる構成にはなりましたが、実行時間がどの程度短縮されたかについては、まだ比較検証できていません。ただ、Providerのダウンロードを省略できる分、実行時間の短縮につながると考えています!
また、GitHub ActionsのキャッシュはPull Requestの実行スコープに関係するため、今回は同じPull Requestで再実行して確認しています。
5. まとめ
今回は、GitHub ActionsでTerraformのCIを改善するために、以下の3つを実装しました。
Reusable WorkflowsによるWorkflowの共通化
Path Filterによる実行条件の制御
Terraform Provider CacheによるProviderのキャッシュ
実際に設定して動作を確認することで、それぞれの仕組みについて理解を深めることができました。
特に、Provider Cacheについては、設定するだけでなく、GitHub Actionsの実行ログからキャッシュの保存と復元まで確認できたのがよかったです。
引き続き、GitHub ActionsやTerraformを使いながら、CI/CDの改善について学んでいきたいと思います。
参考
GitHub Actionsの再利用可能なワークフロー
GitHub Actionsのワークフロー構文
GitHub Actionsの依存関係キャッシュ
actions/cache
Terraform fmt
Terraform init
Terraform validate
hashicorp/setup-terraform
Playwright再入門:Claude Codeでテストを残し、増やし、GitHub Actionsで回し、MCP・CLIと使い分ける
Claude Code で画面を作っていて、ブラウザを動かすテストをまだ持っていないなら、この記事はその入口です。
Playwright(ブラウザを自動で動かす Microsoft の OSS)で書かせて残したテストは、AI なしで1秒前後で回ります。画面が変わると赤くなり、止まった所まで出ます。
AI に頼むのは、テストを書く・直すときと、テストの無い画面をその場で見るときだけで足ります。
テストを残す・増やす・GitHub Actions で回す・MCP と CLI で見させる、の4つを1画面の小さなアプリで順に試します。どの段にも公開サンプルがあります。
!
この記事の内容は動画(全4本)でも解説しています。下は1本目です。
目次:目的から各回を選ぶ
目的から、見たい回へ飛んでください。
テストを作って保つ
#1 テストを1本書かせて残す:画面が変わると AI なしで赤くなる
#2 Test Agents で増やす:計画を絞って書かせ、6本の赤を1行で直させる
人が走らせなくても回す
#3 GitHub Actions で回す:push だけでテストが走り、トレースで原因が分かる
テスト以外の使い方を選ぶ
#4 MCP と CLI:同じ確認を4通りで比べ、残すか見させるかを選ぶ
前提と試した環境
要る物は次のとおりです。
Node.js(公式の導入ページの要件は「latest 22.x, 24.x or 26.x」)
Claude Code(#1・#2・#4)
GitHub のアカウント(#3)
確認環境と、試していないこと
#1・#2・#4 は、Linux arm64・Node.js 22.22.1・Playwright 1.64.0 で試しました。
回
確認日
試した環境
#1
2026-10-10
Chrome for Testing 156・Claude Code 2.1.296
#2
2026-10-11
Claude Code 2.1.296
#3
2026-10-18
GitHub Actions の ubuntu-24.04 ランナー(手元は Linux)
#4
2026-10-18
Chrome なし・画面なし・Claude Code 2.1.302
秒数は途中の許可を聞かれない auto mode の値で、頼むたびに変わります。Mac と Windows では試していません。#4 は本体同梱の npx playwright mcp だけで、@playwright/mcp@latest と Chrome のある端末は試していません。
#1 テストを1本書かせて残す:画面が変わると AI なしで赤くなる
Playwright には 1.56 から Playwright Test Agents が入っています。計画を書く planner、テストを書く generator、失敗したテストを直す healer の3つです。Claude Code からはサブエージェントとして呼べます。私が試したかったのは、AI の確認を使い捨てにせず、テストとして残す形です。
実験台の「買い物メモ」。品物を足してチェックすると、下の「残り N 品」が減る
サンプルでテスト1本を走らせる
zun-tools/playwright-sample は、設定まで済ませた完成形です。
git clone zun-tools/playwright-sample
cd playwright-sample
npm install
npx playwright install chromium
npx playwright test
1 passed になります。走ったテストの全文です。
tests/shopping.spec.js
// @ts-check
import { test, expect } from '@playwright/test';
test('品物を足してチェックすると、残りが減る', async ({ page }) => {
await page.goto('/');
await page.getByLabel('品物').fill('牛乳');
await page.getByRole('button', { name: '追加' }).click();
await page.getByLabel('品物').fill('卵');
await page.getByRole('button', { name: '追加' }).click();
await expect(page.getByRole('listitem')).toHaveCount(2);
await expect(page.getByRole('status')).toHaveText('残り 2 品');
await page.getByRole('checkbox', { name: '牛乳' }).check();
await expect(page.getByRole('status')).toHaveText('残り 1 品');
});
操作は fill・click・check、確かめは expect です。
空のフォルダに2コマンドで入れ、設定を足す
npm init playwright@latest -- --quiet --browser=chromium --lang=js --no-examples
npx playwright init-agents --loop=claude
2つ目で .claude/agents/ に3つのエージェントが、.mcp.json に MCP サーバ(playwright-test)が入ります。サンプルの index.html を置き、設定に3か所足します。
playwright.config.js(足した所)
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'localhost',
},
webServer: {
command: 'npx http-server -p 8080 -s .',
url: 'localhost',
reuseExistingServer: true,
},
tests/seed.spec.ts の // generate code here. は、await page.goto('/'); に替えます。seed は、エージェントが最初に走らせるテストです。設定を足さずに頼んだ回は、generator がページを開けず、画面を操作しないままテストを書きました。
generator を名指しして書かせる
フォルダで claude を開くと MCP サーバを使うか聞かれます。カーソルは最初「Continue without using this MCP server」にあります。上へ2回動かし、「Use this MCP server」を選びます。
playwright-test-generator エージェントを使って、買い物メモ(index.html)のテストを書いて。計画書は無いので、品物を2つ足して、チェックすると残りが減ることを確かめるテストにして
generator はブラウザで操作を通してから、テストを1本書き出しました(51秒〜1分3秒・4回)。読んで合っていれば、そのまま残します。名指しせず「テストを書いて」と頼んだ回は、サブエージェントが使われませんでした。
ボタン名を変えて、AI なしで走らせる
index.html のボタン名を「追加」から「入れる」に変え、npx playwright test だけを打ちます。
✘ 2 …s/shopping-memo/add-and-check-items.spec.ts:7:7 › 買い物メモ › 品物を2つ追加してチェックすると残りが減る (30.0s)
Error: locator.click: Test timeout of 30000ms exceeded.
Call log:
- waiting for getByRole('button', { name: '追加' })
赤になるまで30秒かかりました。Playwright は操作できるまで待ち、テスト1本の時間切れ(既定30秒)で赤にします。use に actionTimeout: 5000 を足すと、5.1〜5.3秒で赤になりました(既定では30.0〜30.1秒・各3回)。
止まった瞬間の画面は、トレースで見られます。
npx playwright test --trace on
npx playwright show-report
actionTimeout: 5000 で走らせたトレースの一部。「追加」を探して5秒待った Click が赤。そのときのページのボタンは「入れる」
healer にテストを直させる
ボタン名をわざと変えたのなら、直すのはテストの側です。
テストが失敗してる。playwright-test-healer エージェントで直して
テストのボタン名2か所が「入れる」に変わり、再実行は緑でした(42〜58秒・4回)。コメントに古い「追加」が残った回もあります。アプリの1行を消して頼んだ別の日の1回(2026-10-09・Claude Code 2.1.295)は、test.fixme を足して止まりました。スキップは結果の上では緑に見えるので、直した後も読みます。
#2 Test Agents で増やす:計画を絞って書かせ、6本の赤を1行で直させる
#1 の設定を済ませた所から始めます。増やした後の状態は zun-tools/playwright-agents-sample にあり、AI なしで赤まで試せます。
planner に計画を5つまで書かせる
playwright-test-planner エージェントで、買い物メモのテスト計画を作って。seed は tests/seed.spec.ts。tests/shopping.spec.js にテストが1本あるので、それと同じ筋書きは除いて、大事な順に5つまで
約1分で specs/ に日本語の計画(Markdown)ができました。テストを書く前に、何を確かめるかを読めます。絞らずに頼むと、21 と 23 シナリオでした(2回)。
generator に計画から書かせる
playwright-test-generator エージェントで、specs/shopping-memo.plan.md の計画からテストを書いて
計画の1項目がテスト1本になり、5本で2分半〜3分半でした。AI なしで走らせると 7 passed、約1秒です。手書きの1本と seed を足した7本になります。緑になったら、計画とテストをコミットしておきます。
表示を1か所変えると、6本がまとめて赤
表示を「残り 1 / 2 品」に変えます。playwright-agents-sample の display-change.patch を git apply すると当たります。自分で変えるなら、index.html の次の3行です。
index.html
- <p id="left" role="status">残り 0 品</p>
- <p id="left" role="status">残り 0 / 0 品</p>
function updateLeft() {
const n = list.querySelectorAll('input[type=checkbox]:not(:checked)').length;
- left.textContent = 残り ${n} 品;
- const all = list.querySelectorAll('input[type=checkbox]').length;
- left.textContent = 残り ${n} / ${all} 品;
}
緑は seed だけ。「残り」を確かめる6本が、1本あたり5秒ちょっとで赤
エラーには Expected: "残り 0 品" と Received: "残り 0 / 0 品"、テストの行番号が出ます。1本が5秒ちょっとで赤になるのは、expect が既定で5秒まで待つからです。
1行頼んで直させ、差分を読む
テストが失敗してる。playwright-test-healer エージェントで直して
約1分で、テスト6ファイルの期待値が新しい表示に直りました。index.html は触られず、再実行は 7 passed です(2回)。
全体の数が行ごとに違うので、一括の置き換えでは書けない
赤にして期待と実際を言葉で出すまでが Playwright で、直したのは Claude Code です。
計画は古いまま残る。 直した後も specs/ は旧表示のままでした。「計画(specs/)も今の表示に合わせて直して」と頼むと、19〜31秒で直りました(3回)
アプリ側の変更が戻されることがある。 アプリを壊して頼んだ4回で、テストを壊れた値に合わせた回は0回でした。ただし3回は、聞かれないまま未コミットの変更が元に戻され、1回は戻してよいか聞いて止まりました。頼む前にコミットしておけば、git diff で取り返せます
#3 GitHub Actions で回す:push だけでテストが走り、トレースで原因が分かる
打ち忘れた日も赤が出るよう、push のたびに GitHub Actions で走らせます。サンプル zun-tools/playwright-ci は、#1 のサンプルに package-lock.json と workflow を足した物です。雛形からは、Chromium だけ入れ、アクションの版を v6 に上げました。
.github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
自分のリポへ push して緑を見る
git clone zun-tools/playwright-ci
cd playwright-ci
npm ci
gh repo create <自分の名前>/playwright-ci --public
git remote set-url origin github.com<自分の名前>/playwright-ci.git
git push
Actions のタブに緑が付きます。push から緑まで55秒と38秒でした。ubuntu-latest は 2026-10-19 から Ubuntu 26.04 へ順に移る、と GitHub が告知しています。ubuntu-26.04 を指定しても、緑2回・赤1回で同じ形でした。
ボタン名を変え、テストを打たずに push する
index.html のボタン名を「入れる」に変えて、そのまま push します。
git commit -am "ボタン名を変える"
git push
テストのコマンドは打たず、push から130秒で ✗ が付きました。
Actions の一覧。ボタン名を変えたコミットだけが ✗ になり、戻すと緑に戻った
赤が長いのは、CI では再試行が2回入る(retries: process.env.CI ? 2 : 0)からです。30秒待つ赤が3回続きます。gh run view <番号> --log-failed でも、待っていた操作とテストの8行目が出ます。
知らせは設定しだいです。試し用の公開リポでは、失敗のときだけ通知とメールの両方が届きました(2026-10-11)。サンプルの上の赤では、メールは届いていません(2026-10-18)。
自分のリポで受け取るなら、Settings → Notifications の Actions の欄で Email を確かめます。一覧の ✗ は、知らせが無くても残ります。
レポートを落とし、再試行1回目のタブからトレースを開く
gh run list
gh run download <番号> -n playwright-report -D ci-report
npx playwright show-report ci-report
-D を付けないと gh run download は失敗します。
失敗したテストのページには Run・Retry #1・Retry #2 の3つのタブがあり、トレースは Retry #1 にだけあります(雛形の trace: 'on-first-retry')。
最初の Run のタブにはトレースが無い。Retry #1 を開く
GitHub の上で走ったテストの、止まった瞬間の画面。ボタンは「入れる」になっている
うっかりならボタン名を戻し、わざとならテストを直します。レポートには画面がそのまま写るので、秘密の入る画面は扱いに気をつけてください。
#4 MCP と CLI:同じ確認を4通りで比べ、残すか見させるかを選ぶ
Playwright の README は、使い道に「テスト・スクリプト・AI エージェントの道具」の3つを挙げています。AI 向けには、本体に MCP サーバと CLI(playwright-cli)が入っています。同じ確認を4通りでやって並べました。確認は「牛乳と卵を足し、牛乳にチェックすると『残り 1 品』になるか」です。サンプルは zun-tools/playwright-scripts-sample です。
git clone zun-tools/playwright-scripts-sample
cd playwright-scripts-sample
npm install
npx playwright install chromium
npm run serve # 別の端末で開いたままにする
テストとスクリプト:先に書いた物を AI なしで走らせる
1通り目は npx playwright test で、#1 と同じテスト1本です。2通り目は、テストの枠と expect を外した12行のスクリプトです。
scripts/check.mjs
// 買い物メモの定型確認(テストランナーを使わない Playwright ライブラリのスクリプト)
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('localhost');
for (const name of ['牛乳', '卵']) {
await page.getByLabel('品物').fill(name);
await page.getByRole('button', { name: '追加' }).click();
}
await page.getByRole('checkbox', { name: '牛乳' }).check();
console.log(await page.getByRole('status').textContent());
await browser.close();
node scripts/check.mjs は、合否ではなく読んだ表示(「残り 1 品」)を出します。スクショや PDF にも同じ書き方が使えます。
MCP と CLI:何も書かずに AI に見させる
既定では手元の Chrome を使いますが、Chrome の無い Linux では入れ方を案内されて止まりました。そこで Playwright が入れた Chromium を指しました。MCP は flag を2つ、CLI はサンプルの .playwright/cli.config.json で指しています。
MCP(Chrome の無い Linux で足した形)
claude mcp add --scope project playwright -- npx playwright mcp --headless --executable-path <Chromium の場所>
CLI(使い方を教えるスキルをフォルダに入れる)
npx playwright cli install --skills
<Chromium の場所> は、npx playwright install --dry-run chromium の「Install location」の下の chrome-linux*/chrome です。
公式の説明では、1.62 から MCP サーバと playwright-cli は本体に同梱されています。
頼む文は次のとおりです。MCP のときは、最後の1文を「ブラウザの操作には playwright MCP のツールを使うこと。」に変えます。
localhost の買い物メモで、品物「牛乳」と「卵」を追加してから「牛乳」にチェックを入れると、画面の表示が「残り 1 品」になるか確かめてください。最後に「はい/いいえ」と根拠を1行で答えてください。ブラウザの操作には playwright-cli(このフォルダでは npx playwright cli)を使うこと。
CLI で頼んだ1回目。スキルを読んでからコマンドを打ち、28秒で「はい」と答えた
答えは4回とも「はい」でした。MCP は道具を1つずつ呼び、CLI はスキルを読んでからコマンドをまとめて打ちます。
4通りを並べて選ぶ
npm run serve のページは開いたままにしました。AI の2つは1回ごとに Claude Code を開き直し、「AI の往復」は道具やコマンドを呼んだ回数です。
やり方
先に書く物
出る物
かかった時間
AI の往復
テスト
テスト1本
合否(1 passed)
0.92・0.87秒
0回
スクリプト
12行
読んだ表示「残り 1 品」
0.43・0.41秒
0回
MCP
なし
「はい」と根拠
18・25秒
6・7回
CLI
なし
「はい」と根拠
28・26秒
5・6回
表の秒数は、ほかの作業が走っていないときの値です。選ぶ基準は桁の差で、残す2つは1秒かからず AI 0回。見させる2つは、頼むたびに20秒前後かかります。
MCP と CLI の間は、速さでは選びません。公式は、playwright-cli は Claude Code などのコーディングエージェントに向き、MCP は状態を持ち続ける長い作業に向くと書いています。
使い所は、表から次のように選べます。
明日も同じ確認をする → テストに残す。2回目も1秒かからず、変わったら赤で知らせる
確かめではない作業(PDF・定型の入力) → スクリプト
テストのまだ無い画面を、いま一度だけ見る → AI に見させる。Claude Code なら、まず CLI
見させた確認を何度も使う → テストへ移す(#1 の generator)
まとめ
generator は操作を通してテストを書き、残したテストは AI なしで回る。画面が変わると止まった所を出して赤になる(既定30秒・actionTimeout で約5秒)
計画を絞って書かせればテストは増え、6本の赤も1行の頼みで直る。頼む前にコミットし、後で差分と計画を読む
workflow を足せば push のたびに走り、赤は約2分で ✗ になる。トレースは再試行1回目のタブにある
MCP と CLI は書かずに見させる道具で、毎回時間がかかる。繰り返す確認はテストに残す
まずは #1 のサンプルで緑を見て、「追加」を「入れる」に変えてもう一度走らせてください。
動画で見たい方はこちら(全4本):
#1 テストを書かせて残す
#2 Test Agents で増やし、直させる
#3 GitHub Actions で回す
#4 MCP と CLI を使い分ける
参考リンク
microsoft/playwright(公式リポジトリ)
Playwright 公式 docs: Playwright Test Agents
Playwright 公式 docs: Installation
Playwright 公式 docs: Locators
Playwright 公式 docs: Auto-waiting
Playwright 公式 docs: Timeouts
Playwright 公式 docs: Trace viewer
Playwright 公式 docs: Continuous Integration
Playwright 公式 docs: Trace viewer intro
Playwright 公式 docs: Library
Playwright 公式 docs: Getting started - MCP
Playwright 公式 docs: Getting started - CLI
GitHub Docs: GitHub Actions の料金
GitHub Docs: Actions の通知の受け取り方
Claude Code 公式 docs: Permission modes
Fix "APNS token has not been set yet" in Flutter on iOS
---
title: Fix "APNS token has not been set yet" in Flutter on iOS
published: true
date: 2026-11-05 00:00:00 UTC
tags: flutter,ios,firebase,dart
canonical_url: saymyframe.com
---
On iOS, Firebase delivers push notifications through Apple's push service (APNs), so FirebaseMessaging.instance.getToken() refuses to run until Apple has given the device its APNs token. Called earlier, it throws apns-token-not-set, which older versions of firebase_messaging describe as "APNS token has not been set yet". Usually the app just asked too early. How it should wait depends on the version: firebase_messaging 17 tells the app when Firebase is ready, and 16.7 doesn't. In a few setups, though, Apple never answers, however long the app waits.
I checked everything below on an iPhone 17 simulator with iOS 26.5, Xcode 27.2 and Flutter 3.44.2, with firebase_messaging 16.7.1 (Firebase iOS SDK 12.19.3) and 17.0.1 (Firebase iOS SDK 13.1.1).
APNS token has not been set yet, or has not been received on the device yet
The error comes in two wordings. Older versions say:
[firebase_messaging/apns-token-not-set] APNS token has not been set yet. Please ensure the APNS token is available by calling `getAPNSToken()`.
Since October 2025 (firebase_messaging 16.0.3, with firebase_messaging_platform_interface 4.7.3), the same error says:
[firebase_messaging/apns-token-not-set] APNS token has not been received on the device yet. Please ensure the APNS token is available before calling `getAPNSToken()`.
Both are a FirebaseException with the code apns-token-not-set, so check the code rather than the text. The hint to call getAPNSToken() is misleading, because that call never throws. It returns null until Apple has answered, which makes it the thing to wait on.
The check runs in Dart, before anything reaches iOS. getToken(), deleteToken(), subscribeToTopic() and unsubscribeFromTopic() all start with it. So a topic subscription at startup fails with the same error, while requestPermission() and onMessage work. If you use Firebase from Swift, the native SDK reports the same situation as No APNS token specified before fetching FCM Token.
Why getToken() fails right after the app starts
At launch, the plugin asks iOS to register the app for remote notifications, and iOS answers later, in application:didRegisterForRemoteNotificationsWithDeviceToken:. The Firebase iOS SDK keeps that answer in memory only. So an early getToken() fails on every launch, not just the first one, even when Firebase gave the app its FCM token on the previous launch.
How long the answer takes varies: about half a second on a simulator that had been running for a while, and about 30 seconds right after the simulator booted. On a phone, it depends on the network.
I checked this with a small app that calls getToken() right after Firebase.initializeApp(), after a fresh install and after a restart, with both versions. The call failed every time, although Apple answered within a second. What differs between the versions is whether the app learns that Firebase is ready.
firebase_messaging 16: wait for getAPNSToken()
With 16.7.1, the Firebase iOS SDK requests the FCM token by itself as soon as Apple answers (the log shows the app connecting to fcmtoken.googleapis.com), but the plugin doesn't pass it on to Dart: onTokenRefresh stayed silent for the 20 seconds I waited, after a fresh install and after a restart. So the app has to wait for getAPNSToken() itself, then call getToken():
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
/// The FCM token of this device once Apple has answered, or `null` when
/// Apple hasn't within [limit].
Future<String?> pushTokenWhenReady({
Duration limit = const Duration(minutes: 2),
}) async {
final messaging = FirebaseMessaging.instance;
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
var wait = const Duration(milliseconds: 500);
var waited = Duration.zero;
while (await messaging.getAPNSToken() == null) {
if (waited >= limit) return null;
await Future<void>.delayed(wait);
waited += wait;
if (wait < const Duration(seconds: 8)) wait *= 2;
}
}
return messaging.getToken();
}
Don't await it before runApp(). I found apps on GitHub that opened to a white screen on iOS for exactly this reason: main() waited, getToken() threw, and runApp() never ran. Start the app first and send the result to your server when it arrives:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const MyApp());
final token = await pushTokenWhenReady();
if (token != null) await sendTokenToServer(token);
}
If the wait runs out, try again when the user comes back to the app, for example from an AppLifecycleListener with onResume.
firebase_messaging 17: listen to onTokenRefresh
Since 17.0.0, released in October 2026, the plugin becomes the delegate of the Firebase iOS SDK during Firebase.initializeApp(), so onTokenRefresh gets two events that 16 lost. After a fresh install, it fires as soon as the SDK has the FCM token, in my runs 1.4 seconds after Apple answered. On every later launch, it fires right at startup with the value that the SDK saved last time: 0.2 seconds after main() started, before Apple had answered and while getToken() still threw.
So on 17, subscribe to onTokenRefresh right after initializeApp(), and keep the getToken() call for Android, where it doesn't depend on Apple:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
final messaging = FirebaseMessaging.instance;
messaging.onTokenRefresh.listen(sendTokenToServer);
runApp(const MyApp());
try {
final token = await messaging.getToken();
if (token != null) await sendTokenToServer(token);
} on FirebaseException catch (e) {
// On iOS before Apple has answered: onTokenRefresh delivers it.
if (e.code != 'apns-token-not-set') rethrow;
}
}
Your server may get the same value twice this way, so it should accept one that it already has. Subscribe early, because onTokenRefresh is a broadcast stream and a listener that comes after the startup event never sees it. If your code subscribes later, after a sign-in for example, wait for getAPNSToken() as with 16.
Firebase is also moving Cloud Messaging from registration tokens to the Firebase Installation ID (FID). Version 17 deprecates getToken(), onTokenRefresh and deleteToken() in favor of register(), onRegistered and unregister(), and flutter analyze exits with code 1 on the code above because of the deprecation infos. I haven't tried the FID path yet, so this article stays with getToken().
When the APNs token never comes
If getAPNSToken() still returns null after a minute, waiting longer won't help. These are the cases I know of.
No aps-environment entitlement
iOS registers an app for remote notifications only when the build is signed with the aps-environment entitlement, which Xcode adds with the Push Notifications capability in Signing & Capabilities. Without it, the Xcode console shows:
[FirebaseMessaging][I-FCM012002] Error in application:didFailToRegisterForRemoteNotificationsWithError: no valid "aps-environment" entitlement string found
The simulator follows the same rule. A build made with flutter build ios --simulator --no-codesign doesn't get the entitlement, and mine waited two minutes for Apple in vain.
A free Apple team
A personal team can't sign aps-environment. To run the app on an iPhone, you have to remove the capability, and then Apple never answers there. The simulator doesn't need a team, since Xcode signs simulator builds ad hoc.
The iOS simulator on GitHub Actions
The iOS simulator on a Mac gets a real APNs token. On a macOS virtual machine it never does, and the macOS runners of GitHub Actions are virtual machines. I tried a macos-26 runner: the app was built with the entitlement, nothing came in four minutes, and the push daemon logged no token info found in keychain before it disconnected. actions/runner-images#12747 reports the same for macos-15. A CI job on a hosted runner can check that the app starts without push notifications, but not that it gets them.
firebase_messaging 16.5.0 and 16.6.0
These two versions skipped the registration for remote notifications at launch in two common setups: when Firebase is initialized from Dart, as with Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) (flutterfire#18643), and in apps with scenes (flutterfire#18555), which the iOS template of current Flutter creates with SceneDelegate.swift. getAPNSToken() then stayed null on every launch. Version 16.7.0 fixed both. Check which version your app has:
flutter pub deps | grep firebase_messaging
Auto-init is off
With FirebaseMessagingAutoInitEnabled set to NO in Info.plist, or after setAutoInitEnabled(false), the plugin doesn't register for remote notifications. Call setAutoInitEnabled(true) before you start waiting.
Not the APNs key
The APNs key in the Firebase console doesn't belong on this list. Firebase needs it to send messages through Apple, so without it nothing arrives, but getAPNSToken() and getToken() work all the same.
How SMF handles push notifications on iOS
The firebase_messaging module of SMF, a free, open-source Flutter project generator, gives the app the address of the device as appPush.address, which stays null until Firebase has one. The first frame never waits for it. On iOS, the module polls getAPNSToken() as above, waiting from half a second up to eight seconds between tries, for at most two minutes, and asks again whenever the user comes back to the app. It adds aps-environment and the background mode remote-notification, and the README of the app explains what to do with a free team. Uploading the APNs key stays a step you do by hand. SMF's CI starts the apps on the iOS simulator of a hosted runner, where they have to start and work without an address.
初めてのCI/CD。チェックが失敗したままマージしても、公開は止められた(GitHub Actions)
1. はじめに
前回の記事では、GitHubにpushすると、GitHub ActionsがCloudflare Workersへ自動で公開するしくみを作りました。
ただ、このしくみには弱点があります。mainにpushすると、中身に間違いがあっても、そのまま公開されてしまいます。
今回の検証は小さな静的ページなので、それでも大きな問題にはなりません。でも、仕事で扱う大きな動的ページでは、そうはいきません。公開する前に、自動で確かめるしくみが必要だと思いました。
そこで今回は、公開の前にHTMLをチェックして、間違いがあれば公開しないようにしました。さらに、わざとHTMLを壊して、本当に公開が止まるのかを確かめています。
イベント・on:・ランナー・既定値の説明は、前回の記事に書いたので省略します。この記事では、前回と違うところだけを説明します。
なお、記事の内容は、断りがない限り自分の環境で動かして確かめていますが、CI/CDは初心者です。間違いなどがあれば、コメントで教えていただけるとうれしいです。
2. 作ったもの
プルリクエストを作るとチェックだけが動き、mainにマージするとチェックのあとに公開されるしくみです。
flowchart TD
PR["プルリクエストを作る<br>ブランチにpushする"] --> E1["pull_request イベント"]
E1 --> C1["check.yml が単独で動く"]
C1 --> R1["緑か赤を見せるだけ<br>公開はしない"]
M["マージする<br>mainにpushする"] --> E2["mainへのpush イベント"]
E2 --> D["deploy.yml が動く"]
D --> C2["check ジョブ<br>check.yml を呼ぶ"]
C2 -- "成功" --> S["deploy ジョブ<br>Cloudflareへ公開"]
C2 -- "失敗" --> X["deploy は実行されない<br>公開中のページはそのまま"]
check.ymlには入口が2つあります。プルリクエストの道は、マージする前に結果を知るためのものです。mainの道は、公開の前の関所です。
3. 手順
環境は前回と同じです。無料プランのPrivateリポジトリを使っています。チェックはGitHub Actionsの中で動くので、自分のPCにNode.jsは入れていません。
ファイルは2つ足し、1つ変えました。
tobaru-net-test/
├─ .htmlvalidate.json … チェックのルール(新規)
└─ .github/
└─ workflows/
├─ check.yml … チェックの指示書(新規)
└─ deploy.yml … チェックを呼ぶように変更
(1) チェックのルールを決める
HTMLのチェックにはhtml-validateを使いました。ルールを自分で作らなくても、できあいのrecommendedがあります。
.htmlvalidate.jsonです。
{
"extends": ["html-validate:recommended"]
}
(2) check.ymlを書く
name: Check
on:
# mainに向けたプルリクエスト
pull_request:
branches: [main]
# ほかのワークフローから uses: で呼ばれたとき
workflow_call:
jobs:
html:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# ランナーにファイルを取り出す
- uses: actions/checkout@v5
# public/ の中の .html を調べる
- name: Validate HTML
run: npx --yes html-validate@11.18.0 public/
(3) deploy.ymlを変える
変えたのは、checkジョブの追加とneeds: checkだけです。
jobs:
# 公開の前の関所。中身は check.yml
check:
uses: ./.github/workflows/check.yml
deploy:
# check が成功したときだけ動く
needs: check
runs-on: ubuntu-latest
# これ以降は前回と同じ
4. 試してみた
結果を先に書くと、チェックが赤のとき、マージは止まりませんでしたが、公開は止まりました。
| やったこと | 結果 |
|---|---|
| そのままのページでプルリクエストを作る | 赤 |
| 指摘されたところを直して、同じブランチにpush | 緑 |
| 緑のままマージ | check→deployと動き、公開された |
| </main>を消して、赤のままマージ | マージはできたが、deployは実行されず、公開中のページは変わらなかった |
(1) 壊す前に、もう赤になった
ブランチをpushしただけでは、何も動きません。check.ymlの条件はプルリクエストで、deploy.ymlの条件はmainへのpushだからです。プルリクエストを作ると、Check / htmlが動き始めました。
!01_pr-running.png
わざと壊すのは、この次の予定でした。ところが、何も壊していないのに赤になりました。
!02_check-failed.png
ログを開くと、原因が書いてあります。
!03_validate-log.png
index.htmlの1行目の<!doctype html>が、小文字だと指摘されています。ブラウザでは正しく表示されていますが、recommendedのdoctype-styleというルールは大文字を求めます。
(2) 直してpushすると、緑になった
<!DOCTYPE html>に直して、同じブランチにpushしました。プルリクエストを作り直さなくても、チェックは自動で動き直します。
!04_check-passed.png
(3) 緑のままマージすると、公開された
マージするとmainへのpushが起きて、deploy.ymlが動きます。checkのあとにdeployが動き、公開中のページも<!DOCTYPE html>に変わりました。
!05_graph-success.png
(4) 赤のままマージしても、公開は止まった
いよいよ本番です。別のブランチで</main>を消して、プルリクエストを作りました。チェックは赤になりましたが、Merge pull requestのボタンは押せます。
!06_failed-but-mergeable.png
あえてマージすると、checkが赤になり、deployはスキップされました。0秒なのは、一度も始まっていないからです。
!07_graph-skipped.png
このときmainのコードからは</main>が消えていましたが、https://test.tobaru.netには</main>が残っていました。コードは壊れたが、公開中のページは守られたということです。
検証のあとは、壊した変更をgit revertで取り消して、もう一度プルリクエストからマージしました。チェックは緑で、公開も通っています。
5. なぜ公開が止まるのか
公開を止めているのは、deploy.ymlのneeds: checkです。画面の設定は何も変えていません。
flowchart TD
A["html-validate が間違いを見つける"] --> B["終了コード1で終わる"]
B --> C["check ジョブが失敗になる"]
C --> D["needs: check の条件を満たさない"]
D --> E["deploy ジョブはスキップ"]
E --> F["wrangler が動かない"]
F --> G["Cloudflare に何も届かない"]
G --> H["公開中のページはそのまま"]
(1) 赤と緑は、何で決まるの?
コマンドの終了コードで決まります。0なら成功、0以外なら失敗です。
4章のログの最後にも、Process completed with exit code 1.とあります。Actionsは「DOCTYPE should be uppercase」という文章を読んでいるのではありません。公式ドキュメントにも、ステップの成功と失敗は終了コードに基づいて報告されると書かれています(Workflow syntax)。
つまり、間違いがあるときに0以外で終わるコマンドなら、何でもチェックとして使えます。
(2) needs:がないと、どうなるの?
checkとdeployが同時に動くので、チェックが赤でも公開されるはずです(ここは試していません)。
ジョブは既定で並列に動きます。上から順に書いてあっても、順番はつきません。needs:を書くと、次の決まりが効きます。
> If a job fails or is skipped, all jobs that need it are skipped
必要としているジョブが失敗すると、そのジョブはスキップされます。この決まりのおかげで、deployが赤を判断する処理は一切書いていません。
(3) なぜcheck.ymlを別のファイルにしたの?
プルリクエストとmainの両方で、同じチェックを使いたかったからです。
ただ、needs:は同じファイルの中のジョブしか指定できません。そこでcheck.ymlのon:にworkflow_callを書いて、ほかのワークフローから呼べるようにしました(公式ドキュメント)。deploy.ymlのcheckジョブは、それをuses:で呼んでいるだけです。
deploy.ymlの中にジョブとしてcheckがあるので、needs: checkと書けます。
プルリクエストのときのチェックと、マージのときのチェックは別の実行です。公開を止めているのは、マージのときの結果です。
(4) なぜマージは止まらなかったの?
無料プランのPrivateリポジトリでは、マージを止めるルールが効かないからです。
GitHubには、チェックが緑にならないとマージできなくする設定があります。ただ、SettingsのRulesetsを開くと、次の警告が出ていました。
!08_rulesets-warning.png
古い形式のBranch protection ruleも同じです。ルールは作れますが、リポジトリをTeamなどの組織アカウントに移すまで適用されません。
!09_branch-protection-warning.png
だから、止めているものを分けて考える必要があります。
| しくみ | 止めるもの | 今回の環境 |
|---|---|---|
| Rulesets・Branch protection rule | マージ | 効かない |
| needs: check | 公開 | 効く |
needs:では、mainのコードが壊れることは防げません。防げるのは公開だけです。それでも、ワークフローのファイルに書くだけなので、無料プランでも使えます。
6. 前回と違うところ
しくみ自体は前回と同じで、イベントが起きたらon:に合うワークフローが動きます。初めて出てきたものだけを説明します。
(1) pull_requestは、いつ動くの?
types:を書かなければ、opened・synchronize・reopenedの3つで動きます(公式ドキュメント)。
| 名前 | いつ |
|---|---|
| opened | プルリクエストを作った |
| synchronize | ブランチに新しいcommitをpushした |
| reopened | 閉じたプルリクエストを開き直した |
4章の(2)で、pushしただけでチェックが動き直したのはsynchronizeのおかげです。これも前回と同じ、書かなかったときの既定値です。
(2) uses:のactions/checkout@v5は何?
持ち主/リポジトリ@バージョンの形で、GitHub上で公開されている部品を借りる書き方です。actions/checkoutは、GitHubが公開しているactions/checkoutのことです。
前回のcloudflare/wrangler-action@v4も、同じ形です。
(3) なぜcheckoutが必要なの?
ランナーは毎回新しく用意され、リポジトリのファイルは入っていないからです。この行がないと、調べるpublic/がありません。
プルリクエストのときに取り出されるのは、ブランチの先頭ではありません。actions/checkoutは既定で、イベントの対象のcommitを取り出します(action.yml)。pull_requestの場合、それはrefs/pull/番号/mergeです。
つまり、「mainにマージしたらこうなる」という仮のcommitを調べています。ブランチ単体では問題がなくても、マージ後に壊れる場合に気づけます。
(4) run:は何?
ランナーのシェルでコマンドを実行する書き方です。今回のコマンドはこれだけです。
npx --yes html-validate@11.18.0 public/
| 部分 | 意味 |
|---|---|
| npx | npmのパッケージを取ってきて実行する。package.jsonがなくても動く |
| --yes | インストールの確認に答える人がいないので、先に「はい」と渡す |
| @11.18.0 | バージョンを固定する。新版のルール追加で、ある日突然赤にならないように |
| public/ | 調べるフォルダ。中の.htmlをすべて調べる |
プルリクエストで変更したファイルだけを調べるのではありません。実際、4章の(1)の赤は、プルリクエストで変更していないindex.htmlで出たものです。
今回は小さなページ1つなので、全部調べても問題ありません。規模が大きくなって時間がかかるようになったら、変更したファイルだけに絞ることも検討が必要かもしれません。
Node.jsを入れる手順はありません。GitHubのランナーには最初からNode.jsが入っています。
7. ほかの言語のチェックツール
今回はHTMLだけですが、言語が変わってもチェックの入れ方は同じです。run:に書くコマンドが変わるだけです。
| 対象 | ツール | チェックするもの |
|---|---|---|
| HTML | html-validate(今回使った) | 文法・書き方 |
| HTML | HTMLHint | 書き方 |
| HTML | lychee | リンク切れ |
| JavaScript / TypeScript | ESLint | 書き方 |
| TypeScript | tsc | 型 |
| Python | Ruff | 書き方 |
| Python | mypy | 型 |
| 共通 | Prettier | 整形 |
| 共通 | gitleaks | コードに混ざったパスワードなど |
こうしたツールには、今回のrecommendedのようにできあいのルールが用意されているものが多くあります。自分で作り込む必要があるのは、アプリの動きを確かめるテストの中身くらいです。
8. まとめ
プルリクエストの時点で間違いに気づけて、うっかりマージしてしまっても、公開中のページは守られます。この2つを実際に確かめられたので、今回の検証は成功だと思います。
今回分かったことを並べると、次の3つです。
- 赤と緑を決めるのは、コマンドの終了コード。Actionsは出力の文章を読んでいない
- ジョブは既定で並列に動く。needs:を書くと、相手が失敗したときにスキップされる
- 無料プランのPrivateリポジトリではマージは止められない。それでも、公開はneeds:で止められる
次は、プルリクエストを作るとAIがレビューしてくれるしくみを試す予定です。
@edgarrmondragon And thank you for the PR that unblocked 3.16! 🚀
1 like
初めての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
macOS runners are vulnerable to the eMudhra intermediate CA compromise · Issue #15228 · actions/runner-images : 👀
---
runner-images#15228
Mentions
pull_request_target blocked: fix workflows by Dec 2 From December 2, 2026, pull_request_target is blocked, at least in public repositories where nobody has set up a matching rule. GitHub announced the change on November 2 and pushed the date back by one month at the same time. That sounds like plenty of time. However, two trial runs in which the block is briefly enforced already take place on November 9 and 16. I care about this because pull_request_target is one of those triggers almost everyone has copied from some example at some point without really knowing what it does. That is exactly why I think the block is the right call. In this article I show you how to find affected workflows, how to decide for each one, and what the safe alternatives look like in practice, including a small scanner written in TypeScript. > In short > > In public repositories, pull_request_target is blocked by default from December 2, 2026, because the trigger can bring fork code and secrets together. Most workflows can move to pull_request or be split with workflow_run, and the rest gets an explicit allowance through an Actions policy. The biggest pitfall: the trial blocks on November 9 and 16, when unchecked workflows already fail. What you will learn in this article - What changes on December 2 and which repositories are affected - Why pull_request_target can lead to “pwn requests” - How to find affected workflows with policy insights, gh and a scanner - Moving workflows to pull_request or splitting them with workflow_run - Allowing individual workflows through an Actions policy pull_request_target blocked from December 2: what changes First, the term: a trigger defines which event starts a GitHub Actions workflow. Both pull_request and pull_request_target react to pull requests. However, they differ in context. With pull_request_target, the workflow runs as if it came from the target repository itself, with its secrets and a GITHUB_TOKEN that may write. For a pull request from a fork, on the other hand, pull_request only gets a read-only token and no secrets. To get pull_request_target blocked by default, GitHub introduces a default rule. It belongs to workflow execution protections, a kind of allowlist for who may start workflows and on which events. Here are the dates: | Date | What happens | | --- | --- | | until December 2 | default rule runs in “evaluate” mode: nothing is blocked, but runs are recorded | | November 9, 2026, 16:00–18:00 UTC | first trial block: affected runs fail with an error annotation | | November 16, 2026, 16:00–18:00 UTC | second trial block, same behavior | | December 2, 2026 | permanent block for pull_request_target without a matching policy | GitHub gives the windows in UTC. In Central Europe that is 17:00 to 19:00 in November. Who is affected Affected are public repositories on GitHub Free, Team and Enterprise Cloud. Private and internal repositories are out of scope, and so is GitHub Enterprise Server. In addition, the default rule does not apply if you have already created your own event policy for the repository. It does not replace that policy. Why GitHub has pull_request_target blocked On its own, the trigger is not evil. As long as the workflow only runs code from your own repository, nothing bad happens. Under pull_request_target, actions/checkout without extra inputs also fetches only the state of the default branch. Things get dangerous when someone checks out the code from the pull request and then does something with it: npm install, make test, a build script. As a result, foreign code runs with your secrets. What a pwn request is This pattern has its own name: pwn request. Anyone who creates a fork can, for example, add a postinstall script to package.json, change a Makefile or tamper with a test file. If they then open a pull request and your workflow builds the code, deploy keys, npm tokens or cloud credentials end up with the attacker. In fact, several well-known attacks on open source supply chains worked exactly like this. > 🎬 Pwn request and safe rework compared – this animated diagram only runs on my site: watch it on sascha-ms.de What makes it tricky is that the code does not have to come through actions/checkout. A gh pr checkout in a run step, a git fetch of the PR ref or a downloaded artifact from a fork have the same effect. By the way, since version 7, actions/checkout refuses on its own to check out fork code under pull_request_target or workflow_run. If you want it anyway, you have to set allow-unsafe-pr-checkout: true. That name is deliberately uncomfortable. Step 1: Find affected workflows Before pull_request_target is blocked and runs start failing, you need a list. In general, there are three ways to build it, and they complement each other well. Prerequisite: admin rights in the repository or owner rights in the organization for the policy pages, otherwise just read access and the GitHub CLI for searching. Policy insights in the settings 1. Open Settings in the repository or organization. 2. Open the Actions section in the sidebar and choose Policies. 3. Right below it you will find Policy insights. It lists the runs a rule in evaluate mode would have blocked. On the upside, you see real runs instead of guesses. However, a workflow that has not run for weeks does not show up there. That is why a look at the code is worth it, too. Search all repositories of an organization With the GitHub CLI you can search all repositories of an organization for the trigger. In this example the fictional organization is called velo-verleih, a bike rental with a public app: gh search code "pull_request_target path:.github/workflows" \ --owner velo-verleih --limit 100 \ --json repository,path \ --jq '.[] | "\(.repository.nameWithOwner) \(.path)"' As a result, you get one line per match, with repository and file path. Code search only covers the default branch and does not return everything when there are many hits. As a first overview it is good enough, though. Check workflows locally with a small scanner On its own, however, a match does not tell you whether a workflow is dangerous. A labeler that only reads metadata is harmless. A preview build that checks out the PR code is not. That is why I wrote a small scanner for this distinction. First, it reads all YAML files in a folder; then it looks for the trigger and checks typical pwn request patterns. // src/scan-workflows.ts import { parse } from "yaml"; export type Severity = "ok" | "review" | "pwn-risk"; export interface Finding { file: string; severity: Severity; reasons: string[]; } type Step = { uses?: string; run?: string; with?: Record<string, unknown> }; type Job = { steps?: Step[]; permissions?: unknown }; type Workflow = { on?: unknown; permissions?: unknown; jobs?: Record<string, Job> }; // Expressions that point to code from the pull request (possibly a fork) const PR_HEAD_REF = /github\.event\.pull_request\.head\.(sha|ref|repo)|github\.head_ref|refs\/pull\//; export function usesPullRequestTarget(on: unknown): boolean { if (typeof on === "string") return on === "pull_request_target"; if (Array.isArray(on)) return on.includes("pull_request_target"); if (on && typeof on === "object") return "pull_request_target" in on; return false; } export function scanWorkflow(file: string, source: string): Finding { const wf = (parse(source) ?? {}) as Workflow; if (!usesPullRequestTarget(wf.on)) { return { file, severity: "ok", reasons: [] }; } const reasons: string[] = ["uses pull_request_target"]; let pwnRisk = false; if (wf.permissions === undefined) { reasons.push("no top-level permissions block (token may have broad rights)"); } for (const [jobName, job] of Object.entries(wf.jobs ?? {})) { for (const step of job.steps ?? []) { const uses = step.uses ?? ""; const withArgs = step.with ?? {}; if (uses.startsWith("actions/checkout")) { const ref = String(withArgs.ref ?? ""); const repo = String(withArgs.repository ?? ""); if (PR_HEAD_REF.test(ref) || PR_HEAD_REF.test(repo)) { pwnRisk = true; reasons.push(`job "${jobName}": checkout of pull request code`); } if (withArgs["allow-unsafe-pr-checkout"] === true) { pwnRisk = true; reasons.push(`job "${jobName}": allow-unsafe-pr-checkout is enabled`); } } const run = step.run ?? ""; if (/gh pr checkout|git fetch[^\n]*(pull\/|head)/.test(run)) { pwnRisk = true; reasons.push(`job "${jobName}": fetches pull request code in a run step`); } } } return { file, severity: pwnRisk ? "pwn-risk" : "review", reasons }; } In addition, the command line script for it is short. It also sets an exit code, so you can use the scanner in a pipeline later: // src/cli.ts import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { scanWorkflow } from "./scan-workflows.ts"; const dir = process.argv[2] ?? ".github/workflows"; const files = (await readdir(dir)).filter((f) => /\.ya?ml$/.test(f)); let worst = 0; for (const name of files) { const finding = scanWorkflow(name, await readFile(join(dir, name), "utf8")); if (finding.severity === "ok") continue; worst = Math.max(worst, finding.severity === "pwn-risk" ? 2 : 1); console.log(`${finding.severity.toUpperCase()} ${name}`); for (const reason of finding.reasons) console.log(` - ${reason}`); } if (worst === 0) console.log(`No pull_request_target workflows found in ${dir}.`); // Exit code 2 = pwn risk, 1 = review needed, 0 = nothing found process.exitCode = worst; You need Node.js 22.18 or later, because the project runs TypeScript files directly. This is how you start it: mkdir pull-request-target-check && cd pull-request-target-check npm init -y && npm pkg set type=module npm install yaml@2.9.1 mkdir src # put scan-workflows.ts and cli.ts from this article here node src/cli.ts ~/code/fahrradverleih-app/.github/workflows For a repository with a risky preview workflow and a harmless labeler, the output looks like this: PWN-RISK preview.yml - uses pull_request_target - no top-level permissions block (token may have broad rights) - job "preview": checkout of pull request code REVIEW labeler.yml - uses pull_request_target To be fair, the scanner is a heuristic. It does not catch shell tricks across several lines or composite actions that check something out internally. For a quick assessment of a few dozen workflows it still does the job. If you want more, also enable CodeQL for Actions, which GitHub recommends for such checks. Step 2: Decide for each workflow Next, go through every workflow on the list one by one. The question is always the same: does this workflow really need secrets or write access while it reacts to a pull request? Most of the time the answer is no. | What the workflow does | My recommendation | | --- | --- | | Tests, linting, build without secrets | move to pull_request | | Build followed by a comment, status or upload with secrets | split: pull_request plus workflow_run | | Set labels, post a welcome message, check metadata | keep pull_request_target and allow it explicitly | | Preview deployment with cloud access for fork PRs | rethink it: only after manual approval, or not for forks at all | Step 3: Move to pull_request First of all, the simplest case is also the most common one. Many workflows only use pull_request_target because at some point someone wanted them to run for forks as well. However, pull_request does that too, just without secrets. For tests and linting that is exactly right: name: lint on: - pull_request_target: + pull_request: branches: [main] permissions: contents: read Afterwards, check whether the workflow reads a secret anywhere. If it does, that step will fail for fork PRs from now on. Then it belongs in the privileged part from step 4. Step 4: Split workflows with workflow_run It gets trickier when a workflow needs both: build foreign code and then do something with privileges, such as posting a comment with the test coverage. My solution: separate these two jobs into two workflows. First, one workflow runs on pull_request, builds and tests without secrets and only stores data as an artifact. After that, a second one starts via workflow_run once the first has finished, and it has the required permissions. It reads the data but never executes code from it. !Flow: fork PR, pr-build.yml on pull_request without secrets, artifact with data, pr-comment.yml on workflow_run The build runs without secrets, the comment workflow only reads data. For example, here is the unprivileged part for the bike rental: # .github/workflows/pr-build.yml # Runs untrusted pull request code: read-only token, no secrets name: pr-build on: pull_request: branches: [main] permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci - run: npm test -- --coverage > coverage.txt - name: Save results as data run: | mkdir -p result echo "${{ github.event.pull_request.number }}" > result/pr-number tail -n 5 coverage.txt > result/summary.txt - uses: actions/upload-artifact@v7 with: name: pr-result path: result/ retention-days: 3 The privileged part And here is the privileged part. Watch for three details: the artifact goes to runner.temp instead of the workspace, the PR number is validated as an integer, and the comment text is only read and truncated, never executed. # .github/workflows/pr-comment.yml # Privileged part: may write comments, but never executes PR code name: pr-comment on: workflow_run: workflows: [pr-build] types: [completed] permissions: contents: read pull-requests: write jobs: comment: if: > github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 with: name: pr-result path: ${{ runner.temp }}/pr-result run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - name: Validate and comment env: GH_TOKEN: ${{ github.token }} RESULT_DIR: ${{ runner.temp }}/pr-result run: | PR=$(cat "$RESULT_DIR/pr-number") if ! [[ "$PR" =~ ^[0-9]+$ ]]; then echo "Invalid PR number" >&2 exit 1 fi { echo "### Test coverage" echo '```' head -c 2000 "$RESULT_DIR/summary.txt" echo '```' } > "$RUNNER_TEMP/comment.md" gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/comment.md" One important point: everything in the artifact comes from the attacker. The PR number could be forged, and the text can contain anything. Still, for a comment that is acceptable. Running a binary from the artifact, on the other hand, would be the same mistake as before, just one detour later. If you want to be completely safe, look up the PR number through the API based on the head commit instead of reading it from the artifact. Step 5: Allow pull_request_target explicitly Nevertheless, some workflows really need the trigger. The classic case is a labeler that tags pull requests based on the changed paths. It has to write to fork PRs, but it only reads metadata: # .github/workflows/labeler.yml # Legitimate use: only reads metadata, never checks out PR code name: labeler on: pull_request_target: types: [opened, synchronize] permissions: contents: read pull-requests: write jobs: label: runs-on: ubuntu-latest steps: - uses: actions/labeler@v7 Create the event policy For workflows like this, create your own event policy and limit it to exactly this file. In a repository it works like this: 1. Open Settings, choose Actions and then Policies in the sidebar, and create a new policy. 2. Give it a descriptive name, for example “labeler may use pull_request_target”. 3. As the target, enter only the workflow path .github/workflows/labeler.yml, not all workflows. 4. Under Restrict events, allow every event this workflow reacts to, here pull_request_target. 5. Set enforcement to Active and save. On Enterprise Cloud you can test with Evaluate first. In particular, step four is easy to underestimate. An event policy is an allowlist: anything not listed is blocked for the targeted workflows. If your workflow also reacts to workflow_dispatch, add it to the list. You can also see existing policies in the repository through the API: gh api repos/velo-verleih/fahrradverleih-app/actions/policies \ --jq '.policies[] | "\(.name) \(.enforcement)"' For this you need a token with administration rights for the repository. In other words, better make the policy too narrow than too broad. A rule that allows pull_request_target for all workflows makes the new block pointless. Step 6: Use the trial block on November 9 You can put the two trial blocks to good use. On November 9 between 16:00 and 18:00 UTC, open a pull request from a fork or a test account and watch what happens. When pull_request_target is blocked, the runs show up as failed with an error annotation, and the workflow itself does not start. After the window, workflows run again, just with warnings. It also helps to give a short heads-up to everyone reviewing pull requests during those two hours. Otherwise someone spends an hour hunting a bug that is not one. On November 16 you repeat the test with the reworked workflows. Common mistakes during the rework Above all, the most common mistake is a policy that allows too much. If you enable pull_request_target for all workflows under time pressure, you will have peace on December 2 and the same hole as before. Better a workflow that does not run for a week than a deploy key that ends up with strangers. Second, people tend to assume workflow_run is safe by design. It is not, because secrets are available there too, and if you unpack and run the artifact from the first workflow, you simply rebuild the pwn request. Third, old pull requests stay open that still contain the vulnerable workflow version. Close those PRs or ask for them to be rebased onto the current state. A final point concerns permissions. Set a permissions block in every workflow and grant only what is really needed. Together with pinned actions from my article on the GitHub Actions lockfile, that is a solid foundation. The policy page can also enforce lockfiles, so the two topics belong closely together. My take Having pull_request_target blocked by default is overdue in my view. pull_request_target has been a known trap for years, and documentation alone was clearly not enough. Secure by default, with exceptions only on purpose: to me that is the right direction, whether for cloud permissions or CI pipelines. On the other hand, I see the short lead time for small open source projects critically. If you maintain a project in your spare time, you do not read every changelog entry. For teams in companies it is the opposite: there is no excuse. Take an hour, go through the list, and you have made your pipeline safer along the way. If your team is thinking about AI agents in GitHub Actions anyway, now is a good moment to clean up the permissions of all workflows once. FAQ Are private repositories affected by the block? No. The default rule only applies to public repositories on GitHub Free, Team and Enterprise Cloud. Private and internal repositories as well as GitHub Enterprise Server are not affected. What is the difference between pull_request and pull_request_target? For fork PRs, pull_request runs with a read-only token and without secrets. pull_request_target executes as if it belonged to the target repository, with secrets and write access, and uses the workflow file from the default branch. What happens during the trial blocks in November? On November 9 and 16, each from 16:00 to 18:00 UTC, affected runs fail with an error annotation and the workflows do not start. Afterwards, evaluate mode with warnings applies again. Is upgrading to actions/checkout v7 enough? It helps, because v7 no longer checks out fork code under these triggers by default. It does not protect against gh pr checkout, git fetch or executed artifacts, though, and it changes nothing about the block from December 2. Your next step Run the search from step 1 for your organization this week and put every hit into a small table with one of the four recommendations. After that you know whether November 9 affects you at all. And I am curious: did you find a workflow that really needs pull_request_target, or was it convenience all along? Let me know in the comments. Sources - GitHub Changelog: pull_request_target blocked by default December 2 (November 2, 2026) - GitHub Docs: Securely using pull_request_target - GitHub Docs: Governing execution of GitHub Actions workflows - GitHub Docs: About Actions policies - GitHub Docs: REST API endpoints for GitHub Actions policies - GitHub Security Lab: Preventing pwn requests - actions/checkout: README and action.yml (v7, allow-unsafe-pr-checkout) - Tested with Node.js 22.23, TypeScript 7.0.2 (tsc --noEmit), yaml 2.9.1 and the Node test runner (5 tests). The workflow files were parsed for valid YAML syntax and the action inputs were checked against each action.yml. The workflows themselves were not run on GitHub. This post was created with AI assistance. --- 👉 Next step: Not sure which AWS service fits your use case? My AWS service finder asks a few questions and points you to the right one. Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de
Give Your Coding Agent a Bundle Budget: size-limit Fails the Branch Before You Land It - Book: Event-Driven Architecture Pocket Guide - The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- You asked your coding agent for a debounce on the search box. It came back with seven lines and green tests. One of those lines was import _ from "lodash", and one line in package.json added lodash to get a single function. Nothing in your test suite cares. The diff looks small. But the main lodash package has no module field and no type: "module" in its package metadata, and a default import is the whole lodash object. Webpack's tree-shaking guide puts the rule plainly: dead-code elimination "relies on the static structure of ES2015 module syntax". So the bundler keeps all of it, and every visitor downloads a utility belt to run one function. Nobody notices until the bundle has grown by a few of these. The fix is to make bundle size a check that fails, the same way a test fails, so the agent hears about it before you do. Step 1: put a budget on each entry point Size Limit calls itself "a performance budget tool for JavaScript". It bundles a file with all its dependencies, minifies and compresses it (Brotli by default), and "throws an error if the cost exceeds the limit". The current release is 14.3.0, which needs Node.js 22.19+, 24.5+ or 26+. Since 14.0.0 the small-library preset bundles with rolldown (changelog). Install it with the preset and the plugin that powers --why for that preset: npm install --save-dev size-limit \ @size-limit/preset-small-lib \ @size-limit/rolldown-why Then give every entry point its own budget in package.json. Here the app has a main entry and a search widget that loads on its own: { "scripts": { "size": "size-limit", "size:why": "size-limit --why" }, "size-limit": [ { "name": "app", "path": "src/index.js", "limit": "12 kB", "message": "Do not raise this limit. Fix the import." }, { "name": "search", "path": "src/search.js", "limit": "2 kB", "message": "Do not raise this limit. Fix the import." } ] } This uses the small-lib preset because it bundles each entry straight from source, with no build step. The README recommends that preset for libraries under 10 kB; for a full app it points to @size-limit/preset-app or @size-limit/file, which measure your built files. The budget-and-gate workflow below is the same with any of them. The numbers are examples. Run npm run size once and read the current sizes. The README suggests about a kilobyte of headroom for a small library and 25% for an app. For a gate an agent has to pass, go tighter than that: a loose budget lets several heavy imports land before it says anything. name labels each check in the report. message is, in the README's words, "an optional custom message to display additional information", and it is the line your agent will read when the check fails, so write it for the agent. If you ship a library rather than an app, the import option measures what a consumer pays for one export, for example "import": "{ formatPrice }". !A parcel on a weighing scale with a burnt-orange budget line, as a robotic arm lowers a heavy crate onto it One budget per entry point, so a heavy import shows up on the check it belongs to. Step 2: see what grew with --why A red check tells you a budget broke. npm run size:why tells you why. With @size-limit/rolldown-why installed, size-limit --why analyses the bundle and writes an HTML report, rolldown-why.html, to the current directory. Pass --save-bundle <DIR> to put it somewhere else. In the report for the search check, look for lodash: a default import brings the whole package along, so expect it to dwarf your few lines of widget code. That is the evidence to put in front of the agent, or in front of yourself before you accept the change. The fix is one of two edits. A named import from lodash-es, which is published as ES modules with "sideEffects": false (package metadata), so the bundler can drop what you don't call: import { debounce } from "lodash-es"; Or no dependency at all, because a debounce fits in a few lines: export function debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } Either way, run npm run size again. Step 3: block the push, then block the merge A check only helps if it runs without anyone remembering to run it. Put it in two places. First, a pre-push hook. Git runs pre-push before every push, and per the githooks docs, "if this hook exits with a non-zero status, git push will abort without pushing anything". Husky keeps the hook in the repo, so every clone that runs npm install gets it: npm install --save-dev husky npx husky init echo "npm run size" > .husky/pre-push husky init creates a pre-commit hook and a prepare script. Keep them or edit them; the line that matters here is the pre-push file. The hook runs on the machine that pushes, and anyone can skip it. The git-push docs say that with --no-verify, "the hook is bypassed completely". An agent with shell access can type that flag as easily as you can. So the second place is CI, where the check runs whether or not anyone remembers it: # .github/workflows/size.yml name: size on: pull_request: push: branches: [main] jobs: size: runs-on: ubuntu-latest env: HUSKY: 0 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 - run: npm ci - run: npm run size HUSKY: 0 follows Husky's advice to keep hooks from installing on CI servers. The version tags match the current setup-node README. If you want the size change posted on each pull request as a comment, andresz1/size-limit-action does that and fails the check when a limit is exceeded; its example workflow grants pull-requests: write. To actually block the merge, mark the size job as a required status check in your branch protection rules. Without that, a red check is only a warning. !Three gates in a row on one path: a turn checkpoint, a push checkpoint and a CI checkpoint, with a heavy crate stopped at the first The same check at three gates: after the agent's turn, before the push, and in CI. Step 4: hand the failure back to the agent The pre-push hook stops the push, but by then the agent has finished and moved on. The cheaper moment is the end of the agent's turn, while it still has the change in context. Without any tooling, that is a habit: after each task, run npm run size, and if it fails, paste the output back with a prompt that closes the easy exit: npm run size fails on the "search" check. Do not change the size-limit section in package.json. Run npm run size:why, find the import that grew, and switch to a named ES-module import or drop the dependency. Run npm run size again until it passes. Put the "never raise a limit" rule in the instructions file your agent reads, too. The message field repeats it in the failure output. In Hermes IDE, your own checks can decide when a task is done, so npm test and npm run size can both sit on that list. I build Hermes, so weigh this with that in mind. The per-repository setup lives in a worktree recipe; the recipe docs show how to list the checks. Whichever agent you run, Claude Code or another, the prompt above is what you send back when the size check fails. Then read the change before it lands. The Review Desk (⌘G, Ctrl+Shift+G on Windows and Linux) shows everything the agent changed, by turn or by file, with risk flags. A new dependency shows up as a package.json and package-lock.json change in the turn that added it, and the package.json line shows exactly which package came in. If the agent quietly raised a limit instead of fixing the import, that is one line in the same diff. A line comment there goes back to the agent that wrote the line. !The Hermes Review Desk in By turn view, with two turns, a diff of src/db.ts and a drafted commit message The Review Desk (⌘G) in By turn view: two turns listed, turn 1's diff of src/db.ts, the changed-files list, and a commit message drafted from the turns. Once the size check and the tests pass, Land commits, opens a pull request or merges locally in one step, into the branch the task started from, and it can be undone. CI still runs the same npm run size on the pull request. What a budget won't catch A size budget measures bytes. It won't tell you a dependency is unmaintained, has a bad license or pulls in a postinstall script; those need their own review, which is why reading the lockfile change matters as much as the number. It also only measures what you point it at. If an agent adds a new entry point, it has no budget until you add one, so add "new entry point means a new size-limit section" to the same instructions file. Hermes IDE is free to download, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Download it, and if it helps you catch a heavy import before it lands, star it on GitHub. --- If this was useful A bundle budget is a small version of a bigger habit: decide what "done" means before work starts, and make a check enforce it. If your agents end up running as workers off a queue instead of in your terminal, the Event-Driven Architecture Pocket Guide covers the retries, idempotency and outbox patterns that keep that kind of system honest. It is one of 8 short books in the Pocket Guides for Developers series, on agents, RAG, prompts, observability, events, databases and system design: the whole series is here. 
dbt v2はどこで動かす?GitHub Actions・Workflows・Airflowの使い分け はじめに こんにちは、青木です! dbt World Tour Tokyoも開催され、dbtがだいぶ普及してきた印象があります。 皆さんdbtを使うなら、まず試すのはdbt v2(旧dbt Core)ですよね? そのときに、「どこでどう動かすのがいいんだろう」と迷ったことはありませんか。 dbt v2は手元でも試せますが、チームで定期的に動かすなら、実行する場所も考えたいところです。 GitHub Actions、Workflows + Cloud Run Jobs、Airflow。候補はありますが、何を基準に選べばよいかは悩むところです。 個人的には、Google Cloudでdbtを動かすなら、Workflows + Cloud Run Jobsをまず検討します。 処理の順序を管理でき、監視もGoogle Cloudの仕組みにそろえやすいからです。 詳細は後ほど説明します! まず小さく試すならGitHub Actions、すでにAirflowを運用しているならその基盤を使うのもよい選択です。 dbt単独の定期実行なら、Cloud SchedulerからCloud Run Jobsを直接起動するだけでも始められます。 次のような方を想定しています。 BigQueryでdbtを使い始めたい方 チームで定期実行する環境を用意したい方 既存の実行基盤を、再実行や費用も含めて見直したい方 dbt platform(旧dbt Cloud)のスケジューラ、Dagster、Dataformは扱いません。 コード例と詳しい説明は、各構成の節の折りたたみにまとめています。 まずは構成の違いをつかむ 比較表には、3構成に加えて、dbt単独の定期実行に使えるCloud Scheduler + Cloud Run Jobsも載せています。 構成 利点 運用上の注意点 向いているチーム GitHub Actions コードと実行をGitHubにまとめ、少ない構成で始められる 定期起動の遅延への備え、対象期間を指定する再実行の仕組み GitHubで運用をまとめたいチーム Cloud Scheduler + Cloud Run Jobs dbtの定期実行をGoogle Cloudの少ないサービスで始められる 起動と実行結果の監視は別。前後処理の連携は自分たちで用意する dbt単独のバッチを動かしたいチーム Workflows + Cloud Run Jobs 常設のAirflow環境を持たず、Google Cloudの処理をつなげられる コンテナの配布、サービス間の権限、過去分の再処理の仕組み Google Cloudで処理をつなぎ、コンテナを管理できるチーム Airflow(Cloud Composer) 日付ごとの履歴や再実行を、取り込みと一緒に管理できる 環境の維持費、DAGや実行環境の保守 既存のAirflowがある、または日付ごとの再処理をまとめて管理したいチーム 最初に見るのは、すでに使っている基盤、処理のつながり、失敗時の確認・再実行の3つです。 同じサービスでも、新しく導入する場合と、使い慣れた既存環境へ追加する場合では、運用にかかる手間や費用が変わります。 dbtが動く場所と、実行を管理する場所 どの構成でも、dbtがモデルのSQLをBigQueryに送り、集計やテーブルの作成はBigQuery側で行います。 変わるのは、dbtをどこで動かし、その実行をどう管理するかです。 WorkflowsはCloud Run Jobsを呼び出し、そこでdbtを動かします。 AirflowはDAGで実行を管理し、dbtはworker上でも、外部のCloud Run Jobsでも動かせます。 GitHub Actions 利点 dbtのコードがGitHubにあるなら、workflowを追加して定期実行を始められます。 GitHub-hosted runnerを使えば、実行用のサーバーを自分たちで維持する必要がありません。 コードの修正と実行結果を、普段使っているGitHubで確認できるのが手軽です。 立ち上げ直後のスタートアップなどで、まずはdbtを動かしてみたいなら、新しい運用基盤を増やさずに始められます。 料金は公開リポジトリの標準runnerなら無料で、プライベートリポジトリはプランに応じた無料枠と実行時間で変わります。 運用上の注意点 schedule は混雑時に遅れたり、ジョブがキューから落ちたりする場合があります。 決まった時刻に必ず起動する前提では組めないため、更新の締切に間に合うかを考えます。 schedule だけでは、失敗したdbtを自動リトライしてくれません。 この例では失敗したジョブを手動で再実行します。 自動リトライが必要なら、workflowに別途組み込む必要があります。 過去7日分を作り直すなら、対象期間を入力で受け取り、dbtへ渡す仕組みも必要です。 ジョブの依存関係や条件分岐はworkflowに書けます。 外部処理の完了確認や複雑な連携が増えると、Pythonなどのスクリプトも含めて、自分たちで実装・保守する範囲が広がります。 こういうときに選ぶ GitHub中心で運用し、まずは独立したdbtのバッチを少ない構成で始めたいチームに向いています。 起動の遅れを許容でき、再実行の手順もチームで扱えるなら、有力な候補です。 データを使う人が増え、「朝9時までに更新してほしい」「取り込みが終わってから動かしたい」といった要望が出てきたら、実行基盤を見直すタイミングです。 Google Cloud上の取り込みや監視とまとめて運用したくなったら、次のWorkflows + Cloud Run Jobsを検討します。 GitHub Actions:定期実行のworkflow例 コード例はdbt v2の 2.0.9 向けです。 BigQueryへの接続設定と必要な権限を用意したdbtプロジェクトを前提にしています。 接続設定はdbtのBigQuery接続ガイド、GitHub ActionsからGoogle Cloudへの認証設定はgoogle-github-actions/auth の資料を参照してください。 dbtはcurlでインストールし、バージョンを固定します。 BigQueryに繋ぐドライバは初回の実行時にダウンロードされるので、actions/cacheでキャッシュしておくと毎回取りに行かずに済みます。 .github/workflows/dbt-daily.yml name: dbt-daily on: schedule: - cron: "0 6 * * *" # 毎朝6時(JST) timezone: "Asia/Tokyo" workflow_dispatch: permissions: contents: read id-token: write concurrency: group: dbt-daily-prod cancel-in-progress: false jobs: dbt: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: google-github-actions/auth@v3 with: workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github service_account: dbt-runner@sample-project.iam.gserviceaccount.com - name: Cache dbt drivers uses: actions/cache@v6 with: path: ~/.cache/com.getdbt key: dbt-drivers-${{ runner.os }}-${{ runner.arch }}-2.0.9 - name: Install dbt run: | curl -fsSL public.cdn.getdbt.com | sh -s -- --update ~/.local/bin/dbt system update --version 2.0.9 - name: Check BigQuery connection run: ~/.local/bin/dbt debug --profiles-dir . - name: dbt build run: ~/.local/bin/dbt build --profiles-dir . schedule のcronはデフォルトではUTCですが、timezone に Asia/Tokyo を指定すればJSTのまま書けます。 workflow_dispatch も付けておくと、画面から手動でも起動できて便利です。 キャッシュはOS・CPUアーキテクチャ・dbtのバージョンごとに分けています。 concurrency は、同じ出力先を更新するジョブが同時に走らないようにする設定です。 実行中のジョブを途中で止めないよう、cancel-in-progress は false にしています。 GitHub-hosted runnerの1ジョブの実行時間は最大6時間です。 workflowはデフォルトブランチに置く必要があり、公開リポジトリでは60日間活動がないと定期実行が無効になります。 定期実行の通知はcronを最後に変更したユーザーに届くため、チーム用の通知先も決めておくと運用しやすいです。 Workflows + Cloud Run Jobs 利点 dbtはCloud Run Jobsのコンテナで動かし、Workflowsが処理の順序や完了待ちを管理します。 常設のAirflow環境を用意せずに、Google Cloudのサービスをつなげられます。 GitHub Actionsと同じようにYAMLをGitで管理でき、処理の順序や分岐をレビューしながら運用できます。 図のように取り込みの完了を待てるので、取り込みとdbtの起動時刻をずらして調整せずに済みます。 実行履歴から、どの処理で止まったかもまとめて追えます。 Cloud Monitoringには実行状況のメトリクスが送られます。 アラートポリシーとSlackなどの通知先を設定すれば、失敗をチームへ知らせることもできます。 Google Cloudのほかの処理と監視の仕組みをそろえられるのも、私がこの構成を推す理由です。 Cloud Scheduler dbtだけを定期実行するなら、Cloud SchedulerからCloud Run Jobsを直接起動する構成も選べます。 前後の処理をつなぎ、完了待ちや通知までまとめたくなったら、Workflowsを加える理由になります。 運用上の注意点 コンテナのビルド・配布と、サービス間の権限を管理します。 また、過去分の対象日を列挙して流し直す仕組みや、どこまで再処理できたかを確認する方法は自分たちで用意します。 Workflowsは実行ステップ数、Cloud Run Jobsはコンテナの実行時間やCPU・メモリに応じて課金されます。 常設のAirflow環境の費用をかけずに始められますが、Cloud Schedulerやイメージの保存なども含めて見積もります。 定期起動にはCloud Schedulerを使います。 更新の締切がある場合は、予定した時刻に起動したかに加えて、締切までにdbtが完了したかを確認する仕組みも用意します。 こういうときに選ぶ 取り込みからdbt、後続の処理までをGoogle Cloud上でつなぎたいチームに向いています。 データを使う人や運用する人が増え、実行結果や失敗時の対応をチームで管理したくなったときに、私ならまず検討する構成です。 コンテナの配布や権限の管理に慣れているなら、既存の運用にも合わせやすいです。 Workflows + Cloud Run Jobs:コンテナの配布と完了待ち BigQueryへの接続設定を用意したdbtプロジェクトを、コンテナに含める構成例です。 BigQueryへのアクセスにはCloud Run Jobsに設定したサービスアカウントを使います。 1. dbtプロジェクトをコンテナに入れる ビルド時にdbt system install-driversでドライバまでイメージに入れておきます。 こうしておくと、ジョブが起動するたびにドライバを取りに行かなくて済み、起動も安定します。 Dockerfile FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates git \ && rm -rf /var/lib/apt/lists/* RUN curl -fsSL public.cdn.getdbt.com | sh -s -- --update \ && /root/.local/bin/dbt system update --version 2.0.9 \ && /root/.local/bin/dbt system install-drivers ENV PATH="/root/.local/bin:${PATH}" WORKDIR /app COPY . . ENTRYPOINT ["dbt"] CMD ["build", "--profiles-dir", "."] COPY . . に認証情報や不要なファイルが入らないよう、ビルド前に .dockerignore を用意します。 .dockerignore .git .env .env.* gha-creds-*.json target logs 2. ジョブを作る Artifact Registryの dbt リポジトリと、実行用サービスアカウント dbt-runner は作成済みとします。 必要なAPIとビルド・配布の権限を用意してから、イメージをビルドしてジョブを作ります。 Cloud Run Jobsのタスクのタイムアウトはデフォルトで10分なので、dbtの実行時間に合わせて --task-timeout で延ばしておきます。 失敗したときのリトライ回数を決めるのは --max-retries です。 gcloud builds submit \ --project sample-project \ --tag asia-northeast1-docker.pkg.dev/sample-project/dbt/dbt:2.0.9 gcloud run jobs deploy dbt-daily \ --project sample-project \ --image asia-northeast1-docker.pkg.dev/sample-project/dbt/dbt:2.0.9 \ --region asia-northeast1 \ --service-account dbt-runner@sample-project.iam.gserviceaccount.com \ --tasks 1 \ --task-timeout 1h \ --max-retries 1 --tasks 1 はdbtプロジェクトを1つのタスクで動かす指定です。 --max-retries 1 ではdbt全体を1回やり直すため、途中まで更新されたモデルを再実行しても問題がないかを確認しておきます。 3. Workflowsから起動して、完了を待つ WorkflowsのCloud Run Admin APIコネクタでジョブを実行すると、ジョブが終わるか失敗するまでWorkflowsが待ってくれます。 この待ち時間はデフォルトで30分までなので、dbtが30分を超えそうなら connector_params の timeout を延ばしておきます。 workflow.yaml main: steps: - run_dbt: call: googleapis.run.v2.projects.locations.jobs.run args: name: projects/sample-project/locations/asia-northeast1/jobs/dbt-daily body: {} connector_params: timeout: 10800 # 起動とリトライを含め、3時間まで待つ connector_params.timeout は、Workflowsが完了を待つ時間です。 Cloud Run Jobsのタスクのタイムアウトとは別なので、起動やリトライも含めて待てる長さにします。 この例では1時間のタスクに1回のリトライを許しているため、起動時間も含めて3時間にしています。 取り込みからdbtとSlack通知までつなぐなら、2つのジョブの実行をtry/exceptで包みます。 WebhookのURLはSecret Managerに置き、accessStringで読み出してhttp.postで送ります。 通知のあとに raise でエラーを投げ直すので、実行は失敗のままです。 WorkflowsのサービスアカウントにはSecret Managerのシークレットアクセサー(roles/secretmanager.secretAccessor)を付けておきます。 なお、呼び出しのログを log-all-calls にしていると、accessString で読み出したWebhookのURLがそのままログに残ります(公式ドキュメントの警告)。 取り込み → dbt → Slack通知をつなぐworkflow.yaml 取り込み用の load-orders ジョブは作成済みとします。 Workflowsのサービスアカウントに付けるロールと範囲は、次の手順の表にまとめています。 取り込みが失敗したらdbtへ進まず、両方成功したときだけ完了通知を送ります。 workflow.yaml main: steps: - get_webhook_url: call: googleapis.secretmanager.v1.projects.secrets.versions.accessString args: project_id: sample-project secret_id: slack-webhook-url result: webhook_url - run_pipeline: try: steps: - load_orders: call: googleapis.run.v2.projects.locations.jobs.run args: name: projects/sample-project/locations/asia-northeast1/jobs/load-orders body: {} connector_params: timeout: 10800 - run_dbt: call: googleapis.run.v2.projects.locations.jobs.run args: name: projects/sample-project/locations/asia-northeast1/jobs/dbt-daily body: {} connector_params: timeout: 10800 except: as: e steps: - notify_failure: call: http.post args: url: ${webhook_url} body: text: ${"注文データの取り込みかdbtが失敗しました。" + e.message} - fail: raise: ${e} - notify_success: call: http.post args: url: ${webhook_url} body: text: "注文データの取り込みとdbtが完了しました。" Webhook URLの取得に失敗した場合は、取り込みを始めずに終了します。 この場合はSlackへ通知できないため、Workflows自体の失敗を拾うCloud Monitoringのアラートも設定してください。 4. Workflowsを配布して、Cloud Schedulerから定期起動する 起動には用途の異なるサービスアカウントを使います。 dbtがBigQueryに接続する権限と、ジョブを起動する権限を分けて考えると整理しやすいです。 サービスアカウント 役割 必要なアクセス scheduler Workflowsを定期起動する roles/workflows.invoker workflow-runner Cloud Run Jobsを起動し、完了を確認する 対象ジョブに roles/run.invoker、プロジェクトに roles/run.viewer dbt-runner コンテナ内のdbtを動かす dbtに必要なBigQueryへのアクセス Cloud Run Invokerにはジョブを起動する run.jobs.run、Cloud Run Viewerには完了待ちに使う run.operations.get が含まれます。 この例では load-orders と dbt-daily にInvokerを付け、Viewerはプロジェクトに付けます。 Secret Managerの roles/secretmanager.secretAccessor は、Webhook URLを保存したシークレットに付けます。 Workflowsはサービスアカウントを指定して配布します。 上記のサービスアカウントと権限を用意したら、次のように設定します。 gcloud workflows deploy dbt-daily \ --project sample-project \ --location asia-northeast1 \ --source workflow.yaml \ --service-account workflow-runner@sample-project.iam.gserviceaccount.com 最後に、Cloud SchedulerからWorkflowsを起動します。 タイムゾーンに Asia/Tokyo を指定して、毎朝6時(JST)に動かします。 gcloud scheduler jobs create http dbt-daily \ --project sample-project \ --location asia-northeast1 \ --schedule "0 6 * * *" \ --time-zone "Asia/Tokyo" \ --uri "workflowexecutions.googleapis.com" \ --http-method POST \ --headers "Content-Type=application/json" \ --message-body '{}' \ --oauth-service-account-email scheduler@sample-project.iam.gserviceaccount.com Cloud Schedulerの起動成功と、dbtの実行成功は別です。 dbtまで成功したかはWorkflowsとCloud Run Jobsの実行結果で確認します。 Cloud Schedulerは少なくとも1回の配信を行う仕組みなので、同じ予定時刻に複数回起動される場合があります。 同じ対象日を処理し直しても重複データを作らないようにします。 Airflow(Cloud Composer) Airflowの利点は、取り込みからdbt、その後の通知までを、一連のワークフローとして管理できることです。 利点 こうした処理の順序や依存関係を、DAGというワークフローに定義します。 どの取り込みで止まったのか、dbtまで進んだのかを同じ画面で確認でき、失敗したタスクの再実行や過去期間の再処理もまとめて管理できます。 すでにAirflowで取り込みを管理しているなら、その後ろにdbtを組み込めるのが便利です。 Workflowsでも処理はつなげられますが、Airflowは複数のデータパイプラインを、日付ごとの実行履歴や再処理まで含めて運用したいときに向いています。 用途の違いはGoogle Cloudの比較資料にもまとまっています。 運用上の注意点 Cloud Composerには環境の稼働時間に応じた費用がかかり、DAGが動いていない時間も環境の費用が発生します。 環境の更新や、DAG・パッケージの保守も必要です。 こういうときに選ぶ すでにAirflowを運用していて、取り込みからdbtまでの流れを同じ基盤にまとめたいチームに向いています。 使い慣れた監視や再実行の手順も生かせます。 dbtの定期実行だけのためにAirflowを新設するなら、私はまずWorkflows + Cloud Run Jobsを検討します。 dbt単独ならSchedulerから直接起動する構成、小さく試す段階ならGitHub Actionsも候補です。 Airflowを新設する場合は、複数のパイプラインや再処理をまとめて管理する必要があり、その便利さが費用と保守の手間に見合うかで判断します。 Airflow:dbtの実行場所の違いとDAG例 dbtを動かす場所は、Cloud Run JobsとAirflowのworker(タスクを実行する環境)の2通りで考えてみます。 どちらも、取り込みの完了を待ってdbtを動かす順序はAirflowのDAGで管理します。 DAG例はAirflow 3向けです。 Cloud Run Jobsで動かす 図の上側はDAGからCloudRunExecuteJobOperatorでCloud Run Jobsを呼ぶ構成です。 コンテナと dbt-daily ジョブはWorkflowsの節と同じものを使え、Composer側にdbtを入れる必要はありません。 dbt_daily_cloud_run.py import pendulum from airflow.providers.google.cloud.operators.cloud_run import CloudRunExecuteJobOperator from airflow.sdk import DAG with DAG( dag_id="dbt_daily_cloud_run", schedule="0 6 * * *", start_date=pendulum.datetime(2026, 11, 1, tz="Asia/Tokyo"), catchup=False, max_active_runs=1, ) as dag: dbt_build = CloudRunExecuteJobOperator( task_id="dbt_build", project_id="sample-project", region="asia-northeast1", job_name="dbt-daily", deferrable=True, timeout_seconds=10800, retries=0, ) ジョブが成功したら、後続のタスクへ進みます。 deferrable=True では、待機中の状態確認をTriggererが担うため、workerの実行枠を使い続けずに済みます。 利用するComposerイメージのGoogle providerの対応と、Triggererの設定を確認します。 Composer側にはジョブの実行・状態参照の権限を付け、BigQueryへの接続はCloud Run Jobs側のサービスアカウントで行います。 retries=0 とし、Airflowの再試行で同じジョブを二重に起動するのを避けています。 ジョブ内の再試行はCloud Run Jobs側で設定し、手動で流し直すときも元の実行状態を先に確認します。 dbtの依存関係や実行リソースをComposerから分けたい場合に、この構成を検討します。 GoogleのDAG作成ガイドも、CPUやメモリを多く使う処理をAirflowの構成要素から分離する方針を示しています。 Airflowのworker上で直接動かす 図の下側はworkerにdbtを入れ、BashOperatorで dbt build を実行する構成です。 コンテナの配布を増やさずに済みますが、dbtの依存関係や必要なメモリはComposer側で管理します。 Composer環境のPyPIパッケージに dbt==2.0.9 を追加し、BigQueryへの接続設定を用意したプロジェクトを dags/dbt/ に置く例です。 ドライバは初回実行時に取得されます。 ComposerのサービスアカウントにBigQueryへのアクセス権限を付け、Python・Airflowと追加パッケージの依存関係を合わせておきます。 dbt_daily.py import pendulum from airflow.providers.standard.operators.bash import BashOperator from airflow.sdk import DAG with DAG( dag_id="dbt_daily", schedule="0 6 * * *", start_date=pendulum.datetime(2026, 11, 1, tz="Asia/Tokyo"), # 毎朝6時(JST) catchup=False, max_active_runs=1, default_args={"retries": 1}, ) as dag: dbt_build = BashOperator( task_id="dbt_build", bash_command=""" set -euo pipefail DBT_WORK_DIR="$(mktemp -d /tmp/dbt.XXXXXX)" trap 'rm -rf "$DBT_WORK_DIR"' EXIT cp -R /home/airflow/gcs/dags/dbt/. "$DBT_WORK_DIR/" cd "$DBT_WORK_DIR" dbt build --profiles-dir . """, ) /tmp に作った作業ディレクトリは終了時に削除するので、run_results.json などが必要なら、その前にCloud Storageなどへ保存します。 Cosmosを使うと、dbtのリネージ(モデル間の依存関係)をAirflowのDAGに反映し、モデルごとの実行結果を確認できます。 この記事のdbt v2(旧dbt Core)をworker上で使うなら、Cosmos 1.11.0以降の ExecutionMode.LOCALを使います。 ExecutionConfig の invocation_mode に InvocationMode.SUBPROCESS、dbt_executable_path にdbtの実行ファイルへのパスを指定します。 dbtのコマンドを別プロセスで動かす方式です。 DAGの生成にも dbt ls を使う場合は、RenderConfig にも invocation_mode=InvocationMode.SUBPROCESS を指定します。 Slackへの成功・失敗通知はDAGのコールバックにSlackNotifierなどを設定できます。 チームで無理なく運用できるか 同じ構成でも、チームが使い慣れているかで、導入や運用にかかる手間は変わります。 GitHub Actionsに慣れているのか、すでにAirflowを運用しているのかも、選ぶときの判断材料になります。 失敗したときにログを確認し、コードを直して再実行するところまで、運用するメンバーと一緒に考えてみてください。 例えば、コンテナの配布を別チームに頼む必要があるなら、その依頼も復旧の手順に含めます。 データを使う人とは更新の締切や許容できる遅れを、基盤を管理する人とは権限や費用の条件を確認します。 おわりに 選ぶときに持ち帰ってほしいのは、次の3点です。 小さく試すならGitHub Actions。Google Cloudで動かすなら、私はWorkflows + Cloud Run Jobsをまず検討します。dbt単独ならSchedulerからの直接起動も候補です。 すでにAirflowを使っているなら、その基盤を生かすのもありです。新設するなら、日付ごとの履歴や再処理をまとめて管理する便利さと、費用・保守の手間を比べます。 最初に動かす手間だけでなく、失敗したときの確認・再実行まで、チームで扱えるかを考えます。 まずは、チームに合いそうな構成で小さく試してみてください。 この記事がdbtをどこで動かすか迷ったときの参考になれば嬉しいです! 補足:コード例を試すときの権限・認証 dbt v2では、通常のクエリ実行・データ読み書きに加え、Storage Read APIの権限も必要です。 実行用サービスアカウントへ、BigQuery Read Session User(roles/bigquery.readSessionUser)をプロジェクト単位で付与します。 鍵なし認証を使う場合は、実際にdbtを動かす各環境で dbt debug --profiles-dir . を実行し、BigQueryへ接続できるか確認してください。
GitHub ActionsでTerraformのCIを改善する はじめに これまで、GitHub Actionsを使ってTerraformの品質チェックやセキュリティチェックを自動化する方法を試して見ました! これまでに書いた記事は以下の2つです。 GitHub ActionsとTrivyでTerraformの設定ミスをPR時に検出する GitHub ActionsでTerraformの品質チェックを自動化する これまで作成したWorkflowをさらに改善するために、以下の3つに取り組みました。 Reusable WorkflowsによるWorkflowの再利用 Path FilterによるWorkflowの実行条件の制御 Terraform Provider CacheによるProviderのキャッシュ 1. Reusable WorkflowsでWorkflowを再利用する これまでのWorkflowでは、Terraformの品質チェックを実行する処理を1つのYAMLファイルにまとめていました。 しかし、今後セキュリティチェックやテストなどを追加していくことを考えると、Workflowごとに処理を分離しておく方が管理しやすいかなと思いました。 そこで、Reusable Workflowsを利用してみました! Reusable Workflowsは、別のWorkflowから呼び出して再利用できる仕組みです。 今回の構成では、以下のように役割を分けています。 呼び出し側:Workflowを実行する条件を定義 呼び出される側:Terraformの品質チェックを実行 呼び出し側のWorkflow .github/workflows/pr-check.yml name: Terraform PR Check on: pull_request: paths: - '**/*.tf' permissions: contents: read jobs: terraform-quality: uses: ./.github/workflows/terraform-quality.yaml uses に呼び出したいWorkflowのパスを指定します。 これにより、Terraformの品質チェック処理を別のWorkflowに切り出すことができます。 呼び出される側のWorkflow .github/workflows/terraform-quality.yaml name: Terraform Quality on: workflow_call: permissions: contents: read jobs: terraform-quality: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup Terraform uses: hashicorp/setup-terraform@v4 - name: Check formatting run: terraform fmt -check -recursive - name: Initialize Terraform run: terraform init -backend=false - name: Validate Terraform run: terraform validate 呼び出される側では、on: workflow_call を指定します。 これにより、別のWorkflowから呼び出せるようになります。 Terraformの品質チェック処理を独立させることで、今後ほかのWorkflowからも再利用しやすい構成にしました。 2. Path FilterでWorkflowの実行条件を制御する 次に、Workflowが実行される条件を見直しました。 Pull Requestを作成するたびにTerraformの品質チェックを実行するのではなく、Terraformファイルに変更があった場合だけ実行するようにします。 そこで、呼び出し側のWorkflowに以下を設定しました。 on: pull_request: paths: - '**/*.tf' paths を指定することで、対象となるファイルに変更があった場合のみWorkflowが実行されます。 今回の設定では、.tf ファイルが変更されたPull Requestが対象になります。 実際に動作を確認したところ、WorkflowのYAMLファイルだけを変更した場合は実行されず、Terraformファイルを変更した場合は実行されることを確認できました。 これにより、Terraformに関係のない変更で不要なCIが実行されることを抑えられます。 3. Terraform Providerをキャッシュする 次に、Terraform Providerのキャッシュを導入してみました。 Terraformでは、terraform init を実行すると、必要なProviderがダウンロードされます。 毎回ダウンロードが発生するため、Providerをキャッシュして再利用できるようにします。 今回は、Terraformの環境変数 TF_PLUGIN_CACHE_DIR とGitHub Actionsの actions/cache を利用しました。 Provider Cacheの設定 まず、Jobの環境変数にキャッシュ先を指定します。 env: TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache TF_PLUGIN_CACHE_DIR は、Terraform Providerのキャッシュ先を指定するための環境変数です。 次に、WorkflowのStepにキャッシュ処理を追加します。 - name: Create Terraform plugin cache directory run: mkdir -p "$TF_PLUGIN_CACHE_DIR" - name: Cache Terraform providers uses: actions/cache@v6 with: path: ${{ github.workspace }}/.terraform-plugin-cache key: ${{ runner.os }}-terraform-providers-${{ hashFiles('**/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-terraform-providers- それぞれの設定について説明します。 path キャッシュ対象のディレクトリを指定します。 今回は、TF_PLUGIN_CACHE_DIR で指定したディレクトリと同じ場所にしています。 key キャッシュを識別するためのキーです。 今回は、実行環境のOSと .terraform.lock.hcl のハッシュ値を組み合わせています。 ロックファイルの内容が変わるとハッシュ値も変わるため、Providerの依存関係に変更があった場合に、別のキャッシュキーを利用できます。 restore-keys 完全に一致するキャッシュが見つからなかった場合に、前方一致するキャッシュを探すための設定です。 今回はOSとProviderキャッシュのプレフィックスを指定しています。 Workflow全体 最終的なReusable Workflowは以下のようになりました。 name: Terraform Quality on: workflow_call: permissions: contents: read jobs: terraform-quality: runs-on: ubuntu-latest env: TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup Terraform uses: hashicorp/setup-terraform@v4 - name: Create Terraform plugin cache directory run: mkdir -p "$TF_PLUGIN_CACHE_DIR" - name: Cache Terraform providers uses: actions/cache@v6 with: path: ${{ github.workspace }}/.terraform-plugin-cache key: ${{ runner.os }}-terraform-providers-${{ hashFiles('**/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-terraform-providers- - name: Check formatting run: terraform fmt -check -recursive - name: Initialize Terraform run: terraform init -backend=false - name: Validate Terraform run: terraform validate キャッシュの復元処理は terraform init より前に実行する必要があります。 これにより、Terraformの初期化時にキャッシュされたProviderを利用できるようになります。 4. Provider Cacheの動作を確認する 設定したキャッシュが実際に動作するか、GitHub Actions上で確認しました。 今回は、同じPull RequestでWorkflowを2回実行し、キャッシュの保存と復元を確認しています。 1回目の実行 初回実行ではキャッシュが存在しないため、Providerのダウンロードが行われます。 実行ログでは、キャッシュが見つからないことを示すメッセージが出力されました。 Cache not found for input keys: その後、Workflowの最後に以下のログが出力されました。 Cache saved with key: ... これにより、Providerのキャッシュが保存されたことを確認できました。 2回目の実行 次に、同じPull Requestで Re-run all jobs を実行しました。 2回目の実行では、以下のログが出力されました。 Cache restored successfully これにより、1回目の実行で保存したキャッシュを復元できることを確認できました。 今回の検証では、キャッシュの保存と復元の両方を確認できました。 なお、キャッシュの復元によってProviderの再ダウンロードを抑制できる構成にはなりましたが、実行時間がどの程度短縮されたかについては、まだ比較検証できていません。ただ、Providerのダウンロードを省略できる分、実行時間の短縮につながると考えています! また、GitHub ActionsのキャッシュはPull Requestの実行スコープに関係するため、今回は同じPull Requestで再実行して確認しています。 5. まとめ 今回は、GitHub ActionsでTerraformのCIを改善するために、以下の3つを実装しました。 Reusable WorkflowsによるWorkflowの共通化 Path Filterによる実行条件の制御 Terraform Provider CacheによるProviderのキャッシュ 実際に設定して動作を確認することで、それぞれの仕組みについて理解を深めることができました。 特に、Provider Cacheについては、設定するだけでなく、GitHub Actionsの実行ログからキャッシュの保存と復元まで確認できたのがよかったです。 引き続き、GitHub ActionsやTerraformを使いながら、CI/CDの改善について学んでいきたいと思います。 参考 GitHub Actionsの再利用可能なワークフロー GitHub Actionsのワークフロー構文 GitHub Actionsの依存関係キャッシュ actions/cache Terraform fmt Terraform init Terraform validate hashicorp/setup-terraform
Playwright再入門:Claude Codeでテストを残し、増やし、GitHub Actionsで回し、MCP・CLIと使い分ける Claude Code で画面を作っていて、ブラウザを動かすテストをまだ持っていないなら、この記事はその入口です。 Playwright(ブラウザを自動で動かす Microsoft の OSS)で書かせて残したテストは、AI なしで1秒前後で回ります。画面が変わると赤くなり、止まった所まで出ます。 AI に頼むのは、テストを書く・直すときと、テストの無い画面をその場で見るときだけで足ります。 テストを残す・増やす・GitHub Actions で回す・MCP と CLI で見させる、の4つを1画面の小さなアプリで順に試します。どの段にも公開サンプルがあります。 ! この記事の内容は動画(全4本)でも解説しています。下は1本目です。 目次:目的から各回を選ぶ 目的から、見たい回へ飛んでください。 テストを作って保つ #1 テストを1本書かせて残す:画面が変わると AI なしで赤くなる #2 Test Agents で増やす:計画を絞って書かせ、6本の赤を1行で直させる 人が走らせなくても回す #3 GitHub Actions で回す:push だけでテストが走り、トレースで原因が分かる テスト以外の使い方を選ぶ #4 MCP と CLI:同じ確認を4通りで比べ、残すか見させるかを選ぶ 前提と試した環境 要る物は次のとおりです。 Node.js(公式の導入ページの要件は「latest 22.x, 24.x or 26.x」) Claude Code(#1・#2・#4) GitHub のアカウント(#3) 確認環境と、試していないこと #1・#2・#4 は、Linux arm64・Node.js 22.22.1・Playwright 1.64.0 で試しました。 回 確認日 試した環境 #1 2026-10-10 Chrome for Testing 156・Claude Code 2.1.296 #2 2026-10-11 Claude Code 2.1.296 #3 2026-10-18 GitHub Actions の ubuntu-24.04 ランナー(手元は Linux) #4 2026-10-18 Chrome なし・画面なし・Claude Code 2.1.302 秒数は途中の許可を聞かれない auto mode の値で、頼むたびに変わります。Mac と Windows では試していません。#4 は本体同梱の npx playwright mcp だけで、@playwright/mcp@latest と Chrome のある端末は試していません。 #1 テストを1本書かせて残す:画面が変わると AI なしで赤くなる Playwright には 1.56 から Playwright Test Agents が入っています。計画を書く planner、テストを書く generator、失敗したテストを直す healer の3つです。Claude Code からはサブエージェントとして呼べます。私が試したかったのは、AI の確認を使い捨てにせず、テストとして残す形です。 実験台の「買い物メモ」。品物を足してチェックすると、下の「残り N 品」が減る サンプルでテスト1本を走らせる zun-tools/playwright-sample は、設定まで済ませた完成形です。 git clone zun-tools/playwright-sample cd playwright-sample npm install npx playwright install chromium npx playwright test 1 passed になります。走ったテストの全文です。 tests/shopping.spec.js // @ts-check import { test, expect } from '@playwright/test'; test('品物を足してチェックすると、残りが減る', async ({ page }) => { await page.goto('/'); await page.getByLabel('品物').fill('牛乳'); await page.getByRole('button', { name: '追加' }).click(); await page.getByLabel('品物').fill('卵'); await page.getByRole('button', { name: '追加' }).click(); await expect(page.getByRole('listitem')).toHaveCount(2); await expect(page.getByRole('status')).toHaveText('残り 2 品'); await page.getByRole('checkbox', { name: '牛乳' }).check(); await expect(page.getByRole('status')).toHaveText('残り 1 品'); }); 操作は fill・click・check、確かめは expect です。 空のフォルダに2コマンドで入れ、設定を足す npm init playwright@latest -- --quiet --browser=chromium --lang=js --no-examples npx playwright init-agents --loop=claude 2つ目で .claude/agents/ に3つのエージェントが、.mcp.json に MCP サーバ(playwright-test)が入ります。サンプルの index.html を置き、設定に3か所足します。 playwright.config.js(足した所) reporter: [['list'], ['html', { open: 'never' }]], use: { baseURL: 'localhost', }, webServer: { command: 'npx http-server -p 8080 -s .', url: 'localhost', reuseExistingServer: true, }, tests/seed.spec.ts の // generate code here. は、await page.goto('/'); に替えます。seed は、エージェントが最初に走らせるテストです。設定を足さずに頼んだ回は、generator がページを開けず、画面を操作しないままテストを書きました。 generator を名指しして書かせる フォルダで claude を開くと MCP サーバを使うか聞かれます。カーソルは最初「Continue without using this MCP server」にあります。上へ2回動かし、「Use this MCP server」を選びます。 playwright-test-generator エージェントを使って、買い物メモ(index.html)のテストを書いて。計画書は無いので、品物を2つ足して、チェックすると残りが減ることを確かめるテストにして generator はブラウザで操作を通してから、テストを1本書き出しました(51秒〜1分3秒・4回)。読んで合っていれば、そのまま残します。名指しせず「テストを書いて」と頼んだ回は、サブエージェントが使われませんでした。 ボタン名を変えて、AI なしで走らせる index.html のボタン名を「追加」から「入れる」に変え、npx playwright test だけを打ちます。 ✘ 2 …s/shopping-memo/add-and-check-items.spec.ts:7:7 › 買い物メモ › 品物を2つ追加してチェックすると残りが減る (30.0s) Error: locator.click: Test timeout of 30000ms exceeded. Call log: - waiting for getByRole('button', { name: '追加' }) 赤になるまで30秒かかりました。Playwright は操作できるまで待ち、テスト1本の時間切れ(既定30秒)で赤にします。use に actionTimeout: 5000 を足すと、5.1〜5.3秒で赤になりました(既定では30.0〜30.1秒・各3回)。 止まった瞬間の画面は、トレースで見られます。 npx playwright test --trace on npx playwright show-report actionTimeout: 5000 で走らせたトレースの一部。「追加」を探して5秒待った Click が赤。そのときのページのボタンは「入れる」 healer にテストを直させる ボタン名をわざと変えたのなら、直すのはテストの側です。 テストが失敗してる。playwright-test-healer エージェントで直して テストのボタン名2か所が「入れる」に変わり、再実行は緑でした(42〜58秒・4回)。コメントに古い「追加」が残った回もあります。アプリの1行を消して頼んだ別の日の1回(2026-10-09・Claude Code 2.1.295)は、test.fixme を足して止まりました。スキップは結果の上では緑に見えるので、直した後も読みます。 #2 Test Agents で増やす:計画を絞って書かせ、6本の赤を1行で直させる #1 の設定を済ませた所から始めます。増やした後の状態は zun-tools/playwright-agents-sample にあり、AI なしで赤まで試せます。 planner に計画を5つまで書かせる playwright-test-planner エージェントで、買い物メモのテスト計画を作って。seed は tests/seed.spec.ts。tests/shopping.spec.js にテストが1本あるので、それと同じ筋書きは除いて、大事な順に5つまで 約1分で specs/ に日本語の計画(Markdown)ができました。テストを書く前に、何を確かめるかを読めます。絞らずに頼むと、21 と 23 シナリオでした(2回)。 generator に計画から書かせる playwright-test-generator エージェントで、specs/shopping-memo.plan.md の計画からテストを書いて 計画の1項目がテスト1本になり、5本で2分半〜3分半でした。AI なしで走らせると 7 passed、約1秒です。手書きの1本と seed を足した7本になります。緑になったら、計画とテストをコミットしておきます。 表示を1か所変えると、6本がまとめて赤 表示を「残り 1 / 2 品」に変えます。playwright-agents-sample の display-change.patch を git apply すると当たります。自分で変えるなら、index.html の次の3行です。 index.html - <p id="left" role="status">残り 0 品</p> - <p id="left" role="status">残り 0 / 0 品</p> function updateLeft() { const n = list.querySelectorAll('input[type=checkbox]:not(:checked)').length; - left.textContent = 残り ${n} 品; - const all = list.querySelectorAll('input[type=checkbox]').length; - left.textContent = 残り ${n} / ${all} 品; } 緑は seed だけ。「残り」を確かめる6本が、1本あたり5秒ちょっとで赤 エラーには Expected: "残り 0 品" と Received: "残り 0 / 0 品"、テストの行番号が出ます。1本が5秒ちょっとで赤になるのは、expect が既定で5秒まで待つからです。 1行頼んで直させ、差分を読む テストが失敗してる。playwright-test-healer エージェントで直して 約1分で、テスト6ファイルの期待値が新しい表示に直りました。index.html は触られず、再実行は 7 passed です(2回)。 全体の数が行ごとに違うので、一括の置き換えでは書けない 赤にして期待と実際を言葉で出すまでが Playwright で、直したのは Claude Code です。 計画は古いまま残る。 直した後も specs/ は旧表示のままでした。「計画(specs/)も今の表示に合わせて直して」と頼むと、19〜31秒で直りました(3回) アプリ側の変更が戻されることがある。 アプリを壊して頼んだ4回で、テストを壊れた値に合わせた回は0回でした。ただし3回は、聞かれないまま未コミットの変更が元に戻され、1回は戻してよいか聞いて止まりました。頼む前にコミットしておけば、git diff で取り返せます #3 GitHub Actions で回す:push だけでテストが走り、トレースで原因が分かる 打ち忘れた日も赤が出るよう、push のたびに GitHub Actions で走らせます。サンプル zun-tools/playwright-ci は、#1 のサンプルに package-lock.json と workflow を足した物です。雛形からは、Chromium だけ入れ、アクションの版を v6 に上げました。 .github/workflows/playwright.yml name: Playwright Tests on: push: branches: [ main, master ] pull_request: branches: [ main, master ] jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: lts/* - name: Install dependencies run: npm ci - name: Install Playwright Browsers run: npx playwright install --with-deps chromium - name: Run Playwright tests run: npx playwright test - uses: actions/upload-artifact@v6 if: ${{ !cancelled() }} with: name: playwright-report path: playwright-report/ retention-days: 30 自分のリポへ push して緑を見る git clone zun-tools/playwright-ci cd playwright-ci npm ci gh repo create <自分の名前>/playwright-ci --public git remote set-url origin github.com<自分の名前>/playwright-ci.git git push Actions のタブに緑が付きます。push から緑まで55秒と38秒でした。ubuntu-latest は 2026-10-19 から Ubuntu 26.04 へ順に移る、と GitHub が告知しています。ubuntu-26.04 を指定しても、緑2回・赤1回で同じ形でした。 ボタン名を変え、テストを打たずに push する index.html のボタン名を「入れる」に変えて、そのまま push します。 git commit -am "ボタン名を変える" git push テストのコマンドは打たず、push から130秒で ✗ が付きました。 Actions の一覧。ボタン名を変えたコミットだけが ✗ になり、戻すと緑に戻った 赤が長いのは、CI では再試行が2回入る(retries: process.env.CI ? 2 : 0)からです。30秒待つ赤が3回続きます。gh run view <番号> --log-failed でも、待っていた操作とテストの8行目が出ます。 知らせは設定しだいです。試し用の公開リポでは、失敗のときだけ通知とメールの両方が届きました(2026-10-11)。サンプルの上の赤では、メールは届いていません(2026-10-18)。 自分のリポで受け取るなら、Settings → Notifications の Actions の欄で Email を確かめます。一覧の ✗ は、知らせが無くても残ります。 レポートを落とし、再試行1回目のタブからトレースを開く gh run list gh run download <番号> -n playwright-report -D ci-report npx playwright show-report ci-report -D を付けないと gh run download は失敗します。 失敗したテストのページには Run・Retry #1・Retry #2 の3つのタブがあり、トレースは Retry #1 にだけあります(雛形の trace: 'on-first-retry')。 最初の Run のタブにはトレースが無い。Retry #1 を開く GitHub の上で走ったテストの、止まった瞬間の画面。ボタンは「入れる」になっている うっかりならボタン名を戻し、わざとならテストを直します。レポートには画面がそのまま写るので、秘密の入る画面は扱いに気をつけてください。 #4 MCP と CLI:同じ確認を4通りで比べ、残すか見させるかを選ぶ Playwright の README は、使い道に「テスト・スクリプト・AI エージェントの道具」の3つを挙げています。AI 向けには、本体に MCP サーバと CLI(playwright-cli)が入っています。同じ確認を4通りでやって並べました。確認は「牛乳と卵を足し、牛乳にチェックすると『残り 1 品』になるか」です。サンプルは zun-tools/playwright-scripts-sample です。 git clone zun-tools/playwright-scripts-sample cd playwright-scripts-sample npm install npx playwright install chromium npm run serve # 別の端末で開いたままにする テストとスクリプト:先に書いた物を AI なしで走らせる 1通り目は npx playwright test で、#1 と同じテスト1本です。2通り目は、テストの枠と expect を外した12行のスクリプトです。 scripts/check.mjs // 買い物メモの定型確認(テストランナーを使わない Playwright ライブラリのスクリプト) import { chromium } from 'playwright'; const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto('localhost'); for (const name of ['牛乳', '卵']) { await page.getByLabel('品物').fill(name); await page.getByRole('button', { name: '追加' }).click(); } await page.getByRole('checkbox', { name: '牛乳' }).check(); console.log(await page.getByRole('status').textContent()); await browser.close(); node scripts/check.mjs は、合否ではなく読んだ表示(「残り 1 品」)を出します。スクショや PDF にも同じ書き方が使えます。 MCP と CLI:何も書かずに AI に見させる 既定では手元の Chrome を使いますが、Chrome の無い Linux では入れ方を案内されて止まりました。そこで Playwright が入れた Chromium を指しました。MCP は flag を2つ、CLI はサンプルの .playwright/cli.config.json で指しています。 MCP(Chrome の無い Linux で足した形) claude mcp add --scope project playwright -- npx playwright mcp --headless --executable-path <Chromium の場所> CLI(使い方を教えるスキルをフォルダに入れる) npx playwright cli install --skills <Chromium の場所> は、npx playwright install --dry-run chromium の「Install location」の下の chrome-linux*/chrome です。 公式の説明では、1.62 から MCP サーバと playwright-cli は本体に同梱されています。 頼む文は次のとおりです。MCP のときは、最後の1文を「ブラウザの操作には playwright MCP のツールを使うこと。」に変えます。 localhost の買い物メモで、品物「牛乳」と「卵」を追加してから「牛乳」にチェックを入れると、画面の表示が「残り 1 品」になるか確かめてください。最後に「はい/いいえ」と根拠を1行で答えてください。ブラウザの操作には playwright-cli(このフォルダでは npx playwright cli)を使うこと。 CLI で頼んだ1回目。スキルを読んでからコマンドを打ち、28秒で「はい」と答えた 答えは4回とも「はい」でした。MCP は道具を1つずつ呼び、CLI はスキルを読んでからコマンドをまとめて打ちます。 4通りを並べて選ぶ npm run serve のページは開いたままにしました。AI の2つは1回ごとに Claude Code を開き直し、「AI の往復」は道具やコマンドを呼んだ回数です。 やり方 先に書く物 出る物 かかった時間 AI の往復 テスト テスト1本 合否(1 passed) 0.92・0.87秒 0回 スクリプト 12行 読んだ表示「残り 1 品」 0.43・0.41秒 0回 MCP なし 「はい」と根拠 18・25秒 6・7回 CLI なし 「はい」と根拠 28・26秒 5・6回 表の秒数は、ほかの作業が走っていないときの値です。選ぶ基準は桁の差で、残す2つは1秒かからず AI 0回。見させる2つは、頼むたびに20秒前後かかります。 MCP と CLI の間は、速さでは選びません。公式は、playwright-cli は Claude Code などのコーディングエージェントに向き、MCP は状態を持ち続ける長い作業に向くと書いています。 使い所は、表から次のように選べます。 明日も同じ確認をする → テストに残す。2回目も1秒かからず、変わったら赤で知らせる 確かめではない作業(PDF・定型の入力) → スクリプト テストのまだ無い画面を、いま一度だけ見る → AI に見させる。Claude Code なら、まず CLI 見させた確認を何度も使う → テストへ移す(#1 の generator) まとめ generator は操作を通してテストを書き、残したテストは AI なしで回る。画面が変わると止まった所を出して赤になる(既定30秒・actionTimeout で約5秒) 計画を絞って書かせればテストは増え、6本の赤も1行の頼みで直る。頼む前にコミットし、後で差分と計画を読む workflow を足せば push のたびに走り、赤は約2分で ✗ になる。トレースは再試行1回目のタブにある MCP と CLI は書かずに見させる道具で、毎回時間がかかる。繰り返す確認はテストに残す まずは #1 のサンプルで緑を見て、「追加」を「入れる」に変えてもう一度走らせてください。 動画で見たい方はこちら(全4本): #1 テストを書かせて残す #2 Test Agents で増やし、直させる #3 GitHub Actions で回す #4 MCP と CLI を使い分ける 参考リンク microsoft/playwright(公式リポジトリ) Playwright 公式 docs: Playwright Test Agents Playwright 公式 docs: Installation Playwright 公式 docs: Locators Playwright 公式 docs: Auto-waiting Playwright 公式 docs: Timeouts Playwright 公式 docs: Trace viewer Playwright 公式 docs: Continuous Integration Playwright 公式 docs: Trace viewer intro Playwright 公式 docs: Library Playwright 公式 docs: Getting started - MCP Playwright 公式 docs: Getting started - CLI GitHub Docs: GitHub Actions の料金 GitHub Docs: Actions の通知の受け取り方 Claude Code 公式 docs: Permission modes
Fix "APNS token has not been set yet" in Flutter on iOS --- title: Fix "APNS token has not been set yet" in Flutter on iOS published: true date: 2026-11-05 00:00:00 UTC tags: flutter,ios,firebase,dart canonical_url: saymyframe.com --- On iOS, Firebase delivers push notifications through Apple's push service (APNs), so FirebaseMessaging.instance.getToken() refuses to run until Apple has given the device its APNs token. Called earlier, it throws apns-token-not-set, which older versions of firebase_messaging describe as "APNS token has not been set yet". Usually the app just asked too early. How it should wait depends on the version: firebase_messaging 17 tells the app when Firebase is ready, and 16.7 doesn't. In a few setups, though, Apple never answers, however long the app waits. I checked everything below on an iPhone 17 simulator with iOS 26.5, Xcode 27.2 and Flutter 3.44.2, with firebase_messaging 16.7.1 (Firebase iOS SDK 12.19.3) and 17.0.1 (Firebase iOS SDK 13.1.1). APNS token has not been set yet, or has not been received on the device yet The error comes in two wordings. Older versions say: [firebase_messaging/apns-token-not-set] APNS token has not been set yet. Please ensure the APNS token is available by calling `getAPNSToken()`. Since October 2025 (firebase_messaging 16.0.3, with firebase_messaging_platform_interface 4.7.3), the same error says: [firebase_messaging/apns-token-not-set] APNS token has not been received on the device yet. Please ensure the APNS token is available before calling `getAPNSToken()`. Both are a FirebaseException with the code apns-token-not-set, so check the code rather than the text. The hint to call getAPNSToken() is misleading, because that call never throws. It returns null until Apple has answered, which makes it the thing to wait on. The check runs in Dart, before anything reaches iOS. getToken(), deleteToken(), subscribeToTopic() and unsubscribeFromTopic() all start with it. So a topic subscription at startup fails with the same error, while requestPermission() and onMessage work. If you use Firebase from Swift, the native SDK reports the same situation as No APNS token specified before fetching FCM Token. Why getToken() fails right after the app starts At launch, the plugin asks iOS to register the app for remote notifications, and iOS answers later, in application:didRegisterForRemoteNotificationsWithDeviceToken:. The Firebase iOS SDK keeps that answer in memory only. So an early getToken() fails on every launch, not just the first one, even when Firebase gave the app its FCM token on the previous launch. How long the answer takes varies: about half a second on a simulator that had been running for a while, and about 30 seconds right after the simulator booted. On a phone, it depends on the network. I checked this with a small app that calls getToken() right after Firebase.initializeApp(), after a fresh install and after a restart, with both versions. The call failed every time, although Apple answered within a second. What differs between the versions is whether the app learns that Firebase is ready. firebase_messaging 16: wait for getAPNSToken() With 16.7.1, the Firebase iOS SDK requests the FCM token by itself as soon as Apple answers (the log shows the app connecting to fcmtoken.googleapis.com), but the plugin doesn't pass it on to Dart: onTokenRefresh stayed silent for the 20 seconds I waited, after a fresh install and after a restart. So the app has to wait for getAPNSToken() itself, then call getToken(): import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; /// The FCM token of this device once Apple has answered, or `null` when /// Apple hasn't within [limit]. Future<String?> pushTokenWhenReady({ Duration limit = const Duration(minutes: 2), }) async { final messaging = FirebaseMessaging.instance; if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { var wait = const Duration(milliseconds: 500); var waited = Duration.zero; while (await messaging.getAPNSToken() == null) { if (waited >= limit) return null; await Future<void>.delayed(wait); waited += wait; if (wait < const Duration(seconds: 8)) wait *= 2; } } return messaging.getToken(); } Don't await it before runApp(). I found apps on GitHub that opened to a white screen on iOS for exactly this reason: main() waited, getToken() threw, and runApp() never ran. Start the app first and send the result to your server when it arrives: Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); runApp(const MyApp()); final token = await pushTokenWhenReady(); if (token != null) await sendTokenToServer(token); } If the wait runs out, try again when the user comes back to the app, for example from an AppLifecycleListener with onResume. firebase_messaging 17: listen to onTokenRefresh Since 17.0.0, released in October 2026, the plugin becomes the delegate of the Firebase iOS SDK during Firebase.initializeApp(), so onTokenRefresh gets two events that 16 lost. After a fresh install, it fires as soon as the SDK has the FCM token, in my runs 1.4 seconds after Apple answered. On every later launch, it fires right at startup with the value that the SDK saved last time: 0.2 seconds after main() started, before Apple had answered and while getToken() still threw. So on 17, subscribe to onTokenRefresh right after initializeApp(), and keep the getToken() call for Android, where it doesn't depend on Apple: Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); final messaging = FirebaseMessaging.instance; messaging.onTokenRefresh.listen(sendTokenToServer); runApp(const MyApp()); try { final token = await messaging.getToken(); if (token != null) await sendTokenToServer(token); } on FirebaseException catch (e) { // On iOS before Apple has answered: onTokenRefresh delivers it. if (e.code != 'apns-token-not-set') rethrow; } } Your server may get the same value twice this way, so it should accept one that it already has. Subscribe early, because onTokenRefresh is a broadcast stream and a listener that comes after the startup event never sees it. If your code subscribes later, after a sign-in for example, wait for getAPNSToken() as with 16. Firebase is also moving Cloud Messaging from registration tokens to the Firebase Installation ID (FID). Version 17 deprecates getToken(), onTokenRefresh and deleteToken() in favor of register(), onRegistered and unregister(), and flutter analyze exits with code 1 on the code above because of the deprecation infos. I haven't tried the FID path yet, so this article stays with getToken(). When the APNs token never comes If getAPNSToken() still returns null after a minute, waiting longer won't help. These are the cases I know of. No aps-environment entitlement iOS registers an app for remote notifications only when the build is signed with the aps-environment entitlement, which Xcode adds with the Push Notifications capability in Signing & Capabilities. Without it, the Xcode console shows: [FirebaseMessaging][I-FCM012002] Error in application:didFailToRegisterForRemoteNotificationsWithError: no valid "aps-environment" entitlement string found The simulator follows the same rule. A build made with flutter build ios --simulator --no-codesign doesn't get the entitlement, and mine waited two minutes for Apple in vain. A free Apple team A personal team can't sign aps-environment. To run the app on an iPhone, you have to remove the capability, and then Apple never answers there. The simulator doesn't need a team, since Xcode signs simulator builds ad hoc. The iOS simulator on GitHub Actions The iOS simulator on a Mac gets a real APNs token. On a macOS virtual machine it never does, and the macOS runners of GitHub Actions are virtual machines. I tried a macos-26 runner: the app was built with the entitlement, nothing came in four minutes, and the push daemon logged no token info found in keychain before it disconnected. actions/runner-images#12747 reports the same for macos-15. A CI job on a hosted runner can check that the app starts without push notifications, but not that it gets them. firebase_messaging 16.5.0 and 16.6.0 These two versions skipped the registration for remote notifications at launch in two common setups: when Firebase is initialized from Dart, as with Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) (flutterfire#18643), and in apps with scenes (flutterfire#18555), which the iOS template of current Flutter creates with SceneDelegate.swift. getAPNSToken() then stayed null on every launch. Version 16.7.0 fixed both. Check which version your app has: flutter pub deps | grep firebase_messaging Auto-init is off With FirebaseMessagingAutoInitEnabled set to NO in Info.plist, or after setAutoInitEnabled(false), the plugin doesn't register for remote notifications. Call setAutoInitEnabled(true) before you start waiting. Not the APNs key The APNs key in the Firebase console doesn't belong on this list. Firebase needs it to send messages through Apple, so without it nothing arrives, but getAPNSToken() and getToken() work all the same. How SMF handles push notifications on iOS The firebase_messaging module of SMF, a free, open-source Flutter project generator, gives the app the address of the device as appPush.address, which stays null until Firebase has one. The first frame never waits for it. On iOS, the module polls getAPNSToken() as above, waiting from half a second up to eight seconds between tries, for at most two minutes, and asks again whenever the user comes back to the app. It adds aps-environment and the background mode remote-notification, and the README of the app explains what to do with a free team. Uploading the APNs key stays a step you do by hand. SMF's CI starts the apps on the iOS simulator of a hosted runner, where they have to start and work without an address.
初めてのCI/CD。チェックが失敗したままマージしても、公開は止められた(GitHub Actions) 1. はじめに 前回の記事では、GitHubにpushすると、GitHub ActionsがCloudflare Workersへ自動で公開するしくみを作りました。 ただ、このしくみには弱点があります。mainにpushすると、中身に間違いがあっても、そのまま公開されてしまいます。 今回の検証は小さな静的ページなので、それでも大きな問題にはなりません。でも、仕事で扱う大きな動的ページでは、そうはいきません。公開する前に、自動で確かめるしくみが必要だと思いました。 そこで今回は、公開の前にHTMLをチェックして、間違いがあれば公開しないようにしました。さらに、わざとHTMLを壊して、本当に公開が止まるのかを確かめています。 イベント・on:・ランナー・既定値の説明は、前回の記事に書いたので省略します。この記事では、前回と違うところだけを説明します。 なお、記事の内容は、断りがない限り自分の環境で動かして確かめていますが、CI/CDは初心者です。間違いなどがあれば、コメントで教えていただけるとうれしいです。 2. 作ったもの プルリクエストを作るとチェックだけが動き、mainにマージするとチェックのあとに公開されるしくみです。 flowchart TD PR["プルリクエストを作る<br>ブランチにpushする"] --> E1["pull_request イベント"] E1 --> C1["check.yml が単独で動く"] C1 --> R1["緑か赤を見せるだけ<br>公開はしない"] M["マージする<br>mainにpushする"] --> E2["mainへのpush イベント"] E2 --> D["deploy.yml が動く"] D --> C2["check ジョブ<br>check.yml を呼ぶ"] C2 -- "成功" --> S["deploy ジョブ<br>Cloudflareへ公開"] C2 -- "失敗" --> X["deploy は実行されない<br>公開中のページはそのまま"] check.ymlには入口が2つあります。プルリクエストの道は、マージする前に結果を知るためのものです。mainの道は、公開の前の関所です。 3. 手順 環境は前回と同じです。無料プランのPrivateリポジトリを使っています。チェックはGitHub Actionsの中で動くので、自分のPCにNode.jsは入れていません。 ファイルは2つ足し、1つ変えました。 tobaru-net-test/ ├─ .htmlvalidate.json … チェックのルール(新規) └─ .github/ └─ workflows/ ├─ check.yml … チェックの指示書(新規) └─ deploy.yml … チェックを呼ぶように変更 (1) チェックのルールを決める HTMLのチェックにはhtml-validateを使いました。ルールを自分で作らなくても、できあいのrecommendedがあります。 .htmlvalidate.jsonです。 { "extends": ["html-validate:recommended"] } (2) check.ymlを書く name: Check on: # mainに向けたプルリクエスト pull_request: branches: [main] # ほかのワークフローから uses: で呼ばれたとき workflow_call: jobs: html: runs-on: ubuntu-latest permissions: contents: read steps: # ランナーにファイルを取り出す - uses: actions/checkout@v5 # public/ の中の .html を調べる - name: Validate HTML run: npx --yes html-validate@11.18.0 public/ (3) deploy.ymlを変える 変えたのは、checkジョブの追加とneeds: checkだけです。 jobs: # 公開の前の関所。中身は check.yml check: uses: ./.github/workflows/check.yml deploy: # check が成功したときだけ動く needs: check runs-on: ubuntu-latest # これ以降は前回と同じ 4. 試してみた 結果を先に書くと、チェックが赤のとき、マージは止まりませんでしたが、公開は止まりました。 | やったこと | 結果 | |---|---| | そのままのページでプルリクエストを作る | 赤 | | 指摘されたところを直して、同じブランチにpush | 緑 | | 緑のままマージ | check→deployと動き、公開された | | </main>を消して、赤のままマージ | マージはできたが、deployは実行されず、公開中のページは変わらなかった | (1) 壊す前に、もう赤になった ブランチをpushしただけでは、何も動きません。check.ymlの条件はプルリクエストで、deploy.ymlの条件はmainへのpushだからです。プルリクエストを作ると、Check / htmlが動き始めました。 !01_pr-running.png わざと壊すのは、この次の予定でした。ところが、何も壊していないのに赤になりました。 !02_check-failed.png ログを開くと、原因が書いてあります。 !03_validate-log.png index.htmlの1行目の<!doctype html>が、小文字だと指摘されています。ブラウザでは正しく表示されていますが、recommendedのdoctype-styleというルールは大文字を求めます。 (2) 直してpushすると、緑になった <!DOCTYPE html>に直して、同じブランチにpushしました。プルリクエストを作り直さなくても、チェックは自動で動き直します。 !04_check-passed.png (3) 緑のままマージすると、公開された マージするとmainへのpushが起きて、deploy.ymlが動きます。checkのあとにdeployが動き、公開中のページも<!DOCTYPE html>に変わりました。 !05_graph-success.png (4) 赤のままマージしても、公開は止まった いよいよ本番です。別のブランチで</main>を消して、プルリクエストを作りました。チェックは赤になりましたが、Merge pull requestのボタンは押せます。 !06_failed-but-mergeable.png あえてマージすると、checkが赤になり、deployはスキップされました。0秒なのは、一度も始まっていないからです。 !07_graph-skipped.png このときmainのコードからは</main>が消えていましたが、https://test.tobaru.netには</main>が残っていました。コードは壊れたが、公開中のページは守られたということです。 検証のあとは、壊した変更をgit revertで取り消して、もう一度プルリクエストからマージしました。チェックは緑で、公開も通っています。 5. なぜ公開が止まるのか 公開を止めているのは、deploy.ymlのneeds: checkです。画面の設定は何も変えていません。 flowchart TD A["html-validate が間違いを見つける"] --> B["終了コード1で終わる"] B --> C["check ジョブが失敗になる"] C --> D["needs: check の条件を満たさない"] D --> E["deploy ジョブはスキップ"] E --> F["wrangler が動かない"] F --> G["Cloudflare に何も届かない"] G --> H["公開中のページはそのまま"] (1) 赤と緑は、何で決まるの? コマンドの終了コードで決まります。0なら成功、0以外なら失敗です。 4章のログの最後にも、Process completed with exit code 1.とあります。Actionsは「DOCTYPE should be uppercase」という文章を読んでいるのではありません。公式ドキュメントにも、ステップの成功と失敗は終了コードに基づいて報告されると書かれています(Workflow syntax)。 つまり、間違いがあるときに0以外で終わるコマンドなら、何でもチェックとして使えます。 (2) needs:がないと、どうなるの? checkとdeployが同時に動くので、チェックが赤でも公開されるはずです(ここは試していません)。 ジョブは既定で並列に動きます。上から順に書いてあっても、順番はつきません。needs:を書くと、次の決まりが効きます。 > If a job fails or is skipped, all jobs that need it are skipped 必要としているジョブが失敗すると、そのジョブはスキップされます。この決まりのおかげで、deployが赤を判断する処理は一切書いていません。 (3) なぜcheck.ymlを別のファイルにしたの? プルリクエストとmainの両方で、同じチェックを使いたかったからです。 ただ、needs:は同じファイルの中のジョブしか指定できません。そこでcheck.ymlのon:にworkflow_callを書いて、ほかのワークフローから呼べるようにしました(公式ドキュメント)。deploy.ymlのcheckジョブは、それをuses:で呼んでいるだけです。 deploy.ymlの中にジョブとしてcheckがあるので、needs: checkと書けます。 プルリクエストのときのチェックと、マージのときのチェックは別の実行です。公開を止めているのは、マージのときの結果です。 (4) なぜマージは止まらなかったの? 無料プランのPrivateリポジトリでは、マージを止めるルールが効かないからです。 GitHubには、チェックが緑にならないとマージできなくする設定があります。ただ、SettingsのRulesetsを開くと、次の警告が出ていました。 !08_rulesets-warning.png 古い形式のBranch protection ruleも同じです。ルールは作れますが、リポジトリをTeamなどの組織アカウントに移すまで適用されません。 !09_branch-protection-warning.png だから、止めているものを分けて考える必要があります。 | しくみ | 止めるもの | 今回の環境 | |---|---|---| | Rulesets・Branch protection rule | マージ | 効かない | | needs: check | 公開 | 効く | needs:では、mainのコードが壊れることは防げません。防げるのは公開だけです。それでも、ワークフローのファイルに書くだけなので、無料プランでも使えます。 6. 前回と違うところ しくみ自体は前回と同じで、イベントが起きたらon:に合うワークフローが動きます。初めて出てきたものだけを説明します。 (1) pull_requestは、いつ動くの? types:を書かなければ、opened・synchronize・reopenedの3つで動きます(公式ドキュメント)。 | 名前 | いつ | |---|---| | opened | プルリクエストを作った | | synchronize | ブランチに新しいcommitをpushした | | reopened | 閉じたプルリクエストを開き直した | 4章の(2)で、pushしただけでチェックが動き直したのはsynchronizeのおかげです。これも前回と同じ、書かなかったときの既定値です。 (2) uses:のactions/checkout@v5は何? 持ち主/リポジトリ@バージョンの形で、GitHub上で公開されている部品を借りる書き方です。actions/checkoutは、GitHubが公開しているactions/checkoutのことです。 前回のcloudflare/wrangler-action@v4も、同じ形です。 (3) なぜcheckoutが必要なの? ランナーは毎回新しく用意され、リポジトリのファイルは入っていないからです。この行がないと、調べるpublic/がありません。 プルリクエストのときに取り出されるのは、ブランチの先頭ではありません。actions/checkoutは既定で、イベントの対象のcommitを取り出します(action.yml)。pull_requestの場合、それはrefs/pull/番号/mergeです。 つまり、「mainにマージしたらこうなる」という仮のcommitを調べています。ブランチ単体では問題がなくても、マージ後に壊れる場合に気づけます。 (4) run:は何? ランナーのシェルでコマンドを実行する書き方です。今回のコマンドはこれだけです。 npx --yes html-validate@11.18.0 public/ | 部分 | 意味 | |---|---| | npx | npmのパッケージを取ってきて実行する。package.jsonがなくても動く | | --yes | インストールの確認に答える人がいないので、先に「はい」と渡す | | @11.18.0 | バージョンを固定する。新版のルール追加で、ある日突然赤にならないように | | public/ | 調べるフォルダ。中の.htmlをすべて調べる | プルリクエストで変更したファイルだけを調べるのではありません。実際、4章の(1)の赤は、プルリクエストで変更していないindex.htmlで出たものです。 今回は小さなページ1つなので、全部調べても問題ありません。規模が大きくなって時間がかかるようになったら、変更したファイルだけに絞ることも検討が必要かもしれません。 Node.jsを入れる手順はありません。GitHubのランナーには最初からNode.jsが入っています。 7. ほかの言語のチェックツール 今回はHTMLだけですが、言語が変わってもチェックの入れ方は同じです。run:に書くコマンドが変わるだけです。 | 対象 | ツール | チェックするもの | |---|---|---| | HTML | html-validate(今回使った) | 文法・書き方 | | HTML | HTMLHint | 書き方 | | HTML | lychee | リンク切れ | | JavaScript / TypeScript | ESLint | 書き方 | | TypeScript | tsc | 型 | | Python | Ruff | 書き方 | | Python | mypy | 型 | | 共通 | Prettier | 整形 | | 共通 | gitleaks | コードに混ざったパスワードなど | こうしたツールには、今回のrecommendedのようにできあいのルールが用意されているものが多くあります。自分で作り込む必要があるのは、アプリの動きを確かめるテストの中身くらいです。 8. まとめ プルリクエストの時点で間違いに気づけて、うっかりマージしてしまっても、公開中のページは守られます。この2つを実際に確かめられたので、今回の検証は成功だと思います。 今回分かったことを並べると、次の3つです。 - 赤と緑を決めるのは、コマンドの終了コード。Actionsは出力の文章を読んでいない - ジョブは既定で並列に動く。needs:を書くと、相手が失敗したときにスキップされる - 無料プランのPrivateリポジトリではマージは止められない。それでも、公開はneeds:で止められる 次は、プルリクエストを作るとAIがレビューしてくれるしくみを試す予定です。
@edgarrmondragon And thank you for the PR that unblocked 3.16! 🚀
初めての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というイベントに決まりを足すだけで作れるはずです。
macOS runners are vulnerable to the eMudhra intermediate CA compromise · Issue #15228 · actions/runner-images : 👀 --- runner-images#15228