1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiitaに初投稿するまで8年かかったので、自分の先延ばしをポストモーテムした【Qiita CLI × textlint × GitHub Actions】

1
Posted at

Sunrise Developer Workspace.png

インシデント ZUMI-0001
ステータス:解決済み(この記事の公開をもって)
影響期間:約8年
重大度:SEV-3(誰も困っていない。自分以外は)

はじめまして、ずみらくです。フリーランスで、AI と Web のあいだを行ったり来たりしているエンジニアです。

最初の記事を何にするか、かなり迷いました。RAG の精度で頭を抱えた話にするか。MCP サーバーを自作した話にするか。サーバー移行の切り替え当日、DNS の TTL を下げ忘れていたことに気づいて背中が冷たくなった話にするか。

迷った末に、いちばん身近な障害を題材にすることにしました。実務8年、技術記事を一度も書かなかったという、自分自身の障害です。

仕事で障害が起きたら、ポストモーテムを書きます。誰かを吊るし上げるためではなく、同じことを二度と起こさないためにです。それなら、自分の先延ばしにも同じことをすればいい。そう思って書き始めたら、思っていたよりずっと技術寄りの話に着地しました。最後には、記事を CI で検査してから Qiita に自動公開する仕組みができあがっています。この記事に載っているコードブロックも、後半で紹介するスクリプトで検査してから出しています。

0. このポストモーテムのルール

ポストモーテムで大事なのは blameless(非難しない) であることです。Google の SRE 本でもおなじみの考え方で、「誰がやったか」ではなく「なぜ仕組みがそれを許したか」を見ます。

なので、今回は過去の自分も責めません。責めると、たぶん次の8年も書かないので。

1. 概要

技術記事を書きたいという気持ちは、学生時代からずっとありました。PHP で作った掲示板が文字化けして途方に暮れていた夜、名前も知らない誰かの Qiita 記事に救われたのが最初です。「いつか自分も書く側に」と思ったのを、わりとはっきり覚えています。

それから PHP、Python、WordPress、Laravel、Next.js、AWS、そしてここ数年は LLM と RAG。触る技術はどんどん増えて、ハマった回数も増えて、そのたびに誰かの記事に助けられてきました。

そして、返したことは一度もありませんでした。

2. 影響

数字にできるものだけ書きます。

  • 8年間で解決したハマりどころ:数えきれない
  • そのうち、再利用できる形で記録が残っているもの:Slack の自分宛て DM に散らばった断片だけ
  • 同じ穴に二度落ちたこと:少なくとも1件は確実にある

その「確実な1件」はこうです。PDF の中の表が、RAG のチャンク分割で表の途中から真っ二つになり、検索で片方しか引っかからなくなる。ヘッダー行のないもう半分は、ただの数字の羅列です。LLM はその数字の羅列から、自信満々にそれらしい答えを作ってくれます。

1回目は原因の特定に丸一日かかりました。2年後、別の案件でまったく同じ症状に出会って、半日かかりました。2回目の自分は「これ、前にも見たな……」と思いながら、何をしたかを思い出せなかったんです。

1回目に書き残していれば、2回目の自分は少なくとも3時間早く寝られました。いちばん影響を受けたユーザーは、未来の自分です。

もうひとつの影響は、もっと静かです。「情けは人のためならず」という言葉があります。人に親切にすると、巡り巡って自分に返ってくるという意味です(「甘やかすと本人のためにならない」という意味ではありません。念のため)。8年間、受け取るだけで巡らせてこなかったので、そもそも何も返ってきようがなかったわけです。

3. タイムライン

時期 出来事
学生時代 文字化けした掲示板を Qiita の記事に救われる。書く側に憧れる
実務1年目 「もう少し経験を積んでから書こう」と決める。ここが障害の発生点
実務3年目 下書きが増え始める。中身はほぼ「タイトルだけ」
実務4年目 AWS の資格を取る。「これで書く資格ができた」と思う。書かない
実務6〜7年目 生成 AI が一気に普及。「AI が答えてくれるなら、もう書かなくてもいいのでは」という、もっともらしい言い訳を手に入れる
2026年10月 本記事を公開。復旧

資格を取ったら書く資格ができると思っていたあたりに、当時の自分の生真面目さと、少しずれた方向への几帳面さがにじんでいます。

4. 根本原因分析:なぜなぜを5回

なぜ書かなかったのか?
→「もっとちゃんと書けるようになってから」と思っていたから。

