結論

  • 式は、存在しない値を参照してもエラーにせず空文字を返す。未登録の secret も、skip された step の output も '' になる
  • with: に空文字を渡すと action.yml の default は使われず、空文字がそのまま action に届く。hashicorp/setup-terraform は最新版を入れ、actions/checkout は起動したイベントの ref を取る。どちらも失敗しない
  • step の env: は if: より先に評価される。skip させるつもりの step でも、空文字を fromJSON() に通すとその step が落ちる
  • if: failure() は job のキャンセルと job の timeout-minutes 超過では走らない。後片付けは if: always() に置く
  • shell: を書かない run と、shell: を自前の文字列に書き換えた run には pipefail が付かない
  • 空文字は受け取る側では気付けない。値を作る step で空を落とし、真偽値の output は true / false 以外を落とす

以下は公式ドキュメントと、actions/runner v2.337.0 / actions/checkout v7.0.1 / hashicorp/setup-terraform v4.0.1 / tj-actions/changed-files v47.0.6 のソースで確かめた。ワークフローを実際に走らせて測ったものではない。

前提: 式は存在しない値を空文字にする

公式ドキュメントは、存在しないプロパティの参照を空文字として評価すると明記している ( Contexts reference )。

If you attempt to dereference a nonexistent property, it will evaluate to an empty string.

secret も同じで、未登録の secret を参照した式は空文字を返す ( Using secrets )。

step の間には、job の needs のような失敗の伝播が無い。ある step が値を作れなかったことは、値を使う step には空文字としてしか届かない。 以下の罠の多くは「失敗が空文字に化けて届く」と「空文字を受けた側が未指定と同じに扱う」の組み合わせから来る。

空文字が生まれる場所

未登録の secret は空文字になる

${{ secrets.FOO }} は FOO が未登録でも失敗せず、空文字が入る。step の env: にも空文字が渡る。組織の secret がリポジトリへ共有されていない場合も同じ見え方になる。

環境を増やした、リポジトリを分けた、fork した直後に「他の secret は効いているのに 1 個だけ無い」が起きる。値を使う step で空を落とす。

- name: Call API
  env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
  run: |
    : "${API_TOKEN:?secret API_TOKEN is empty}"    

登録状況は API で確かめる。組織の secret のうちリポジトリに共有されているものは別の API で出る。

gh api repos/<owner>/<repo>/actions/secrets --jq '.secrets[].name'
gh api repos/<owner>/<repo>/actions/organization-secrets --jq '.secrets[].name'
  • PITFALL: 空文字のまま後続へ流れるので、失敗地点が原因から離れる。さらに if: failure() の後片付け step は先行 step が起動前に落ちた場合も走るので、接続できずに 2 個目の赤を作り、そちらが原因に見える

skip された step の output は空文字になる

if: が false で skip された step の steps.<id>.outputs.<name> は、未定義ではなく空文字になる。 値を受ける側が「空 = 未指定」と解釈する実装なら、エラーにならず既定の動作 (多くは最新版の取得) で進む。

版やパスを 1 つの step が作り、別の step が使う構成で起きる。作る側と使う側に同じ if: を書いて整合を取っているつもりの箇所が危ない。

作る step で空を落とす。composite action の中でも、step が失敗すれば後続は skip される。

- name: Resolve version
  id: versions
  shell: bash
  run: |
    set -euo pipefail
    v=$(awk '$1 == "terraform" { print $2; exit }' .tool-versions)
    echo "TERRAFORM_VERSION=${v:?terraform version not found}" >> "$GITHUB_OUTPUT"    
  • PITFALL: 使う側の挙動は action ごとに違う。版が空だと最新版を入れて進むもの (setup-terraform) と、空の URL で 404 になって落ちるもの (tarball の取得) が同じ workflow にあると、片方だけ静かに壊れる

変更検知 action は比較元を引けないと output を設定せずに終わる

tj-actions/changed-files は push イベントで直前のコミットを引けない (git rev-list -n 1 HEAD^ が失敗する) と、最初のコミットと判定する。 Initial commit detected no previous commit found. の warning と This is the first commit for this repository; exiting... を出し、output を 1 つも設定せずに正常終了する。

後続の if: steps.changed.outputs.any_changed == 'true' は静かに false になり、fromJSON() に通すと空文字でエラーになる。

浅いクローン (fetch-depth: 1) で親コミットが無い状態も同じ経路に入る。

