GitHubで管理しているMarkdownを、レビュー後にWebサービスへ下書き保存する仕組みを作っています。PRをmainへマージするとGitHub Actionsが起動し、Playwrightでエディターを操作する構成です。
この仕組みで確認したのが、Workflowが成功していても、外部サービスへの保存処理を実行していない場合があるということでした。
最初の実行は、記事の送信フラグが無効だったため対象0件で終了していました。フラグを有効にすると保存ステップへ進みましたが、今度は認証状態の読み込みで停止しました。
整理すると、次の2つは別の問題です。
| 確認した状態 | その時点で分かったこと |
|---|---|
| 対象0件、保存ステップがskipped、Workflowはsuccess | 保存対象が選ばれておらず、ブラウザー操作を実行していない |
| 対象1件、保存ステップがfailure、AUTH_CONFIG | 保存対象は選ばれたが、認証設定を読み込めずブラウザー起動前に停止した |
この記事では、Actionsの一覧だけで判断せず、対象選択、ステップの実行、認証、外部保存を順番に確認した流れをまとめます。GitHub ActionsからPlaywrightや外部APIを実行し、「緑なのに反映されていない」を調べる方を想定しています。
記事を置いただけでは保存対象にならなかった
今回の記事管理では、frontmatterに下書き保存用のフラグがあります。
---
title: "動作確認用の記事"
slug: example-draft
status: draft
upload: false
note_id: null
---
status: draftは記事の状態、upload: trueは下書きを外部サービスへ保存してよいことを表します。書きかけの原稿が意図せず送られないよう、執筆時はupload: falseにしています。
対象選択スクリプトは、マージされたPRの変更ファイルから記事を読み、そのうちuploadが有効なものだけを選びます。結果の件数をGITHUB_OUTPUTへ書き、後続ステップの実行条件に使っています。
const articles = (await loadArticles(files)).filter(a => a.upload);
await writeFile(
'targets.json',
JSON.stringify(articles.map(a => a.file)) + '\n',
);
await appendFile(process.env.GITHUB_OUTPUT!, `count=${articles.length}\n`);
console.log(`Selected ${articles.length} draft(s).`);
Workflow側は、この件数が0以外のときだけChromiumのインストールと保存処理を行います。
- name: Select requested articles
id: targets
run: npm run note:targets
- name: Install Chromium
if: steps.targets.outputs.count != '0'
run: npx --no-install playwright install --with-deps chromium
- name: Save drafts
if: steps.targets.outputs.count != '0'
env:
NOTE_STORAGE_STATE: ${{ secrets.NOTE_STORAGE_STATE }}
NOTE_USERNAME: ${{ vars.NOTE_USERNAME }}
GH_TOKEN: ${{ github.token }}
run: npm run note:upload -- --files targets.json
これは実装から抜き出して名前を一部一般化した例です。イベント条件やcheckoutなどは省略しています。
最初の実行では、形式チェックと対象選択は成功していました。一方、Chromiumのインストールと下書き保存は両方skippedでした。記事のupload: falseと対象選択の条件を照合すると、保存されなかった理由を説明できます。
対象0件を正常終了にする設計自体には理由があります。執筆途中の記事や保存しない変更をマージするたびにエラーを出す必要はありません。ただし、保存を期待している実行なら、その正常終了を保存成功と読み替えないことが必要でした。
最初に変えたのは、対象フラグの1行
下書き連携の対象にする修正は、frontmatterの1行だけでした。
-upload: false
+upload: true
ここで本文やブラウザー操作コードまで変更する必要はありません。保存ステップがまだ実行されていないため、エディターのセレクターや保存ボタンを調べても、今回のスキップ理由には到達しません。
対象フラグを変更したPRでは、形式チェックとモックテストが通りました。マージ後の実行ではChromiumのインストールも成功し、保存ステップへ進むようになりました。
ただし、保存まで完了したわけではありませんでした。
対象1件になった後、認証設定で停止した
次に確認したログは、ブラウザーを起動する前のstartupで止まっていました。個人情報を含む箇所を除くと、重要なのは次の値です。
{"stage":"startup","code":"VALIDATED","selected":1,"skipped":0}
{"stage":"startup","code":"AUTH_CONFIG","message":"Set NOTE_STORAGE_STATE secret or run npm run note:auth locally."}
前の実行との違いは、保存対象が1件選ばれていることです。対象選択の問題は解消しましたが、Playwrightへ渡す認証状態を読み込めていませんでした。
読み込み部分は、概ね次の順序です。
let storageState;
try {
storageState = JSON.parse(
process.env.NOTE_STORAGE_STATE
|| await readFile('.auth/note.json', 'utf8'),
);
if (!Array.isArray(storageState.cookies)
|| !Array.isArray(storageState.origins)) {
throw new Error();
}
} catch {
throw new Error('AUTH_CONFIG');
}
const browser = await chromium.launch();
const context = await browser.newContext({ storageState });
GitHub ActionsではSecretを環境変数へ渡し、ローカルでは専用の認証ファイルを読む実装です。認証ファイルはGit管理から除外しています。
この例では、環境変数が空、ローカルファイルが存在しない、JSONが不正、想定する配列がない、といった失敗を同じAUTH_CONFIGで扱っています。そのため、ログにAUTH_CONFIGがあるだけで、Secret未登録と一意に断定することはできません。
次に調べるのは、GitHubのSecret設定、値の受け渡し、JSON形式です。セッションの期限切れやエディターのUI変更とは分けます。今回のコードではAUTH_CONFIGがブラウザー起動より前に出るので、実ページへアクセスして拒否されたエラーではありません。
Secretの登録とログイン成立も別に確認する
GitHubの公式資料では、未設定のSecretを参照する式は空文字列になります。Workflowに${{ secrets.NOTE_STORAGE_STATE }}と書いてあることは、実行時に値を使えることの証明にはなりません。
確認するときは、値を出力するのではなく、名前、保存先、Workflowからの参照、期待するJSON形式を照合します。EnvironmentのSecretを使う構成なら、そのジョブが該当Environmentを利用する設定かも確認します。
この実装で必要なのは、手動ログイン済みのブラウザーから取得したPlaywrightのstorage stateです。パスワードそのものをActionsへ渡す方式にはしていません。
登録は、手元で作成したファイルを標準入力から渡せます。
gh secret set NOTE_STORAGE_STATE \
--repo OWNER/REPOSITORY < .auth/note.json
OWNER/REPOSITORYは説明用の置き換えです。JSONはbase64化せず登録する前提で、認証ファイルの中身を記事やログへ貼りません。
また、cookiesとoriginsが配列になっているだけでは、ログインが有効だとは分かりません。空配列でも構造チェックは通りますし、保存したCookieの期限が切れている場合もあります。
現在の実装には、記事を新規作成せずログイン先だけを調べる--check-sessionがあります。ローカルで認証設定を用意した後は、この確認でアカウントとログイン状態を照合し、その後に対象1件の下書き保存を確認する順序になります。
形式検査の成功を、実保存の成功にしない
この仕組みでは、PRの検証用Workflowと、マージ後の保存用Workflowを分けています。
PRのチェックはMarkdownの形式、対象パス、模擬画面での処理などを確認します。実アカウントのSecretsを渡して外部サービスへ保存するものではありません。
したがって、次の段階はそれぞれ違う証拠で確認します。
| 段階 | 確認する証拠 |
|---|---|
| 原稿とコードを検証できた | 形式チェック、モックテストの結果 |
| 保存する原稿が選ばれた | 対象件数と対象パス |
| 保存処理が実行された | 保存ステップがskippedではないこと |
| 実アカウントでログインできた | セッション・アカウント確認の結果 |
| 下書きを保存できた | 保存後に再読込したタイトル・本文・下書き状態 |
今回、実行履歴では最初の保存ステップがスキップされたこと、次の実行はselected: 1の後にAUTH_CONFIGで終了コード1になったことを確認しました。対象フラグを有効にしたPRの差分も1行だけでした。
記事用の最小例では、対象0件と1件、空の認証設定、不正JSON、必要な配列を欠くJSONをローカルで確認しています。空の配列を持つJSONが形式検査に通ることも確認し、形式検査とログイン成立の違いを残しました。実際のCookie、アカウント、外部サービスへの送信は使っていません。
再発を減らすなら、実行結果に件数を残す
今後の改善案は、ActionsのSummaryへ「選択件数」「保存完了件数」「スキップした理由」をまとめることです。これは今回確認した実装への提案で、追加済みの機能としては扱っていません。
対象0件を失敗にするかは、Workflowの用途によります。通常のマージで未送信の記事が含まれることを許すなら、0件は正常終了で構いません。一方、「この1件を保存する」と明示した手動実行なら、対象0件をエラーにする設計も考えられます。
どちらの場合も、成功・失敗の色だけで外部への反映を判断せず、何件を選び、どこまで実行し、何を保存後に確認したかを残すと、調査の入口が分かりやすくなります。
まとめ
今回の調査では、最初に対象選択を確認したことで、保存ステップを実行していない理由が分かりました。対象フラグを有効にした後は、停止地点が認証設定へ進んだこともログから確認できました。
Workflowの成功、形式検査の成功、ブラウザー起動、ログイン、外部保存は、それぞれ別の到達点です。反映されていないときは、期待した保存ステップが実行されたかを最初に確認し、対象件数と停止段階から調べると、まだ動いていない処理を修正せずに済みます。
参考
公式資料は2026年10月12日に確認しました。