5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【自動投稿勢は要注意】GitHub Actions × Qiita CLIでキャンペーン全損。開発陣がv1.9.1ですぐ救ってくれた話(原因はソースまで追いました)

5
Posted at

GitHub Actionsで自動投稿している方へ。参加しているキャンペーンが、静かに全部消えるかもしれない

안녕하신게라!パナソニック コネクト株式会社クラウドソリューション部の加賀です。

ある日ふと自分の投稿を眺めていたら、参加していたはずのQiitaキャンペーンの紐付けが、軒並み外れていました。直前に「キャンペーンを外す」ような操作をした覚えは一切ありません。
よし、ついにこの台詞を言えるときが来た!
「自分は何もしていないのに、おかしくなっちゃったんです!」

ということで、ソースコードを追ったところ、犯人は Qiita CLI v1.9.0 と、記事を GitHub Actions で公開している私の運用が最悪の形で噛み合ったことでした。

この記事は、まず何よりも注意喚起です。GitHub ActionsでQiita CLIを用いて記事を投稿している方は、過去記事のキャンペーン登録が同じように静かに消えていた可能性がありました。対応済みとは言え、どうか他人事だと思わずに読んでください。

そのうえで、これは感謝の記事でもあります。不具合が報告され、すぐに v1.9.1 で修正されました。事故の記録であると同時に、素早く直してくれた開発陣へのお礼でもあります。