$ git clone -q --depth 1 <repo> shallow && cd shallow
$ git rev-parse HEAD~1
fatal: ambiguous argument 'HEAD~1': unknown revision or path not in the working tree.

composite action の中で actions/checkout を重ねたときも踏みやすい。内側の persist-credentials: false が外側の残した認証情報を消し、非公開リポジトリでは git fetch が通らなくなる。

workflow 側は、真偽値でない output を落とす step を挟む。

- name: Check changed-files output
  env:
    ANY_CHANGED: ${{ steps.changed.outputs.any_changed }}
  run: |
    case "$ANY_CHANGED" in
      true|false) ;;
      *) echo "changed-files returned '$ANY_CHANGED' (expected true/false)" >&2; exit 1 ;;
    esac    
  • PITFALL: use_rest_api: true は回避策にならない。v47.0.6 では pull_request 系イベント専用で、push では例外を投げる。push で動く CD には使えない

$GITHUB_OUTPUT の値に改行が混ざると、2 行目は別の行として読まれる

ランナーは $GITHUB_OUTPUT を 1 行ずつ読み、NAME=VALUE か NAME<<DELIMITER として解釈する (FileCommandManager.cs)。 echo "K=$v" >> "$GITHUB_OUTPUT" の v が改行を含むと、2 行目以降は K の値にならない。

2 行目の内容結果
= を含まない (1.10.0)Invalid format '1.10.0' で step が失敗する
= を含む (a=b)黙って別の output a になる

awk / grep / sed のように、マッチが 1 件とは限らないコマンドの出力をそのまま流すと踏む。 抽出は 1 件目で打ち切り、複数行が正当な値なら delimiter 構文を使う。

# 1 行に限定する
awk '$1 == "terraform" { print $2; exit }' .tool-versions

# 複数行を渡す
{
  echo "BODY<<EOF"
  cat multi-line.txt
  echo "EOF"
} >> "$GITHUB_OUTPUT"

delimiter は、値の中に単独の行として現れない文字列にする ( Workflow commands )。

  • PITFALL: 手元のファイルでマッチが 1 件なら通る。ファイルに行が増えたときに初めて壊れるので、CI の変更と原因が離れる

空文字を受け取る側

ランナーは action.yml の default を、呼び出し側の with: にキーが無いときだけ補う (ActionRunner.cs)。 terraform_version: ${{ steps.x.outputs.v }} のようにキーを書けば、式が空文字でも default は使われず、空文字がそのまま action に届く。空文字をどう扱うかは action の実装で決まる。

setup-terraform は空の version で最新版を入れる

hashicorp/setup-terraform の版の指定は terraform_version だけで、.tool-versions や .terraform-version を読む機能は無い。 空文字を渡すと、版の解決に使う @hashicorp/js-releases が semver.validRange('') を * と解釈し、プレリリースを除く最新版を入れる。

CI とローカルの版を 1 か所で管理したいなら、読み取り step を挟み、空を落とす。

- id: tfver
  shell: bash
  run: |
    VERSION=$(awk '$1 == "terraform" { print $2; exit }' .tool-versions)
    if [[ -z "$VERSION" ]]; then echo "::error::terraform version not found"; exit 1; fi
    echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"    
- uses: hashicorp/setup-terraform@<sha>
  with:
    terraform_version: ${{ steps.tfver.outputs.VERSION }}
  • PITFALL: required_version = "~> 1.14" は 2.0 未満を許すので、最新の 1.x が入っても plan は通る。CI だけ違う版で本番に apply している状態に気付けない

actions/checkout の ref は空なら起動したイベントの ref を取る

ref の既定値は空文字で、空のときは github.ref / github.sha を使う (src/input-helper.ts)。未指定と空文字の区別は無い。

github.head_ref は pull_request / pull_request_target でしか値を持たないので、次の step を workflow_dispatch で走らせても失敗しない。

- uses: actions/checkout@<sha>
  with:
    ref: ${{ github.head_ref }}   # workflow_dispatch では空文字 => 起動した ref を取る
  • PITFALL: 「式が空になれば落ちる」を前提に安全策や改善案を組むと、前提ごと崩れる。式が空になる経路は走らせて確かめる

step の env: は if: が false でも評価される

ランナーは step ごとに、env: の式を評価してから if: を評価する (StepsRunner.cs で EvaluateStepEnvironment が EvaluateStepIf より前)。 env: の評価が失敗すると、if: を見る前にその step は Failed になる。 with: は action を起動する処理 (ActionRunner.RunAsync) の中で評価するので、skip された step では評価されない。

