「ローカルは緑なのにCIが落ちる」現象を解説する図。開発者がGitHub ActionsとWoodpecker CI間のシェルやクローン、変数展開、シークレットの挙動の差異を示す複雑なフローチャートを分析しています。移行時の7つの罠を視覚的に表現しています。

GitHub Actions(以下 GHA)で運用していた CI を、セルフホストの Woodpecker CI へ移すと、不思議な現象に出くわします。ローカルでは緑(成功)なのに、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 pipefailIllegal option を出して即死する。

原因: Woodpecker の step commands はデフォルトで /bin/sh を経由します。Debian 系のイメージ(node:20-bookworm など)では /bin/sh の実体は dash で、bash 拡張である set -o pipefail に対応していません。GHA の run: は bash 前提なので、その感覚で移植すると死にます。

対策: 素直に pipefail を外すか、bash を明示的に呼び出します。

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 継承を活用します。

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 が空に置き換えてしまうわけです。

# NG: Woodpecker が ${ARCH} を空に置換 → //file になる
case "$(uname -m)" in aarch64) ARCH=linux-arm64 ;; esac
curl "https://example.com/${ARCH}/file"

対策: シェルローカル変数はブレースなしの $VAR で書きます。これは Woodpecker の substitution 対象外なので、シェルがそのまま展開します。

# 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 describegit log を使うリリース系処理が過去履歴を見つけられない。

原因: Woodpecker v3 のデフォルト clone は --depth=1 の shallow clonegit 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 を要求します。

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-gitpull_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 も入れておくと安全です。

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 にするほうが分かりやすくなります。

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 だけ通らない半日」を作らずに済みます。一つひとつの差は小さいのですが、積み重なると本当に効いてきます。