なぜ、ちゃんと書けるようになってからだと思ったのか?
→ 間違ったことを書いて、読んだ人を困らせるのが怖かったから。

なぜ、それがそんなに怖かったのか?
→ 記事を「一度出したら終わりの完成品」だと思っていたから。

なぜ、完成品だと思っていたのか?
→「本番に出すものはテスト済みであるべき」という仕事の感覚を、そのまま記事に持ち込んでいたから。

なぜ、その感覚が記事では足かせになったのか?
→ 記事に対するテストもリリースフローも、何ひとつ持っていなかったから。

5回目まで掘ったところで、自分でも少し笑ってしまいました。根本原因は、才能でも時間でも気合いでもありませんでした。デプロイパイプラインがなかったんです。

仕事のコードなら、テストがあって CI があって、レビューがあります。だから安心してリリースできます。記事にはそれがなかった。リリース前の検証手段がないものを本番に出すのは、誰だって怖いです。怖さの正体は、性格ではなく構成の問題でした。

ついでに言うと、ドナルド・クヌースの有名な言葉があります。

Premature optimization is the root of all evil.
(早すぎる最適化は諸悪の根源である)

自分がやっていたのは、まさにこれでした。最初のコミットもしていない記事の品質を、頭の中で延々と最適化し続けていたわけです。LinkedIn 創業者のリード・ホフマンはこう言っています。

If you are not embarrassed by the first version of your product, you've launched too late.
(最初のバージョンを恥ずかしいと思わないなら、ローンチが遅すぎたということだ)

8年は、さすがに遅すぎました。

5. 寄与要因

根本原因のほかに、悪化させた要因もありました。

完璧主義(重大度:高)

上で書いたとおりです。細かいところが気になる性分は、仕事では武器になります。ただ、出口のないところで発揮すると、ただのブレーキになります。

「AI があるから、もう書かなくていい」問題(重大度:中)

これは少し丁寧に考えたいところです。

Qiita が2026年4月に出したトレンド分析によると、2026年3月時点のタグランキングは「AI」が1位、「Claude Code」は2025年11月の27位から3位まで駆け上がっています。AI 関連の記事は2025年に前年の2倍以上に増えたそうです。書く側も読む側も、AI と一緒に開発する時代にすっかり入っています。

一方で、2026年6月にエンジニア330人を対象にした調査では、89.7% が「技術書は学習手段として手放せない」と答えています。理由のトップは「情報の信頼性」で、64.9% でした。同じ調査で「生成 AI に質問する」と答えた人も 43.9% います。AI に聞きつつ、最後に信じるのは「誰かが責任を持って書いたもの」なんですね。

AI は答えをくれます。それはもう、本当に優秀です。でも「その答えにたどり着くまでに、どこで何時間溶かしたか」までは教えてくれません。ハマった夜の手触りや、「この設定を変えたら直ったけど、なぜ直ったかは翌朝まで分からなかった」というような泥くさい経緯は、書かれない限りどこにも残りません。

物理学者のリチャード・ファインマンの黒板には、こう書かれていたそうです。

What I cannot create, I do not understand.
(自分で作れないものは、理解していない)

自分にとって、書くことはまさに「作る」側の作業です。人に説明できるところまで言語化して、初めて「分かった」と言える。AI が賢くなるほど、この部分の価値はむしろ上がっていると感じます。

石の上にも三年(重大度:低)

我慢強く続ければ報われる、という意味のことわざです。ただし三年です。8年は座りすぎでした。

6. 対応:記事のためのリリースパイプラインを作る

根本原因が「パイプラインがない」なら、対応は「パイプラインを作る」です。「段取り八分、仕事二分」というのは自分が昔から大事にしている言葉で、準備さえ整えば本番は二割の力で済む、という意味です。今回はその八分のほうをやります。

Linus Torvalds の言葉を借りるなら "Talk is cheap. Show me the code." です。ここから先はコードの話をします。

最終的な構成はこうなりました。

.
├── .github/workflows/publish.yml   # lint → 公開
├── .textlintrc.json                # 日本語の校正ルール
├── prh.yml                         # 表記ゆれの辞書
├── scripts/check_codeblocks.py     # コードブロックの構文チェック
├── public/                         # 記事本体(Qiita CLI の管理対象)
└── package.json

6-1. Qiita CLI で記事を Git 管理する

Qiita 公式の Qiita CLI を使うと、記事を Markdown ファイルとしてローカルで書いて、Git で管理して、GitHub Actions から公開できます。

