手順書に「デプロイ前に検査せよ」と書いても守られない — ゲートはラッパの中に置く

「本番へ出す前に検査を通すこと」と手順書に書いたのに、実際の運用では検査がデプロイの後に走っていた。書いてあったのに守られなかったのではなく、守れる形になっていなかった。今日その原因を潰したので、何が起きていたのかと、どう直したのかを書く。

起きていたこと

自動更新している複数のサイトで、公開の直前に検査を通す仕組みを作ってあった。絵文字の混入、Amazonリンクのタグ抜け、期限切れ記事が一覧に残っていないか、テストが通るか、といった項目をまとめて見るものだ。手順書にはこう書いてあった。

build の後・deploy の前に必ず preflight を実行する。exit 1 なら deploy しない。

ところが、いくつかのサイトは build と deploy が1つのラッパコマンドになっていた。

1
node automation/scripts/manga/build-and-deploy.js --deploy

このコマンドの中で node build.js が走り、続けて firebase deploy が走る。実行する側から見ると、build と deploy の「あいだ」という時点が存在しない。手順どおりに検査を入れようとすると、実際にはこうなる。

1
2
node automation/scripts/manga/build-and-deploy.js --deploy   # ← ここで本番に出ている
node automation/run.js preflight --site manga-deals # ← 出したあとで検査

実際にこの順で実行された回があった。そのときは全項目が通ったので実害は出ていない。ただし、落ちていれば本番に出したあとで気づくことになっていた。検査の意味がほぼ失われる位置だ。

手順書を直すのは対策にならない

最初に考えたのは、手順書の文言を強くすることだった。「必ず」を太字にする、順序を図にする、といった類だ。しかしこれは効かない。割り込む余地がない場所に、割り込めと書いているだけだからである。

実行する側にできることは2つしかなかった。

  1. ラッパを使わず、build.jsfirebase deploy を手で分けて実行する
  2. ラッパを使い、検査を前後どちらかに置く

1 は、ラッパを作った理由(手順の取り違え防止)を捨てることになる。このラッパは「単純な firebase deploy を使ってはいけない」といったサイト固有の事情を吸収するために存在するので、迂回させるのは本末転倒だ。

残るのは 2 で、そして「前」は物理的に選べない。つまり構造上、検査は後ろに置くしかなかった。守られなかったのではなく、守れなかったのである。

ラッパの内側にゲートを置く

直し方は単純で、ラッパ自身が build → preflight → deploy の順に走るようにした。共通部分は1つの関数に切り出してある。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// automation/scripts/common/deploy-gate.js
function deployWithGate({ siteKey, steps, skipPreflightArg, deployFn, gateFn }) {
const { skip, reason } = parseSkipPreflight(skipPreflightArg);
if (skip) {
steps.push(`preflight:skip(${reason})`);
deployFn();
steps.push("deploy:ok");
return { ok: true, skipped: reason };
}
const gate = (gateFn || gateBeforeDeploy)(siteKey);
if (!gate.ok) {
steps.push("preflight:fail");
return { ok: false, gate }; // deployFn を呼ばずに返る
}
steps.push("preflight:ok");
deployFn();
steps.push("deploy:ok");
return { ok: true, gate };
}

肝は deployFn引数で受け取っているところだ。deploy 本体を呼び出し側から渡させることで、「ゲートを通らずに deploy を呼ぶ」という書き方が構造的にできなくなる。呼び出し側はこうなる。

1
2
3
4
5
6
7
8
9
10
11
const gated = deployWithGate({
siteKey: "manga-deals",
steps,
skipPreflightArg: args["skip-preflight"],
deployFn: () =>
run("firebase", ["deploy", "--only", "hosting", "--project", PROJECT], REPO),
});
if (!gated.ok) {
emit({ ok: false, step: "preflight", steps, failed: gated.gate.failed });
process.exit(1);
}

実行すると、成功時の出力に順序がそのまま残る。

1
2
3
4
{
"ok": true,
"steps": ["check:ok(bib)", "build:ok", "preflight:ok", "deploy:ok"]
}

この steps 配列は記録として残るので、後から「その回はどの順で走ったか」を確認できる。検査が通ったかどうかだけでなく、通った位置が分かる。

「本番へ出す瞬間」はコマンド名では決まらない

同じゲートを別のサイトへ広げるときに、1つ引っかかった点がある。そのサイトは firebase deploy を使っていない。

Firestore にデータを取り込み、そこから全ページを再生成して配信する独自の仕組みになっていて、公開の実体は2つのHTTP関数の呼び出しだった。

1
2
3
// これが「本番へ出す瞬間」
await callFn("setupInitialData"); // seed を Firestore へ取り込む
await callFn("redeployHosting"); // Firestore の現行データから再生成して配信

他のサイトと同じ発想で「firebase deploy の直前」を探しても見つからない。ここで置くべき位置は、コマンドの名前ではなく副作用が外に出る地点で決まる。このサイトでは --seed--redeploy が指定された回だけ、HTTP関数を叩く手前にゲートを置いた。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (args.seed || args.redeploy) {
const { skip, reason } = parseSkipPreflight(args["skip-preflight"]);
if (skip) {
steps.push(`preflight:skip(${reason})`);
} else {
const gate = gateBeforeDeploy("amazon_site");
if (!gate.ok) {
steps.push("preflight:fail");
emit({ ok: false, step: "preflight", steps, failed: gate.failed });
process.exit(1);
}
steps.push("preflight:ok");
}
}

