はじめに
ローカルでは API key を読めているのに、GitHub Actions だけ 401 になることがありました。
私の場合は Flatkey AI の OpenAI 互換 API に対して、CI から軽い smoke test を打つだけの job でした。最初は API key 自体が間違っているのかと思ったのですが、結局は GitHub Actions 側の secret 名と env の渡し方が原因でした。
地味ですが、API 側の 401 だけを見ると遠回りしやすいので、先に CI の secret 注入を検査する形にしました。
再現環境
| 項目 | 値 |
|---|---|
| CI | GitHub Actions |
| 実行環境 | ubuntu-latest |
| API 例 | Flatkey AI |
| Base URL | https://router.flatkey.ai/v1 |
| エンドポイント | POST /chat/completions |
| Secret 名 | FLATKEY_API_KEY |
エラー全文
Authorization: Bearer のように Bearer の後ろが空になった状態で API を叩くと、次のようなレスポンスになりました。
{"error":{"code":"","message":"Invalid token (request id: 202608030036033049671068268d9d6eqCijLOt)","type":"new_api_error"}}
HTTP status は 401 です。
API key が完全に未送信のときは Token not provided になり、Authorization ヘッダーはあるけれど中身が空のときは Invalid token になりました。GitHub Actions の secret typo では後者になりやすいと思います。
再現手順
失敗する workflow
最初はこんな workflow でした。
name: flatkey smoke bad
on:
workflow_dispatch:
jobs:
smoke:
runs-on: ubuntu-latest
env:
FLATKEY_BASE_URL: https://router.flatkey.ai/v1
FLATKEY_MODEL: gpt-5.4-nano
steps:
- uses: actions/checkout@v4
- name: API smoke test without preflight
env:
# Typo: the configured secret is FLATKEY_API_KEY.
FLATKEY_API_KEY: ${{ secrets.FLATKEY_APIKEY }}
run: |
curl -sS -i -X POST "$FLATKEY_BASE_URL/chat/completions" \
-H "Authorization: Bearer $FLATKEY_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$FLATKEY_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":4}"
見落としポイントはここです。
FLATKEY_API_KEY: ${{ secrets.FLATKEY_APIKEY }}
設定していた secret は FLATKEY_API_KEY なのに、workflow 側では FLATKEY_APIKEY と書いていました。GitHub Actions では未設定の secret は空文字列として評価されるため、shell から見ると FLATKEY_API_KEY="" になります。
そのまま curl すると、CI のログ上は一見それっぽい API call に見えますが、実際には Authorization: Bearer を送っているだけでした。
ローカル再現
本物の secret は使わず、次の 3 ケースだけ確認しました。
bash agents/qiita-growth-agent/results/qiita_issue_902_ci_secret_401_repro.sh
出力はこうです。
case=missing-env-preflight
::error::FLATKEY_API_KEY is empty. Check secret name, environment scope, and step env mapping before API call.
exit=1
case=empty-bearer-real-401
HTTP/2 401
{"error":{"code":"","message":"Invalid token (request id: 202608030036033049671068268d9d6eqCijLOt)","type":"new_api_error"}}
case=fixed-env-injection-dry-run
::notice::FLATKEY_API_KEY present=true length_bucket=20+
::notice::base_url=https://router.flatkey.ai/v1 model=gpt-5.4-nano
::notice::dry_run=true, skip live API call
ここで見たいのは成功レスポンスではなく、API を呼ぶ前に空 secret を止められるかです。CI の問題を API の問題として追いかけ始めると、かなり時間を溶かすと思います。
原因の調査
先に CI 境界を見る
今回の切り分けでは、API key の値そのものを疑う前に、ローカルと CI の差分だけを見ればよかったです。
ローカルでは .env や shell の export で FLATKEY_API_KEY が入っています。一方で GitHub Actions では、GitHub の secret store から workflow の env へ明示的に写す必要があります。この写し替えの境界で空文字になっているなら、API 側から見ると「空の Bearer token が来た」だけです。
なので、最初に見るべきログは API のレスポンス本文ではなく、API call の直前で FLATKEY_API_KEY が空ではないことを確認する redacted diagnostic でした。値を出さなくても、存在確認だけで十分に切り分けできます。
GitHub Actions の secret は未設定だと空文字になる
GitHub Docs では、未設定の secret を参照した場合は空文字列になると説明されています。
つまり typo があっても、workflow の構文エラーにはなりません。
env:
FLATKEY_API_KEY: ${{ secrets.FLATKEY_APIKEY }}
この状態でも job は動きます。だからこそ API の 401 まで進んでしまいます。
environment secret は job が environment を参照していないと読めない
Repository secret ではなく Environment secret に置いている場合、job 側に environment が必要です。
jobs:
smoke:
runs-on: ubuntu-latest
environment:
name: production
私はここも毎回少し不安になります。secret 名が合っていても、置き場所が Environment 側で、job がその environment を見ていなければ、同じように空になります。
step env が一番追いやすい
env は workflow、job、step に書けます。ただ、secret を使う処理では step に寄せた方がログと対応させやすいです。
- name: API smoke test
env:
FLATKEY_API_KEY: ${{ secrets.FLATKEY_API_KEY }}
run: node scripts/flatkey-smoke.mjs
どの step が secret を必要としているかが見えるので、後から読む自分に優しいです。
解決方法
secret 名を決め打ちでそろえる
まず名前を 1 つに決めました。
FLATKEY_API_KEY
ローカルの .env、GitHub の secret 名、workflow の env、アプリ側の process.env を全部この名前にそろえます。
似た名前を増やすと、FLATKEY_KEY、FLATKEY_APIKEY、OPENAI_API_KEY のような微妙なズレが出ます。私はここで普通に踏みました。
API を呼ぶ前に redacted diagnostic を入れる
secret の値は出しません。suffix や hash も、不要なら出さない方がいいと思います。
私が入れたのは、空かどうかとざっくりした長さの bucket だけです。
if [ -z "$FLATKEY_API_KEY" ]; then
echo "::error::FLATKEY_API_KEY is empty. Check secret name, environment scope, and step env mapping."
exit 1
fi
echo "::add-mask::$FLATKEY_API_KEY"
if [ "${#FLATKEY_API_KEY}" -lt 20 ]; then
length_bucket="<20"
else
length_bucket="20+"
fi
echo "::notice::FLATKEY_API_KEY present=true length_bucket=$length_bucket"
この時点で落ちれば、API の 401 を見に行く前に CI 設定の問題だと分かります。
修正後の workflow
修正後はこうしました。
name: flatkey smoke fixed
on:
workflow_dispatch:
jobs:
smoke:
runs-on: ubuntu-latest
# Keep this only when FLATKEY_API_KEY is stored as an environment secret.
# If it is a repository secret, remove the environment block.
environment:
name: production
deployment: false
env:
FLATKEY_BASE_URL: https://router.flatkey.ai/v1
FLATKEY_MODEL: gpt-5.4-nano
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Preflight secret mapping
env:
FLATKEY_API_KEY: ${{ secrets.FLATKEY_API_KEY }}
run: |
if [ -z "$FLATKEY_API_KEY" ]; then
echo "::error::FLATKEY_API_KEY is empty. Check secret name, environment scope, and step env mapping."
exit 1
fi
echo "::add-mask::$FLATKEY_API_KEY"
if [ "${#FLATKEY_API_KEY}" -lt 20 ]; then
length_bucket="<20"
else
length_bucket="20+"
fi
echo "::notice::FLATKEY_API_KEY present=true length_bucket=$length_bucket"
- name: API smoke test
env:
FLATKEY_API_KEY: ${{ secrets.FLATKEY_API_KEY }}
run: node scripts/flatkey-smoke.mjs
Environment secret を使っていないなら、environment ブロックは消して大丈夫です。Repository secret に置いている場合は、むしろ余計な environment を足さない方が混乱しにくいです。
Flatkey AI で見たこと
Flatkey AI は https://router.flatkey.ai/v1 を OpenAI 互換の base URL として使えるので、CI の smoke test は OpenAI SDK や curl の設定とほぼ同じ見方になります。
私が CI に入れたかったのは、モデルの品質評価ではなく、次の最低限の確認でした。
- base URL が変わっていない
- API key が CI に渡っている
-
401や404のような設定ミスを早めに見つける - request id をログに残して後から追えるようにする
逆に、prompt や response body を CI ログに出す必要はありません。API key の問題を見たいだけなら、ログに出すのは status、request id、error type くらいで十分だと思います。
気づき
今回の反省は、401 を見た瞬間に API key の中身を疑ったことです。
ローカルで動いて CI だけ落ちるなら、先に見る順番はこうでした。
- GitHub secret の名前が workflow と一致しているか
- Repository secret と Environment secret の置き場所が合っているか
- job が必要な
environmentを参照しているか - secret を使う step の
envに明示的に渡しているか - API を呼ぶ前に空文字を検知しているか
ここまで見てから 401 の中身を見る方が、たぶん速いです。
参考
- GitHub Docs: Using secrets in GitHub Actions
https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets - GitHub Docs: Workflow syntax for GitHub Actions
https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax - GitHub Docs: Workflow commands, masking a value in a log
https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions - Flatkey AI
https://flatkey.ai/
おわりに
GitHub Actions だけ 401 になると、どうしても API 側を見に行きたくなります。ただ、secret typo や environment scope の問題なら、API に届く前に検知できます。
私は 401 を見てから workflow を読み直したので少し負けた気分でした。次からは API call の前に redacted diagnostic を置いて、空 secret はそこで止めます。
間違いあったらコメントください。よろしくお願いします。