npm install @qiita/qiita-cli --save-dev
npx qiita init     # GitHub Actions のワークフローや設定ファイルを生成
npx qiita login    # アクセストークンを登録
npx qiita new first-post-postmortem
npx qiita preview  # ブラウザでプレビュー

アクセストークンのスコープは read_qiita と write_qiita だけで足ります。必要以上の権限は渡さない。最小権限の原則は、ブログでも同じです。

記事ファイルの先頭には Front Matter が付きます。下書きのうちは ignorePublish: true にしておけば、CI が回っても公開されません。うっかり書きかけを全世界に公開してしまう事故を、仕組みで防げます。

6-2. textlint で日本語にもテストを書く

コードに linter があるように、日本語にも linter があります。textlint と、技術文書向けのルールプリセット textlint-rule-preset-ja-technical-writing を入れます。

npm install --save-dev textlint textlint-rule-preset-ja-technical-writing textlint-rule-prh
.textlintrc.json
{
  "rules": {
    "preset-ja-technical-writing": {
      "sentence-length": { "max": 100 },
      "no-exclamation-question-mark": false,
      "ja-no-mixed-period": false
    },
    "prh": {
      "rulePaths": ["./prh.yml"]
    }
  }
}

1文100文字を超えると怒られます。最初に自分の下書きを通したとき、かなり怒られました。読み返すと、確かにどれも一息で読めない文でした。機械に言われると素直に直せるのは不思議です。

no-exclamation-question-mark を切っているのは、「なぜなぜ5回」のように疑問文で書きたい場面があるからです。ルールは守るためにありますが、理由を説明できるなら外していい。これはコードの lint と同じ考え方です。

表記ゆれは prh で辞書化します。

prh.yml
version: 1
rules:
  - expected: JavaScript
    patterns:
      - javascript
      - Javascript
  - expected: GitHub
    pattern: /\bGithub\b/
  - expected: サーバー
    pattern: /サーバ(?!ー)/

「Github」と書いてしまう癖は、この辞書を作った初日に3回検出されました。

6-3. 記事のコードブロックが壊れていないかを検査する

ここが一番やりたかったところです。

技術記事で読者をいちばん困らせるのは、たぶん誤字ではありません。コピペしたら動かないコードです。閉じ忘れたカッコ、JSON の末尾カンマ、if に対応する fi がないシェルスクリプト。書いた本人は気づかず、読んだ人だけがハマります。

なので、Markdown の中のコードブロックを全部取り出して、言語ごとに構文チェックをかけるスクリプトを書きました。

scripts/check_codeblocks.py
#!/usr/bin/env python3
"""記事中のコードブロックが「少なくとも構文として壊れていないか」だけを確かめる。

動くことまでは保証しない。でも、閉じ忘れたカッコで読者を待たせることはなくなる。
"""
import ast
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)")
FENCE_CLOSE = re.compile(r"^ {0,3}(`{3,}|~{3,})\s*$")
ALIASES = {"py": "python", "js": "javascript", "mjs": "javascript", "sh": "bash", "shell": "bash"}


def check_python(code):
    try:
        ast.parse(code)
    except SyntaxError as e:
        return f"ブロック内 {e.lineno} 行目: {e.msg}"
    return None


def check_json(code):
    try:
        json.loads(code)
    except json.JSONDecodeError as e:
        return f"ブロック内 {e.lineno} 行目: {e.msg}"
    return None


def check_bash(code):
    r = subprocess.run(["bash", "-n"], input=code, capture_output=True, text=True)
    if r.returncode:
        return r.stderr.strip() or "bash -n failed"
    return None


def check_javascript(code):
    if shutil.which("node") is None:
        return None  # node がない環境では黙ってスキップ(CI では必ず入っている)
    with tempfile.NamedTemporaryFile("w", suffix=".mjs", delete=False) as f:
        f.write(code)
    try:
        r = subprocess.run(["node", "--check", f.name], capture_output=True, text=True)
        if r.returncode:
            return r.stderr.strip() or "node --check failed"
        return None
    finally:
        Path(f.name).unlink()


CHECKERS = {"python": check_python, "json": check_json, "bash": check_bash, "javascript": check_javascript}


