
はじめに
Woodpecker CI を運用していると、push や pull_request では問題なく動くのに、cron で動かしたときだけ pipeline が error で止まることがあります。
ログには secret not found と出ますが、スクリプトを読み返しても原因が見当たりません。
犯人は、repo secret の Events 設定から cron のチェックが外れていたことでした。
この記事では、この「secret の Events 設定ずれ(drift)」を夜間ヘルスチェックで自動検出する仕組みを、Node.js スクリプトと GitHub Actions の組み合わせで紹介します。
こんな人におすすめ
- Woodpecker CI で cron や manual 実行のパイプラインを運用している方
- CI が「たまに、特定のトリガーだけ」失敗して原因の切り分けに苦労した方
- 設定と実態のずれを機械的に検出するスクリプトの書き方を知りたい方
- 夜間ヘルスチェックを育てていて、新しい失敗パターンを足したい方
なぜ silent fail になるのか
Woodpecker CI の repo secret には Events 設定があります。
secret を push / pull_request / cron / manual のどのイベントの pipeline に展開するかを制御するものです。
ここから cron を外すと、cron で起動した pipeline では secret が空文字に展開されます。
その結果、linter が secret not found を返し、pipeline 全体が error で止まります。
厄介なのは、エラーの見た目が「スクリプトの不具合」そのものだという点です。
secret は存在しているので、一覧を見ても異常に気づけません。
「存在するか」だけでなく「どのイベントに適用されるか」まで監視して、はじめて検出できます。
secret の契約をコードで宣言する
最初に、このプロジェクトで必要な「secret × event」の組み合わせを Map で宣言します。
// scripts/check-secret-events.mjs
export const REQUIRED_SECRET_EVENTS = new Map([
['ci_token', new Set(['push', 'pull_request', 'cron', 'manual'])],
['notify_webhook_url', new Set(['cron', 'manual'])],
['cloud_account_id', new Set(['cron', 'manual'])],
['cloud_api_token', new Set(['cron', 'manual'])],
])
名前は例ですので、ご自身の secret に置き換えてください。
新しい secret を追加するときもここに1行足すだけで監視対象になります。
「あるべき姿」をコードとして持つので、欠けているものを機械的に出力できます。
live API の結果と突き合わせる
次に、Woodpecker API から取得した実際の secret 一覧と契約を比較します。
export function validateSecretEvents(liveSecrets, requiredSecrets = REQUIRED_SECRET_EVENTS) {
const liveByName = new Map()
for (const secret of liveSecrets ?? []) {
if (!secret || typeof secret.name !== 'string') continue
liveByName.set(secret.name, {
...secret,
events: normalizeEvents(secret.events),
})
}
const missingSecretNames = []
const missingSecretEvents = []
for (const [name, requiredEvents] of requiredSecrets.entries()) {
const live = liveByName.get(name)
if (!live) {
missingSecretNames.push(name)
missingSecretEvents.push({ name, missingEvents: [...requiredEvents].sort() })
continue
}
const missingEvents = [...requiredEvents].filter(e => !live.events.has(e)).sort()
if (missingEvents.length > 0) {
missingSecretEvents.push({ name, missingEvents })
}
}
return {
ok: missingSecretNames.length === 0 && missingSecretEvents.length === 0,
missingSecretNames,
missingSecretEvents,
}
}
ポイントは、secret が「存在しない」ケースと「存在するが events が足りない」ケースを両方拾うことです。
normalizeEvents は null や空文字を除く前処理で、API レスポンスの表記ゆれから身を守ります。
純粋関数にしておくと、単体テストで drift のパターンを網羅しやすくなります。
API 取得時のページネーションは、perPage と maxPages で上限を明示しておくのが安全です。
secret の件数は通常多くありませんが、上限がないと想定外のレスポンスで延々とループするおそれがあります。
workflow に組み込み、原因を1つに絞って通知する
ヘルスチェックの step は continue-on-error: true で書き、結果を ok / cause という出力で後続 step に渡します。
こうすると部分的な失敗を集めて、最後の summary step でまとめて判定できます。
CAUSE='ok'
if [ "$HEALTHZ_OK" != 'true' ]; then
CAUSE='healthz_down'
elif [ "${SECRET_EVENTS_CAUSE:-}" != 'ok' ]; then
CAUSE="${SECRET_EVENTS_CAUSE:-secret_events_error}"
elif [ "${CRON_CAUSE:-}" != 'ok' ]; then
CAUSE="${CRON_CAUSE:-cron_check_error}"
fi
複数のチェックが同時に失敗しても、通知する根本原因は1つに絞ります。
優先順位は「インフラ障害 > 設定ずれ > 実行不良」です。
サーバーが落ちているときに、secret の drift まで並べて通知しても読み手の負担になるだけだからです。
もう一つの工夫は、cause の文字列をスクリプト側が持ち、workflow 側は受け取って流すだけにしたことです。
新しい失敗分類を足しても、workflow の YAML はほとんど変わりません。
Issue の重複起票を防ぐ dedup ロジックも関数に切り出しておけば、新しい cause に分岐を1つ足すだけで既存の流れに乗せられます。
つまづきやすいポイント
- secret の存在確認だけで安心してしまう: 名前が一覧にあっても、Events が足りなければ該当イベントでは空になります。名前と events の両方を契約にしてください。
- cause の優先順位を決めていない: 複数のチェックが同時に落ちたとき、通知が割れて原因が分かりにくくなります。先に順位を決めておきましょう。
- API のページネーションに上限がない:
maxPagesを決めずにループすると、レスポンスの異常で止まらなくなります。 - 契約と workflow の約束事が壊れていても気づけない: スクリプトと YAML にまたがる約束は、内容を文字列で検査する contract test で守ると安心です。重めですが、複数ファイルのずれを継続的に拾えます。
まとめ
Woodpecker CI の cron だけが secret not found で止まる場合は、secret の Events 設定から cron が外れていないかを疑ってみてください。
「必要な secret × event」を Map で宣言し、live API と差分を取る方法は、設定と実態のずれを検出する汎用パターンとして使えます。
cause の分類をスクリプト側に持たせ、workflow は流すだけにすると、チェックを足しても保守しやすい形を保てます。