逆に、ビルドだけを走らせる --astro-check や、関数バンドルだけを更新する --deploy-fn は対象から外した。配信を伴わない操作でゲートに落とされると、検査と操作が噛み合わなくなるためだ。「危険そうなコマンド全部で検査する」ではなく「外に出る操作でだけ検査する」のほうが、結果的に無効化されにくい。

飛ばすときは理由を必須にする

どうしても検査を飛ばしたい回はある。外部サービスの障害で検査自体が実行できない、といった場合だ。そこで --skip-preflight を用意したが、理由を書かないと通らないようにした。

1
2
3
4
5
6
7
8
9
10
11
function parseSkipPreflight(raw) {
if (raw === undefined || raw === false) return { skip: false };
if (typeof raw !== "string" || !raw.trim()) {
const e = new Error(
'--skip-preflight には理由が必須です(例: --skip-preflight "GA障害で seo-must が測れないため")。'
);
e.exitCode = 2;
throw e;
}
return { skip: true, reason: raw.trim() };
}

理由なしの --skip-preflight は exit 2 で落ちる。そして飛ばした場合も、飛ばした事実と理由が stepspreflight:skip(GA障害で...) として残る。

ここは意図的な設計で、「飛ばせない」ではなく「黙って飛ばせない」にしてある。飛ばせない仕組みにすると、緊急時に迂回する別経路が生まれて、そちらには何の記録も残らなくなる。逃げ道を塞ぐより、逃げ道に記録装置を付けるほうが実効性がある。

回帰テストで位置を固定する

実装しただけでは、次の改修で元に戻る。ゲートが deploy より前にあることを、テストで固定した。

関数を持つラッパは、ゲートが落ちたときに deploy が呼ばれないことを直接確かめられる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
test("ゲートが落ちたら deployFn を呼ばない", () => {
let deployed = false;
const fakeGate = () => ({ ok: false, exitCode: 1, failed: [{ name: "emoji" }], checks: [] });
const steps = [];
const res = deployWithGate({
siteKey: "manga-deals",
steps,
skipPreflightArg: undefined,
gateFn: fakeGate,
deployFn: () => { deployed = true; },
});
assert.equal(deployed, false, "不合格なのに deploy している");
assert.deepEqual(steps, ["preflight:fail"]);
});

独自配信のサイトは形が違うので、ソース上の位置関係を見るテストにした。

1
2
3
4
5
6
7
8
9
test("amazon_site も seed/redeploy の前にゲートを通している(ソース検査)", () => {
const src = fs.readFileSync(path.join(__dirname, "..", REL), "utf8");
const gateAt = src.indexOf('gateBeforeDeploy("amazon_site")');
for (const fn of ["setupInitialData", "redeployHosting"]) {
const callAt = src.search(new RegExp(`callFn\\(\\s*"${fn}"`));
assert.ok(callAt > 0, `${fn} の呼び出しが見つからない`);
assert.ok(gateAt < callAt, `${fn}(本番へ出す瞬間)より前にゲートが無い`);
}
});

ソースを文字列として検査するのは行儀のよいやり方ではないが、「呼ばれたか」ではなく「どこに書かれているか」を固定したい場合には有効だ。実際に位置が動けば落ちる。

効いていることの確認

同じ日に、このゲートが実際に仕事をした。あるサイトへ新しいページを追加してデプロイしようとしたところ、検査で止まった。

1
2
3
preflight:fail
nav-links: 回遊導線が欠けた記事 1/64本:
manage-costs-effectively.html(article-nav,related-section,related-actions)

前後の記事へのリンクと関連記事のブロックを付け忘れていた。以前の形なら、これは公開されてから気づく(あるいは気づかない)類の抜けだった。リンクを足して再実行したら通り、そのまま公開された。手を止められた回数がゼロでない時点で、この仕組みは元が取れている。

なお、このゲートを通して公開している成果物のひとつがガジェットデスクLabで、Firestore からページを再生成して配信する例として本文に出したサイトはこれにあたる。

まとめ

手順書に書いても守られない手順があったとき、疑うべきは実行者の注意力ではなく、その手順を守れる形になっているかのほうだ。build と deploy が1コマンドなら、そのあいだに何かを挟めと書くのは無理を言っている。

  • ゲートは手順書ではなくコマンドの構造に埋める
  • deploy 本体をコールバックで受け取ると、ゲートを迂回する書き方ができなくなる
  • ゲートを置く位置は、コマンド名ではなく副作用が外に出る地点で決める
  • 飛ばす経路は残す。ただし理由を必須にして記録に残す
  • 位置関係は回帰テストで固定する。実装しただけでは次の改修で戻る

手順書に「デプロイ前に検査せよ」と書いても守られない — ゲートはラッパの中に置く
https://blog.hashito.biz/2026/08/04/deploy-gate-inside-the-wrapper-not-the-runbook/
著者
hashito
作成日
2026年8月4日
著作権