def code_blocks(markdown):
    """(開始行番号, info文字列, 本文, 閉じているか) を順に返す。"""
    opened = None
    for no, line in enumerate(markdown.splitlines(), start=1):
        if opened is None:
            m = FENCE_OPEN.match(line)
            if m:
                opened = {"fence": m[1], "info": m[2], "start": no, "body": []}
            continue
        m = FENCE_CLOSE.match(line)
        if m and m[1][0] == opened["fence"][0] and len(m[1]) >= len(opened["fence"]):
            yield opened["start"], opened["info"], "\n".join(opened["body"]), True
            opened = None
        else:
            opened["body"].append(line)
    if opened:
        yield opened["start"], opened["info"], "", False


def main(paths):
    problems = 0
    for path in paths:
        text = Path(path).read_text(encoding="utf-8")
        for start, info, code, closed in code_blocks(text):
            where = f"{path}:{start}"
            if not closed:
                print(f"✗ {where} コードブロックが閉じていません", file=sys.stderr)
                problems += 1
                continue
            if not info:
                print(f"✗ {where} 言語の指定がありません", file=sys.stderr)
                problems += 1
                continue
            lang = info.split(":")[0].lower()  # Qiita の ```python:main.py 記法に対応
            checker = CHECKERS.get(ALIASES.get(lang, lang))
            error = checker(code) if checker else None
            if error:
                print(f"✗ {where} ({lang})\n    {error}", file=sys.stderr)
                problems += 1
    if problems:
        print(f"\n{problems} 件の問題が見つかりました", file=sys.stderr)
        return 1
    print("✓ すべてのコードブロックを確認しました")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

ポイントをいくつか。

Python 本体で書いて、依存ゼロにしています。 Python は ast.parse で、JSON は json.loads で、シェルは bash -n で構文だけを見ます。どれもコードを実行しません。記事の中には rm を含むシェル例が載ることもあるので、検査のつもりで手元のファイルを消してしまったら笑えません。

言語指定のないコードブロックはエラーにしています。 シンタックスハイライトが効かないと読みにくいですし、何より検査をすり抜けます。書き手の怠慢がそのまま穴になるので、そこを塞いでおきたかったんです。ディレクトリ構成のように言語がないものは、上の例のように text と書けば通ります。

Qiita 独自の ```python:ファイル名 記法にも対応しています。 コロンの手前だけを言語として扱います。

「構文が正しい」までしか保証しません。 動作までは見ていません。そこは正直に docstring に書きました。テストの限界を書いておくのも、テストの一部だと思っています。

実際にわざと壊したサンプルを食わせると、こう返ってきます。

✗ sample.md:6 (python)
    ブロック内 1 行目: invalid syntax
✗ sample.md:11 (json)
    ブロック内 1 行目: Illegal trailing comma before end of object
✗ sample.md:15 (bash)
    bash: line 2: syntax error: unexpected end of file from `if' command on line 1
✗ sample.md:19 言語の指定がありません

4 件の問題が見つかりました

余談ですが、GitHub のドキュメントを読むと、コードスキャンのアラートを AI が自動修正する「agentic autofix」は、修正後に CodeQL をもう一度走らせて、その修正が本当に効いているかを自分で確かめる仕組みになっているそうです。AI ですら自分の仕事を検証してから PR を出す時代に、人間が検証なしで記事を出すわけにはいきません。

6-4. GitHub Actions で「lint が通ったら公開」にする

npx qiita init が生成するワークフローは「main に push したら公開」だけのシンプルなものです。その手前に lint のジョブを挟みます。

.github/workflows/publish.yml
name: Lint and publish articles