まず結論

  • GitHub ActionsでQiita CLIの自動投稿をしている人は、同じ事故に遭う可能性がありました。今すぐ Qiita CLI を v1.9.1 以上に上げてください。キャンペーンを使わないなら、v1.8.0 への固定も緊急避難になります。
  • 私は Qiita CLI v1.9.0 で、参加していたキャンペーンをすべて失いました。原因は操作ミスではなく、新機能と私の運用の相性でした。
    • v1.9.0 で「Support posting campaign」(PR #382)が入り、posting_campaign_uuidagreed_posting_campaign_term という新しいfrontmatterが追加されました。
    • 手元の記事にはこの欄が無く、null として扱われます。それを自動publishが送信し、サーバ側のキャンペーン参加を上書きして消しました。
  • 引き金は、意外にも「差分があるものだけ更新する」という賢い最適化のほうでした。詳しくは後半でソースを追います。
  • そして、この問題はすでに直っています。まさに私が提言しようとした内容が PR #384 として取り込まれ、v1.9.1 としてリリースされました。対応の速さには、本当に頭が下がります。
  • ただし「全損」は事故の瞬間の話です。あの日はキャンペーン記事の紐付けが軒並み外れました。その後、現在進行中のキャンペーンは復旧できたものの、応募期間が終了した過去のキャンペーンは戻せませんでした。注意喚起として残しておきます。

何が起きたのか

私はQiita記事をGitで管理し、GitHub Actionsで投稿を自動化しています。この構成自体は過去記事で紹介した通りで、今も気に入って利用しています。

まず、私の普段の運用です。

  • 記事をpushすると、GitHub ActionsでQiita CLIが走る仕組みにしています。
  • このとき動くのは qiita publish --all です。pushしたのが下書き1本でも、毎回 public/ 配下の全記事をまとめてQiitaへ反映しにいきます。
  • 新しい記事は基本 ignorePublish: true(まだ公開しない状態)で置いておきます。
  • CRONで動く定期公開のGitHub Actionsが、ファイル名に記載の公開日付を見て ignorePublish: false に書き換え、記事を公開します。

ここが今回の伏線でした。「毎回、全記事を反映する」と聞くと乱暴に思えますが、v1.8.0 までは何の問題もありませんでした。実際に更新されるのは差分のある記事だけで、手を触れていない公開済みの記事は、反映対象に入っても中身が一致するので素通りされていたからです。だからこそ、下書きを1本pushするだけで、公開済みの記事まで一斉に書き換わるとは夢にも思っていませんでした。

「下書きをpushしておき、時間が来たら自動で公開する」というこのスタイルは、とても快適です。問題が起きたのは、ここに v1.9.0 が噛み合ったときです。

経緯はこうでした。

  1. Qiita CLIのバージョンが v1.9.0 に上がる。
  2. たった1記事をpushする。これをトリガーにGitHub ActionsのQiita CLIが起動する。
  3. CLIは、pushした当の記事だけでなく、public/ 配下にある全記事を走査(スキャン)対象として舐める。(--all の振る舞い)
  4. 気づいたときには、すでに公開済みだった過去記事のキャンペーン情報まで、軒並み外れていた。

私は「キャンペーンを外す」操作を一度もしていません。下書きを1本pushしただけです。それなのに、ignorePublish で管理しているはずの公開・非公開とはまったく別のところで、キャンペーンに参加していた記事の紐付けが、軒並み剥がれ落ちていました。

実際に、自動投稿のbotが残したコミットの差分(の一部)がこれです。

-updated_at: '2026-01-26T10:23:15+09:00'
+updated_at: '2026-07-03T17:47:00+09:00'
 id: xxxxxxxxxxxxxxxxxxxx
organization_url_name: panasonic-connect
 slide: false
 ignorePublish: false
+posting_campaign_uuid: null
+agreed_posting_campaign_term: false

posting_campaign_uuidnull が書き込まれ、キャンペーンとの紐付けが外れました。さらに updated_at まで、publishが走った時刻に書き換わっています。本文は一文字も直していないのに、記事の最終更新日時だけが一斉に「事故の日」へ塗り替えられてしまいました。

犯人は「差分検知」だった(ソースを追う)

最初は「全記事を無条件に上書きしているのだろうか?」と思っていました。ところがソースを読むと、話はもっと皮肉なものでした。犯人は、むしろ賢い最適化のほうだったのです。

publish.ts--all は、こう対象を選びます(以下のコードはいずれも Qiita CLI(Qiita Inc., Apache License 2.0)からの引用で、日本語コメントは筆者による注釈です)。

// 冒頭でサーバの最新状態を取り込む
await syncArticlesFromQiita({ fileSystemRepo, qiitaApi });

// --all の対象は「変更あり、または新規」だけ
targetItems = (await fileSystemRepo.loadItems()).filter((item) => {
  if (item.ignorePublish === true) return false;
  return item.modified || item.id === null;
});

順を追うと、こうなります。

  1. publishは冒頭で syncArticlesFromQiita を呼び、サーバの最新状態をローカルのキャッシュに取り込みます。キャンペーン記事なら、キャッシュにサーバのキャンペーンUUIDが入ります。
  2. 次に loadItems()public/ の記事を読みます。v1.9.0 では、キャンペーン欄の無いファイルは data.posting_campaign_uuid ?? null によって null と解釈されます。
  3. modified(変更あり)の判定は、ローカルの内容とキャッシュの比較です。ローカルは null、キャッシュはサーバのUUID。両者は一致しないので modified = true になります。
  4. こうして「変更あり」と見なされた記事だけが patchItem に渡され、そのとき postingCampaignUuid: null が送られて、サーバのキャンペーンが消えます。

つまり「差分があるものだけ更新する」という最適化が、ここでは「全キャンペーン記事を差分ありとして拾い、null で塗り替える」方向に働きました。差分検知は防波堤ではなく、引き金だったのです。手が滑ったわけではなく、ツールが忠実に仕事をした結果でした。

Terraformユーザなら、見覚えのある光景

この構図、インフラをやっている方にはデジャヴのはずです。Providerをバージョンアップしたら新しい属性が増え、そのデフォルト値が既存リソースの実態と食い違っていて、terraform apply で意図せず上書きされた。あの感覚と、今回はほぼ同じです。

  • public/.tf ファイル(宣言)にあたる
  • Qiita上の記事が実リソース(実態)にあたる
  • CLIのバージョンアップが provider のバージョンアップにあたる
  • 新frontmatterが、新しく増えた属性(デフォルト値つき)にあたる
  • 反映(publish)が apply にあたる

Terraformなら apply の前に必ず plan で差分を確認します。今回の悲劇は、その plan にあたるレビューを挟まずに apply してしまった、と読み替えられます。宣言的なツールと付き合う限り、この落とし穴はどこにでも顔を出します。

その瞬間には、防げなかった

私と同じ「pushしたら自動でpublishが走る」フローのままで、事故のその瞬間に防げたか。正直に言うと、実質的には防げませんでした。自動publishには人間の確認が挟まらず、当時の私はCLIのバージョンを固定しておらず(実質 @v1 相当)、実行時に勝手に最新へ上がる状態だったからです。

「じゃあ公開済みの記事を public/ から出しておけばいい」とも考えました。ですが、あとで分かったことに、qiita publish は実行の冒頭で必ず全記事をQiitaからダウンロードして public/ に書き戻します。つまり public/ から手で追い出しても、次のpublishが勝手に連れ戻す。素直な回避は効きませんでした。

解決策は2つあります。ツール側の修正(v1.9.1)と、自分の運用の作り替えです。まずはツール側から、順に見ていきます。

提言しようと思ったら、もう直っていた(PR #384 と v1.9.1)

ここまで書いて、私は「未指定のフィールドは既存のサーバ値を保持してほしい」という提言をするつもりでした。ローカルに欄が無いのに null で宣言されたとみなして上書きするのではなく、指定が無ければ現状維持にする、いわゆる merge semantics です。

ところが調べてみると、まさにその修正がすでに入っていました。PR #384「Keep posting campaign when frontmatter has no campaign keys」 です。変更の中身は、こうでした。

// frontmatter にキャンペーンのキーが無ければ、送信ペイロードから省略する
...(this.postingCampaignUuid !== undefined && {
  posting_campaign_uuid: this.postingCampaignUuid,
}),
...(this.agreedPostingCampaignTerm !== undefined && {
  agreed_posting_campaign_term: this.agreedPostingCampaignTerm,
}),

欄が無ければ patch から外す。つまりサーバ側の値をそのまま残す。私が言おうとしたことが、そのまま実装されていました。しかもこれは v1.9.1 としてリリース済みで、v1 タグも同じコミットに更新されています。GitHub Actionsで increments/qiita-cli/actions/publish@v1 を使っている人は、修正版が適用されています。

キャンペーン機能を出し、この不具合が見つかると、すぐに直してリリースまで持っていく。この速さと誠実さには、いち利用者として本当に感謝しています。ありがとうございます。

一点だけ、正直に補足します。この修正が効くのは「キーが存在しない」場合です。テスト returns null for an explicit null が示すとおり、posting_campaign_uuid: null と明示的に書いてあると、今も null が送られて上書きされます。qiita new の雛形はこの null を書き込むので、確実に守りたい既存記事は、キャンペーンの欄を丸ごと消しておくのが安全です。

同じ使い方をしている人へ

GitでQiita記事を管理し、CLIやGitHub Actionsで反映を自動化している方は、まず次を確認してください。

  • Qiita CLI を v1.9.1 以上に上げる(当面は v1.8.0 に固定でも可)。まずこれで今回のキャンペーン全損は防げます。
  • 確実に守りたい既存記事は、frontmatterのキャンペーン欄を消しておく(明示的な null を残さない)。
  • 新しいバージョンでfrontmatterが増えたら、リリースノートと差分を必ず確認する。
  • さらに堅くするなら、後述のように CLIを使い捨てルートで動かす(CIは work/、ローカルは preview/)。ダウンロードの副作用を、本物の記事から切り離せます。

次に怖いのは、アドベントカレンダーかもしれない

今回の事故の根っこは、こうです。これまでサーバ側にしか無かった紐付け情報が、ある日のバージョンアップでfrontmatterの新しい欄として増える。手元にその欄が無いまま反映すると、既存の紐付けが消える。

今回はキャンペーンだけで済みましたが、Qiita記事には、同じように「今はサーバ側にしか存在しない紐付け」がまだあります。その代表格が、アドベントカレンダーです。いまカレンダーへの登録はQiitaのWeb画面で行うもので、frontmatterには現れません。状況は同じです。

v1.9.1 でキャンペーンの件は直りました。もし将来、Qiita CLIがアドベントカレンダー用の欄を追加したとき、今回と同じことが起きる可能性はゼロではありません。過去記事のカレンダー登録が、同じように一斉に吹き飛ぶかもしれない。

ツールの修正は、いつも「事故のあと」にやってきます。次の新しいfrontmatterが来る前に、良い機会と捉え、自分の運用も見直すのが良さそうです。

記事公開の運用をこう変更しました

狙いは「次に何か新しいfrontmatterが増えても、二度と全損しない形」にすることです。

Qiita CLIの動作はこうです。qiita publish は必ず全記事をダウンロードして public/ に書き戻す。ならば発想を逆にして、Qiita CLIには「使い捨てのコピー領域」だけを触らせ、本物の記事には一切触れさせない方針にしました。

フォルダの役割を3つに分けます。

  • public/(Git管理・ステージング)… これから公開・更新したい記事だけを置く出荷用トレイ
  • published/(Git管理・正本)… 公開済みの記事の保管庫
  • work/(gitignore扱い・使い捨て)… CLIを走らせるためだけの作業場

GitHub Actionsはこう動かします。

  1. public/ を丸ごと work/ にコピー
  2. qiita publish --all --root ./work を実行(全記事ダウンロードは work/ の中だけで完結)
  3. Qiita CLIが標準出力に出す Updated: / Posted: から、実際に公開された記事名だけを拾う
  4. その記事だけ public/ から published/ へ移動
  5. work/ は捨てて、public/published/ だけをコミット

定期公開との噛み合わせも補足します。下書きは ignorePublish: true のまま public/ に滞留させておきます。CRONの定期公開が、ファイル名の日付で公開日を迎えたものを ignorePublish: false へ書き換え、それをトリガーにこのpublishフローが走ります。晴れて公開されると、その記事だけが published/ へ移ります。過去記事で紹介した「日付が来たら自動で公開」の仕組みは、この新運用でもそのまま活きています。

肝は3と4です。「Qiita CLIが公開・変更した記事」のみ正本に反映する。ダウンロードで増えたゴミも、身に覚えのない差分も、まとめて work/ ごと捨てられます。これで良いのだ。

# 1. public/(ステージング)を使い捨ての作業場へ
cp public/*.md work/public/
# 2. 使い捨てルートで公開(全記事DLは work/ に閉じ込められる)
qiita publish --all --root ./work | tee publish.log
# 3-4. CLIが「公開した」と言った記事だけを正本へ昇格
grep -E '^(Updated|Posted): ' publish.log \
  | sed -E 's/^[^:]*: (.+) -> .+/\1/' \
  | while read -r name; do
      cp "work/public/$name.md" "published/$name.md"
      rm -f "public/$name.md"
    done
# 5. workはコミットしない、commitとpushは省略
git add -A public published

ローカルのプレビューも同じ発想です。qiita preview --root ./preview と使い捨てルートで起動すれば、全記事ダウンロードは preview/ に閉じ込められ、手元の public/ は汚れません。「Qiita CLIは使い捨ての部屋でだけ暴れさせる」。これがこの運用の背骨です。

もっとも、本音を言えば、この「使い捨ての部屋で動かす」隔離は、いつかCLI本体が既定で面倒を見てくれると嬉しい部分でもあります。publish が一時領域のなかで完結し、実際に公開したものだけを書き戻す。そんな安全弁が標準で備われば、ユーザが毎回 work/ を用意しなくても済みます。あくまで一利用者の小さな願いとして、そっと添えておきます。

そして、これがさっきのアドベントカレンダーの不安への答えです。公開済みの記事は published/ にいて、CLIが触るのは毎回サーバから取り直す work/ だけ。しかも work/ の中身は常に最新なので、身に覚えのない差分で勝手に更新対象へ入ることもありません。だから将来カレンダー用の欄が増えても、過去記事のカレンダー登録が一斉に吹き飛ぶ筋書きは起きにくい。影響しうるのは、せいぜい「いま自分が手で編集して公開しようとしている、その1本」だけ。全損から、単発のかすり傷まで、被害の上限が下がりました。

もちろん過信は禁物です。新しいfrontmatterが増えたら、それが何であれ、いったん立ち止まってリリースノートと差分を確認する。この習慣だけは、これからも手放しません。

おわりに

現在進行中のキャンペーンは、CLI付属の posting-campaigns コマンド(開催中のキャンペーンをUUID付きで一覧表示する)でUUIDを調べ、手動で書き戻すことで復旧できました。しかし、応募期間が終了した過去のキャンペーンはそうもいきません。posting-campaigns に出るのは開催中のものだけで、終了したキャンペーンのUUIDを辿る術はなく、再登録する窓口ももうありません。あの記事たちが確かに何かのキャンペーンに参加していたという記憶だけを頼りに、どうにも戻せないまま、静かに諦めました。

ただ、私が踏んだこの地雷は、もう撤去されています。素早く直してくれた開発陣のおかげで、これから同じ道を通る人は、この穴には落ちません。
だからこの記事は、悲しみの記録であると同時に、感謝の記録でもあります。新しいバージョンが来たら、まず差分を確認する。そしてCLIは、使い捨ての部屋でだけ動かす。それだけで、じゅうぶんです。
そして最後に Qiita CLI 開発陣のみなさん、迅速な修正対応、ありがとうございました。


お断り
記事内容は個人の見解であり、所属組織の立場や戦略・意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?