
GitHub Actions(以下 GHA)で運用していた CI を、セルフホストの [Woodpecker CI](https://woodpecker-ci.org/) へ移すと、不思議な現象に出くわします。**ローカルでは緑(成功)なのに、CI だけ落ちる**。あるいはその逆で、明らかにテストが失敗しているのに pipeline が緑のまま通ってしまう。
原因の多くは、GHA と Woodpecker の「シェルの扱い」「clone の挙動」「変数展開」「secret 注入」の差にあります。GHA の YAML をそのまま
この記事では、複数のリポジトリを GHA から Woodpecker へ実際に移管する過程で踏んだ7つの罠を、**症状 → 原因 → 対策**の形でまとめます。CI ホストは
## 罠1:
**症状**: GHA でそのまま動いていた step が、Woodpecker では冒頭の
**原因**: Woodpecker の step commands は**デフォルトで
**対策**: 素直に
``
今あらためて移管するなら、まず1本だけ「履歴走査もある・secret も使う・PR 差分も取る」という"全部入り"のリポジトリを選んで先に通します。簡単なものから移すと、罠4・罠5・罠6に最後まで気づけず、本番リポジトリで初めて踏むことになるからです。あわせて、移管直後はしばらく GHA と Woodpecker を**並走**させて、両方の結果を突き合わせます。「GHA で green だった」を信用しないのが鉄則で、緑の根拠(テストのサマリ行・secret が実際に注入されているか)まで一度は自分の目で確認しておくと、あとで「なぜか CI だけ通らない半日」を作らずに済みます。一つひとつの差は小さいのですが、積み重なると本当に効いてきます。
GitHub Actions(以下 GHA)で運用していた CI を、セルフホストの [Woodpecker CI](https://woodpecker-ci.org/) へ移すと、不思議な現象に出くわします。**ローカルでは緑(成功)なのに、CI だけ落ちる**。あるいはその逆で、明らかにテストが失敗しているのに pipeline が緑のまま通ってしまう。
原因の多くは、GHA と Woodpecker の「シェルの扱い」「clone の挙動」「変数展開」「secret 注入」の差にあります。GHA の YAML をそのまま
.woodpecker.yml に貼り替えると、文法は通るのに挙動だけがずれる、という厄介な落とし穴を踏みます。この記事では、複数のリポジトリを GHA から Woodpecker へ実際に移管する過程で踏んだ7つの罠を、**症状 → 原因 → 対策**の形でまとめます。CI ホストは
ci.example.dev のように匿名化していますが、構造はそのまま実運用のものです。## 罠1:
/bin/sh が dash で set -o pipefail が動かない**症状**: GHA でそのまま動いていた step が、Woodpecker では冒頭の
set -o pipefail で Illegal option を出して即死する。**原因**: Woodpecker の step commands は**デフォルトで
/bin/sh を経由**します。Debian 系のイメージ(node:20-bookworm など)では /bin/sh の実体は dash で、bash 拡張である set -o pipefail に対応していません。GHA の run: は bash 前提なので、その感覚で移植すると死にます。**対策**: 素直に
pipefail を外すか、bash を明示的に呼び出します。``
yaml
commands:
# NG: dash では Illegal option
- set -o pipefail; npm run build | tee build.log
# OK: bash を明示
- bash -ec 'set -o pipefail; npm run build | tee build.log'
`
さらに厄介なのが cmd | tee file の戻り値です。pipefail なしだと**パイプ全体の終了コードは末尾の tee(=ほぼ常に 0)になる**ため、cmd の失敗が握り潰されます。これがまさに「テストが落ちているのに pipeline が緑」の正体です。意図的に失敗を許容したいなら、failure: ignore を step 単位で宣言したほうが見通しが良くなります。
## 罠2: commands list の cwd が次の行に継承される
**症状**: GHA 感覚で各行に cd foo && cmd と書いたら、2 行目以降が cd: can't cd to foo で落ちる。
**原因**: Woodpecker の commands list は、各要素を**同じシェルプロセスで順次実行**します。1 行目の cd foo で変えたカレントディレクトリは、2 行目以降にも継承されます。一方、GHA の run: は各 line が独立シェル(cwd リセット)なので、毎行 cd && cmd を書くのが当たり前です。この挙動差が真逆なため、移植時にもっとも踏みやすい罠の一つです。
実際のログでは、こうなります。
`
+ cd testing/e2e && node -e "..."
test-helpers exports: 13 ← 1 回目の cd は成功
/bin/sh: 32: cd: can't cd to testing/e2e ← 2 回目以降、testing/e2e/testing/e2e を探して失敗
/bin/sh: 35: cd: can't cd to testing/e2e
`
**対策**: cd は最初に 1 回だけ実行し、以降は cwd 継承を活用します。
`yaml
commands:
- cd testing/e2e # 1 回だけ
- node -e "..." # cwd = testing/e2e のまま
- npx playwright test
`
## 罠3: ${VAR} を Woodpecker がシェルより先に置換する
**症状**: シェルで計算した変数を ${ARCH} で埋めたら、URL が https://example.com//file になって download が失敗する。
**原因**: Woodpecker は YAML 中の **${VAR} を、シェルに渡す前に自前で substitute** します。Woodpecker のコンテキスト(環境変数・secret・ビルトイン変数)に存在しない変数名は**空文字に置換**されます。つまり step 内で計算したシェルローカル変数を ${VAR} 形式で書くと、シェルが見る前に Woodpecker が空に置き換えてしまうわけです。
`sh
# NG: Woodpecker が ${ARCH} を空に置換 → //file になる
case "$(uname -m)" in aarch64) ARCH=linux-arm64 ;; esac
curl "https://example.com/${ARCH}/file"
`
**対策**: シェルローカル変数は**ブレースなしの $VAR** で書きます。これは Woodpecker の substitution 対象外なので、シェルがそのまま展開します。
`sh
# OK: $ARCH は Woodpecker を素通り、シェルが展開
case "$(uname -m)" in aarch64) ARCH=linux-arm64 ;; esac
curl "https://example.com/$ARCH/file"
`
どうしても ${VAR} 構文をシェルへ渡したいときは、$$${VAR} のように **$$ でエスケープ**します。
## 罠4: shallow clone がデフォルトで gitleaks / git describe が壊れる
**症状**: gitleaks による secret スキャンが、なぜか main の最新 1 コミットしか検査していない。git describe や git log を使うリリース系処理が過去履歴を見つけられない。
**原因**: Woodpecker v3 のデフォルト clone は **--depth=1 の shallow clone**(git fetch --no-tags --depth=1 --filter=tree:0 ...)です。通常の build/test なら問題ありませんが、全履歴の走査を前提とする処理では破綻します。
- **gitleaks**: 全 history を走査して secret を検出する。shallow のままだと過去の漏洩を見落とす
- **git log / git describe**: 過去コミット参照系
- **conventional-changelog**: リリースノート自動生成系
**対策**: pipeline の clone: セクションを明示し、partial: false で full clone を要求します。
`yaml
clone:
- name: clone
image: woodpeckerci/plugin-git
settings:
partial: false # full history を取得
`
一度入れると pipeline 全体が full clone になり、リソースを食います。**履歴走査が必要な YAML にだけ**入れるのが運用上のコツです。
## 罠5: PR pipeline では refs/remotes/origin/main が作られない
**症状**: PR の差分を取ろうと git diff origin/main...HEAD を実行したら、fatal: ambiguous argument 'origin/main...HEAD': unknown revision で落ちる。
**原因**: woodpeckerci/plugin-git は pull_request event で **PR head のみを checkout** し、refs/remotes/origin/main ref を作りません。git fetch origin main(refspec なし)でも FETCH_HEAD は更新されますが、refs/remotes/origin/main は更新されないため、origin/main という名前は解決できません。なお罠4の partial: false(full clone)を入れても**この ref は作られません**。depth/filter と refspec は独立した設定だからです。
**対策**: explicit refspec で fetch して ref を明示的に作ります。差分取得が失敗したときの fallback も入れておくと安全です。
`sh
if [ "$CI_PIPELINE_EVENT" = "pull_request" ]; then
git fetch origin "+refs/heads/main:refs/remotes/origin/main" \
--no-tags --depth=50 2>/dev/null || true
CHANGED=$(git diff --name-only origin/main...HEAD 2>/dev/null || git ls-files)
fi
`
## 罠6: secret の event を全部 enable しないと空文字が注入される
**症状**: WebUI から手動 trigger したら、secret を使う処理だけが失敗する。しかもエラーは secret "X" not found ではなく、値が空のまま step が走るので原因に気づきにくい。
**原因**: Woodpecker の secret は、WebUI で登録した後に **events タブで使う event を全部 enable** しなければ注入されません。push / pull_request / manual / cron / tag のうち、**manual のチェック忘れ**がとくに多いパターンです。「WebUI から手動実行したら secret が空文字」はこれが原因です。空文字はエラーにならず**そのまま step に渡る**ため、.npmrc の ${NPM_TOKEN} が空に置換されて npm ci が 401、といった遠回りな失敗として現れます。
**対策**: secret 登録時に、必要な event をすべて enable します。とくに manual を忘れないことです。
`
WebUI > Repository > Settings > Secrets > (対象 secret) > Events
[x] push
[x] pull_request
[x] manual ← 忘れやすい
[x] cron
[x] tag
`
加えて、.npmrc を heredoc でまるごと上書きするのは避けましょう。legacy-peer-deps=true のような他設定が消えて npm ci の挙動が変わります。NPM_TOKEN を env で渡せば、リポジトリ側の .npmrc が自動展開してくれます。
## 罠7: failure: ignore でも step バッジは赤いまま
**症状**: 脆弱性チェックなどを「落ちても止めない」ようにしたのに、UI で step が赤く表示されて混乱を招く。
**原因**: failure: ignore は step が非 0 で終了しても**pipeline 全体の status を成功扱い**にする設定ですが、**step 単体の UI バッジは赤いまま**です。見た目だけ見ると「失敗しているのに通った」ように映ります。
**対策**: UI 上の見た目も緑にしたいなら、コマンド側を cmd || true でラップして必ず exit 0 にするほうが分かりやすくなります。
`yaml
commands:
# UI も green、log には脆弱性リストが残る
- npm audit --audit-level=high || true
`
## まとめ: 移植の鉄則は「GHA の常識を一度疑う」
7つの罠を振り返ると、共通するのは「**GHA で当たり前だった前提が Woodpecker では逆**」という点です。
- シェルは bash ではなく dash(罠1)
- commands は独立シェルではなく同一シェルで cwd 継承(罠2)
- ${VAR} はシェルより先に Woodpecker が食う(罠3)
- clone は full ではなく shallow がデフォルト(罠4)
- PR では origin/main ref が存在しない(罠5)
- secret は event ごとに明示 enable が必要(罠6)
- failure: ignore でも UI は赤い(罠7)
「ローカルは緑なのに CI だけ落ちる」と感じたら、まずこの7点を疑うと診断が速くなります。
## 現役実装者の視点
この移管は、自宅の Mac を CI ステーションに仕立てて、自分のプロダクトと顧問先のリポジトリをまとめて GHA から Woodpecker へ寄せていく過程で踏んだものです。最初に手をつけたのは自分の素振り用リポジトリだったので「壊しても困らない」気持ちで始めたのですが、結局いちばん時間を溶かしたのは罠1と罠6でした。とくに dash の pipefail 非対応は、「CI だけ落ちる」より「**落ちるべきテストが緑で通る**」方向の事故を生むのが怖いところです。私の場合、移管直後に「全部緑になった、移行完了だ」と思った数日後に、実は E2E が静かにこけ続けていたことに気づきました。pipeline は緑、でも tee がパイプ末尾の終了コードを 0 にしていて、Playwright のサマリ行(N failed, M passed)を目視しないと分からない状態だったわけです。あの数日の「動いているつもり」が、いちばんヒヤッとした体験でした。
想定外だったのは、罠3の ${VAR} を Woodpecker がシェルより先に食う挙動です。GHA でも ${{ }} という独自の展開層があるので「まあそういうものだろう」と頭では分かっていたつもりでしたが、いざ自分が step 内で計算したシェルローカル変数まで空に潰されると、ログ上は https://example.com//file のように一見シェルのミスに見えてしまい、シェルスクリプトばかり疑って遠回りしました。原因が「YAML の方の置換だった」と分かったときは、少し脱力しました。罠6の secret の manual チェック忘れも同じ系統で、エラーではなく空文字が静かに注入されるので、npm ci が 401 で落ちた理由を .npmrc` のトークン記法だと思い込んで延々と直していました。決定的なエラーで止まってくれるならまだ楽で、「静かに間違った値が通る」系の罠ほど切り分けに体力を持っていかれます。今あらためて移管するなら、まず1本だけ「履歴走査もある・secret も使う・PR 差分も取る」という"全部入り"のリポジトリを選んで先に通します。簡単なものから移すと、罠4・罠5・罠6に最後まで気づけず、本番リポジトリで初めて踏むことになるからです。あわせて、移管直後はしばらく GHA と Woodpecker を**並走**させて、両方の結果を突き合わせます。「GHA で green だった」を信用しないのが鉄則で、緑の根拠(テストのサマリ行・secret が実際に注入されているか)まで一度は自分の目で確認しておくと、あとで「なぜか CI だけ通らない半日」を作らずに済みます。一つひとつの差は小さいのですが、積み重なると本当に効いてきます。