on:
  pull_request:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: write

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: npm
      - uses: actions/setup-python@v6
        with:
          python-version: "3.13"
      - run: npm ci
      - run: npx textlint "public/**/*.md"
      - run: python scripts/check_codeblocks.py public/*.md

  publish:
    needs: lint
    if: github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0
      - uses: increments/qiita-cli/actions/publish@v1
        with:
          qiita-token: ${{ secrets.QIITA_TOKEN }}
          root: "."

これで、記事はプルリクエストで lint され、main にマージされたときだけ公開されます。自分ひとりのリポジトリでもプルリクエストを作るようにすると、差分を見返すタイミングが必ず1回挟まります。レビュアーが自分しかいなくても、レビューはレビューです。

6-5. 実際に踏んだ(踏みかけた)細かい罠

几帳面な性分は、こういうところで役に立ちます。

  • glob はクォートで囲む。 npx textlint public/**/*.md とクォートなしで書くと、先にシェルが展開します。bash は globstar が無効だと ** を * と同じに扱うので、public/*/*.md 相当になって、肝心の public/ 直下の記事が渡りません。しかも何もマッチしなければパターンが文字列のまま textlint に渡り、たまたま正しく動いてしまう。環境によって結果が変わる、いちばん嫌なタイプの罠です。クォートで囲んで、展開は textlint 自身に任せます。
  • 初回公開時、Actions がリポジトリにコミットを積みます。 記事に Qiita 側の id が書き戻されるためです。permissions: contents: write が必要なのはこのためです。手元で git pull を忘れたまま次の修正を push すると、きれいにコンフリクトします(しました)。
  • ローカルの記事ファイルを消しても、Qiita 上の記事は消えません。 削除は Qiita の画面から行います。「Git で管理している=Git が正」と思い込むと、ここでズレます。
  • トークンは Secrets に入れる。 当たり前ですが、当たり前のことほど一度ちゃんと書いておきたいので書きました。

7. 教訓

ポストモーテムの定番の3項目で振り返ります。

うまくいったこと

「書けない」を性格の問題ではなく、仕組みの問題として扱えたことです。仕組みの問題なら、エンジニアはいくらでも手を打てます。「気合いで毎日書く」は再発防止策になりませんが、「lint が通れば出す」は再発防止策になります。

うまくいかなかったこと

8年かかったことです。以上です。

運が良かったこと

8年サボっている間に、Qiita がなくならなかったことです。登録者は150万人規模、記事は累計100万件を超えたそうです。遅れて来た新人でも、場所はちゃんと残っていました。ありがたい話です。

8. アクションアイテム

ID 内容 担当 期限
AI-1 記事用リポジトリと CI を整備する 自分 完了(本記事)
AI-2 月2本ペースで書く 自分 継続
AI-3 ハマったら、その日のうちに「下書き」ではなく「メモ」を残す 自分 今日から
AI-4 下書きを90日寝かせたら、出すか消すかを決める 自分 四半期ごと

月2本は少なく見えるかもしれません。でも、守れない SLO は SLO ではありません。最初は守れる数字から始めて、エラーバジェットに余裕が出てきたら引き上げます。

ついでに、この先の記事に対する自分なりの SLO も宣言しておきます。

  • 掲載コードの構文チェック通過率:100%(CI で強制)
  • 動作を確認した環境とバージョンを、必ず本文に書く
  • 誤りの指摘をいただいてから修正するまで:72時間以内

「絶対に間違えない」とは約束できません。間違いは出ます。人間なので。その代わり、間違いを放置しないことは約束できます。コメント欄でのご指摘は、障害報告と同じ重さで受け止めます。

書こうと思っているのは、RAG を実務で運用して分かったこと、Claude Code や MCP、AI エージェントを業務に組み込むときの段取りとハマりどころ、WordPress を何十サイトも運用するときの地味だけど効く工夫、AWS 移行で無停止を目指すときの手順、脆弱性診断の現場の話、などです。たまに技術以外の話も混ぜるかもしれません。締め切りとの付き合い方とか、打ち合わせ後の10分で何をするかとか。

今月は Anthropic から Claude Haiku 5.5 が出ました。新しいモデルは毎月のように出ますし、推論の深さを effort で調整できるモデルも当たり前になりました。自分の effort は常に high で固定しておくつもりです。ただ、max にすると8年前の自分に戻りそうなので、そこはほどほどにします。

9. ステータス更新

このポストモーテムを書いてみて、ひとつ気づいたことがあります。

書けなかった8年間、自分は「書く準備」をしていたつもりでした。でも本当に必要だった準備は、知識でも経験でも資格でもなく、間違えても大丈夫な仕組みのほうでした。知識はもう十分にあったんです。足りなかったのは、それを外に出す勇気を支える足場でした。

「千里の道も一歩から」という言葉は、もとは老子の「千里の行も足下より始まる」です。最初の一歩は、思っていたよりずっと地味でした。.textlintrc.json を1枚書いて、Python のスクリプトを1本書いて、YAML を1枚書いただけです。

インシデント ZUMI-0001 のステータスを、解決済み から 監視中 に変更します。

次の記事が出なかったら、たぶん再発です。そのときはコメント欄でアラートを鳴らしてください。オンコール担当(自分)がすぐに起きます。


次回予告(ZUMI-0002)

2年をあけて同じ穴に二度落ちた、あの件を書きます。PDF の表がチャンクの境目で真っ二つになり、LLM が自信満々に存在しない数字を答えた事件です。

RAG の精度が出ないとき、多くの人はまずチャンクサイズを疑います。自分もそうでした。でも、本当に最初に見るべき場所は、そこではありませんでした。

1
1
2

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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?