上流の step が skip / 失敗して output が空文字だと、if: で守っているつもりの step が fromJSON('') で落ちる。

- name: Write summary
  if: steps.coverage.outputs.report
  env:
    REPORT: ${{ fromJSON(steps.coverage.outputs.report || '""') }}
  run: echo "$REPORT" >> "$GITHUB_STEP_SUMMARY"

|| '""' で、空文字のときは JSON の空文字列を渡す。with: 側にはこの fallback は要らない。

  • PITFALL: エラーは The template is not valid. .github/workflows/x.yaml (Line: N, Col: M) の形で job ログの本体とは別に出るので、本命の失敗に紛れる。上流が成功した run では再現しないので、導入時のテストをすり抜ける
  • PITFALL: run: の中に ${{ }} を直接展開せず env: を経由させるのは、スクリプトインジェクション対策として別に要る。評価順の話とは独立している

失敗が消える場所

if: failure() は job のキャンセルと job の timeout では走らない

failure() は job の状態が failure かどうかだけを見る (FailureFunction.cs)。job がキャンセルされると job の状態は cancelled になるので、failure() は false を返す。

直前の状況failure()!cancelled()always()
前の step が失敗truetruetrue
step の timeout-minutes 超過truetruetrue
job のキャンセルfalsefalsetrue
job の timeout-minutes 超過falsefalsetrue

step の timeout-minutes を超えると、ランナーはその step を Failed にする (The action '<name>' has timed out after N minutes.)。 job の timeout-minutes を超えると、GitHub が job をキャンセルする ( Workflow syntax )。同じ timeout でも、どちらに付けたかで failure() の結果が変わる。

外部リソースの後片付け (テストデータの削除など) を if: failure() にすると、キャンセルと job の timeout でゴミが残る。 後片付けは if: always() に置き、失敗時だけのログ収集は if: failure() のままでよい。

公式ドキュメントは、成否にかかわらず走らせたいなら always() より !cancelled() を推奨している。always() はキャンセルでも走るので、checkout のように致命的に失敗しうる処理に付けると、workflow が timeout まで止まることがある。 キャンセルでも走らせたい後片付けにだけ always() を使う。

  • PITFALL: always() は成功時にも走る。後片付けが結果を検査する作りだと、成功時に対象が無く (404) 落ちる。後片付けは結果を検査しない作りにする

shell: を書き換えると pipefail が消える

run step の shell: ごとに、ランナーが実際に実行するコマンドは次のとおり ( Workflow syntax )。

shell:実行されるコマンドpipefail
書かない (Linux / macOS)bash -e {0}なし
bashbash --noprofile --norc -eo pipefail {0}あり
nix develop --command bash -e {0}書いた文字列そのままなし

既定値はフラグ込みの 1 つの文字列で、一部だけを上書きする仕組みは無い。 開発環境のラッパー (nix develop --command / devbox run / docker compose exec) の中で run step を走らせるときは、フラグを自分で書き戻す。

- run: make test | tee test.log
  shell: nix develop --command bash -eo pipefail {0}

shell: を書かない step にも pipefail は付かない。workflow の先頭で defaults.run.shell: bash を指定すると、shell: を書かない step も bash を指定した扱いになる。

症状が出るのは、パイプの左が失敗して右が成功するときだけ。

$ bash -e -c 'false | tee /dev/null; echo reached'
reached
$ bash -eo pipefail -c 'false | tee /dev/null; echo reached'; echo "rc=$?"
rc=1

まとめ

場面挙動対策
未登録の secret空文字使う step で ${VAR:?}。登録状況は gh api で確かめる
skip された step の output空文字作る step で ${v:?} で落とす
changed-files が親コミットを引けないoutput 無しで正常終了true / false 以外を落とす step を挟む
$GITHUB_OUTPUT に改行入りの値2 行目は別の行。= があれば別の output1 件目で打ち切る。複数行は delimiter 構文
with: に空文字action.yml の default は使われない空を作る側で落とす
setup-terraform に空の version最新版を入れる読み取り step で空を落とす
actions/checkout に空の ref起動したイベントの ref を取る空になる経路を走らせて確かめる
skip される step の env:if: より先に評価されるfromJSON(x || '""')
job のキャンセル / job の timeoutif: failure() が走らない後片付けは if: always()
shell: 未指定 / 自前の文字列pipefail が付かない-eo pipefail を書き戻す。defaults.run.shell: bash

参考