From 4af7a5e0c56c9163b486a75d384bc978a8fa73e3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 20:38:09 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore(PLAN60):=20CI=20=E3=81=A7=20tests/=20?= =?UTF-8?q?=E3=81=AE=20pytest=20=E3=82=92=E5=AE=9F=E8=A1=8C=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uv.lock から uv sync --frozen で依存を入れ、Python 3.10 / 3.13 で pytest tests/ を回す Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 19 ++++++++++ issues/PLAN60_ci-pytest.md | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 issues/PLAN60_ci-pytest.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77fb0f1d..2b31895d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,22 @@ jobs: severity: error - name: Run ShellCheck on install.sh run: shellcheck --severity=error install.sh + + pytest: + name: Pytest (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # requires-python の下限と、手元で使う版 (PLAN60 前提 1) + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies from uv.lock + run: uv sync --frozen + - name: Run pytest + # CI に DEVBASE_ROOT は無い。テストは自前の tmp を DEVBASE_ROOT に向ける (PLAN60 前提 4) + run: uv run --frozen pytest tests/ -q diff --git a/issues/PLAN60_ci-pytest.md b/issues/PLAN60_ci-pytest.md new file mode 100644 index 00000000..3683cb8b --- /dev/null +++ b/issues/PLAN60_ci-pytest.md @@ -0,0 +1,76 @@ +# PLAN60: CI で tests/ の pytest を実行する + +対象 issue: devbasex/devbase#141 + +- ワークフローモード: `light` + - 根拠: 変えるのは CI の構成(`.github/workflows/ci.yml`)と、CI で通すためのテスト側の調整だけで、 + 本番の振る舞いも本番コード(`bin/` `lib/` `etc/` `containers/`)の構造も変えない + +## 依頼(原文) + +> `tests/` に pytest のテスト一式(`tests/cli` `tests/commands` `tests/project` `tests/snapshot` など 10 ディレクトリ)がありますが、`.github/workflows/ci.yml` はこれを実行しません。 +> +> テストを足しても、それが壊れたことに CI が気づきません。 +> +> 導入時には次を確認する必要があります。 +> +> - `uv` のセットアップ手順(`pyproject.toml` / `uv.lock` がある) +> - 実 `docker` を必要とするテストがある場合の切り分け(マーカーか、CI では除外するか) + +## 目的 + +- `tests/` のテストが壊れた変更を、Pull Request の時点で CI が落とすようにする + +## 前提 + +- 前提 1: CI の Python は `pyproject.toml` の `requires-python` の下限と、手元で使う版(2026-09-18 時点で uv が選ぶ 3.13)の 2 つで足りる。 + 既存の `python-syntax` ジョブの行列(3.10 / 3.11 / 3.12)には合わせない。pytest の実行時間が 3 倍になる + 割に、版の差で落ちる箇所は `compileall` の行列が既に構文で拾っている +- 前提 2: 依存は `uv.lock` から `uv sync --frozen` で入れる。lock と `pyproject.toml` が食い違えば CI が落ちる +- 前提 3: 実 docker・実 OpenBao・ネットワークを要るテストは、CI では走らせない。手元で走る + テストのうち CI で落ちるものは、**テストの側を直して**(環境の隔離・スタブ)CI でも通るようにする。 + `skip` で逃がすのは、実機そのものを確かめるテストに限る +- 前提 4: CI の実行環境に `DEVBASE_ROOT` は無い。手元の実行はシェルの `DEVBASE_ROOT` を継承するため、 + 両方で同じ結果になるテストだけを CI に載せる + +## 対象範囲 + +含む: + +- `.github/workflows/ci.yml` に pytest のジョブを足す +- CI の環境(`DEVBASE_ROOT` 無し・docker 無し・Linux)で落ちるテストの修正 +- 開発者向け文書の CI の説明(`docs/developer/contributing.md` / `CONTRIBUTING.md` に記載があれば) + +含まない: + +- 本番コードの変更 +- カバレッジの計測・閾値 +- ruff の検査範囲の拡大(`--select` の追加) +- 既存ジョブ(`python-syntax` / `lint` / `shellcheck`)の変更 + +## 受け入れ条件 + +- [ ] 1. `main` 宛ての Pull Request と `main` への push で、`tests/` 全体の pytest を実行するジョブが走る +- [ ] 2. そのジョブは `uv.lock` から依存を入れ(`uv sync --frozen` 相当)、lock に無い依存を取りに行かない +- [ ] 3. この Pull Request の CI で、そのジョブが成功する +- [ ] 4. テストを 1 件わざと失敗させた状態で、そのジョブが失敗する(確かめた後にその変更は戻す) +- [ ] 5. 手元(`DEVBASE_ROOT` を持つシェル)の `uv run pytest tests/` の結果が、この変更の前後で変わらない + (通っていたテストが落ちない。件数の変化は CI 向けに skip へ回したものの数と一致する) +- [ ] 6. CI で skip するテストがあれば、1 件ごとに skip の理由がテストの中に書かれ、実機を要するテストに限られる + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト(手元) | `uv run pytest tests/ -q` | +| テスト(CI 相当) | `env -u DEVBASE_ROOT uv run --frozen pytest tests/ -q` | +| CI | Pull Request の checks(`gh pr checks`) | +| 静的解析 | 既存の `python-syntax` / `lint` / `shellcheck` のジョブ | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 手元と CI 相当の両方で pytest を回す | +| 確認してから行う | テストを skip へ回すこと(理由を条件 6 のとおり残す) | +| 行わない | 本番コードの変更、既存ジョブの変更 | From cdff561072efa0f9f5668a691d2508876f5d4042 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 20:41:32 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test(PLAN60):=20Linux=20=E3=81=AE=20CI=20?= =?UTF-8?q?=E3=81=A7=E8=90=BD=E3=81=A1=E3=82=8B=203=20=E4=BB=B6=E3=82=92?= =?UTF-8?q?=E7=92=B0=E5=A2=83=E3=81=AB=E4=BE=9D=E3=82=89=E3=81=AA=E3=81=84?= =?UTF-8?q?=E5=BD=A2=E3=81=B8=E7=9B=B4=E3=81=99=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entrypoint の gcloud 不在: /usr/bin を並べず bash だけを置いた PATH を作る (runner は /usr/bin/gcloud を持つ) - tmux の terminal-overrides: 既定を固定値でなく空の設定を読ませた同じ tmux から取る - cache の消せない控え: os.unlink でなく Path.unlink を差し替える (3.10 の pathlib は束縛済み) Co-Authored-By: Claude Opus 5 (1M context) --- .../containers/test_entrypoint_startup_log.py | 26 ++++++++++++++----- tests/containers/test_tmux_conf.py | 15 +++++++---- tests/env/test_cache.py | 7 +++-- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/tests/containers/test_entrypoint_startup_log.py b/tests/containers/test_entrypoint_startup_log.py index 36372507..b3db9454 100644 --- a/tests/containers/test_entrypoint_startup_log.py +++ b/tests/containers/test_entrypoint_startup_log.py @@ -8,6 +8,7 @@ from __future__ import annotations import os +import shutil import subprocess from pathlib import Path @@ -16,8 +17,6 @@ ENTRYPOINT = Path(__file__).resolve().parents[2] / "containers" / "base" / "entrypoint.sh" -# gcloud を含まない最小の PATH。`/nonexistent` にすると bash 自体も見つからない。 -MINIMAL_PATH = "/usr/bin:/bin:/usr/sbin:/sbin" def run(script: str, cwd: Path, path: str | None = None): @@ -45,6 +44,21 @@ def install(script: str): return install +@pytest.fixture +def no_gcloud_path(tmp_path: Path) -> str: + """``bash`` だけを置いた PATH (gcloud を含まない)。 + + システムの ``/usr/bin`` を並べるだけでは足りない。GitHub Actions の Ubuntu の + runner は ``/usr/bin/gcloud`` を持つ (PLAN60)。`/nonexistent` にすると bash 自体も + 見つからないため、bash への symlink だけを持つディレクトリを作る。 + entrypoint.sh の関数定義より前のトップレベルは外部コマンドを呼ばない。 + """ + d = tmp_path / "minimal-bin" + d.mkdir() + (d / "bash").symlink_to(shutil.which("bash")) + return str(d) + + def test_group_and_account_are_reported(tmp_path, fake_bin): path = fake_bin('echo "someone@example.com"') @@ -77,16 +91,16 @@ def test_empty_account_is_reported_as_unset(tmp_path, fake_bin): assert "gcloud account: unset" in result.stdout -def test_missing_gcloud_is_reported(tmp_path): +def test_missing_gcloud_is_reported(tmp_path, no_gcloud_path): """gcloud を含まないイメージでも落ちない。""" - result = run('devbase_log_account_group "default"', tmp_path, path=MINIMAL_PATH) + result = run('devbase_log_account_group "default"', tmp_path, path=no_gcloud_path) assert result.returncode == 0, result.stderr assert "gcloud not installed" in result.stdout -def test_group_defaults_when_omitted(tmp_path): - result = run('devbase_log_account_group', tmp_path, path=MINIMAL_PATH) +def test_group_defaults_when_omitted(tmp_path, no_gcloud_path): + result = run('devbase_log_account_group', tmp_path, path=no_gcloud_path) assert result.returncode == 0, result.stderr assert "Account group: default" in result.stdout diff --git a/tests/containers/test_tmux_conf.py b/tests/containers/test_tmux_conf.py index ca0257a5..e4fcec67 100644 --- a/tests/containers/test_tmux_conf.py +++ b/tests/containers/test_tmux_conf.py @@ -152,11 +152,16 @@ def test_default_terminal_is_tmux_256color(options): @needs_tmux -def test_terminal_overrides_appends_without_dropping_defaults(options): - """条件 5: `set -ga` で追記し、tmux 既定の linux*:AX@ を残す。""" - overrides = options["terminal-overrides"] - assert "xterm-256color:Tc" in overrides - assert "linux*:AX@" in overrides +def test_terminal_overrides_appends_without_dropping_defaults(options, tmp_path): + """条件 5: `set -ga` で追記し、tmux 既定の値 (3.x 前半の linux*:AX@ など) を残す。 + + 既定の値は tmux の版で違う (Ubuntu 24.04 の tmux は空の既定を持つ。PLAN60)。 + 固定の値ではなく、空の設定を読ませた同じ tmux の既定と比べる。 + """ + empty_conf = tmp_path / "empty.tmux.conf" + empty_conf.write_text("") + defaults = _effective_options(empty_conf).get("terminal-overrides", []) + assert options["terminal-overrides"] == [*defaults, "xterm-256color:Tc"] @needs_tmux diff --git a/tests/env/test_cache.py b/tests/env/test_cache.py index df6b57df..c4fa1507 100644 --- a/tests/env/test_cache.py +++ b/tests/env/test_cache.py @@ -7,6 +7,7 @@ import logging import os import stat +from pathlib import Path import pytest @@ -371,14 +372,16 @@ def test_undeletable_entries_fail_with_their_paths(openbao_root, openbao, monkey bc.save(openbao_root, bc.BackendConfig(backend='openbao', openbao=ob, cache_enabled=False)) target = cache.entry_path(openbao_root, GLOBAL) - real_unlink = os.unlink + real_unlink = Path.unlink def deny(path, *a, **kw): if str(path) == str(target): raise PermissionError('denied') return real_unlink(path, *a, **kw) - monkeypatch.setattr(os, 'unlink', deny) + # os.unlink ではなく Path.unlink を差し替える。Python 3.10 の pathlib は + # os.unlink をクラス定義の時点で束縛するため、os 側の差し替えが届かない (PLAN60) + monkeypatch.setattr(Path, 'unlink', deny) with pytest.raises(SecretStoreError) as exc: SecretStore(openbao_root).load(GLOBAL) From 609dc5f35c61dd964820c1006c23f6e24d202c6a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 20:50:00 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test(PLAN60):=20=E7=B4=A0=E3=81=AE=20tmux?= =?UTF-8?q?=20=E3=81=8C=E6=97=A2=E5=AE=9A=E3=82=92=E6=8C=81=E3=81=9F?= =?UTF-8?q?=E3=81=AA=E3=81=84=E7=92=B0=E5=A2=83=E3=81=A7=20terminal-overri?= =?UTF-8?q?des=20=E3=81=AE=E6=AF=94=E8=BC=83=E3=81=8C=E8=90=BD=E3=81=A1?= =?UTF-8?q?=E3=82=8B=E3=81=AE=E3=82=92=E7=9B=B4=E3=81=99=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu の tmux は値の無い terminal-overrides 行を出し、_parse_options が 空文字を既定として積むため、追記後の値と一致しなかった。比べる前に空文字を除く。 あわせて pytest ジョブに timeout-minutes: 15 を付ける。 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 1 + tests/containers/test_tmux_conf.py | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b31895d..435132a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,7 @@ jobs: pytest: name: Pytest (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/tests/containers/test_tmux_conf.py b/tests/containers/test_tmux_conf.py index e4fcec67..27e61472 100644 --- a/tests/containers/test_tmux_conf.py +++ b/tests/containers/test_tmux_conf.py @@ -155,12 +155,16 @@ def test_default_terminal_is_tmux_256color(options): def test_terminal_overrides_appends_without_dropping_defaults(options, tmp_path): """条件 5: `set -ga` で追記し、tmux 既定の値 (3.x 前半の linux*:AX@ など) を残す。 - 既定の値は tmux の版で違う (Ubuntu 24.04 の tmux は空の既定を持つ。PLAN60)。 - 固定の値ではなく、空の設定を読ませた同じ tmux の既定と比べる。 + 既定の値は tmux の版で違う (Ubuntu 24.04 の tmux は既定を持たない。PLAN60)。 + 固定の値ではなく、空の設定を読ませた同じ tmux の既定と比べる。既定を持たない + tmux は値の無い ``terminal-overrides`` 行を出し、``_parse_options`` が空文字を + 積むため、比べる前に空文字を除く (追記後の値には空文字が残らない)。 """ empty_conf = tmp_path / "empty.tmux.conf" empty_conf.write_text("") - defaults = _effective_options(empty_conf).get("terminal-overrides", []) + defaults = [ + d for d in _effective_options(empty_conf).get("terminal-overrides", []) if d + ] assert options["terminal-overrides"] == [*defaults, "xterm-256color:Tc"] From 08ffa23c42f85e3a11666440abc9f8c554f43759 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Fri, 18 Sep 2026 20:57:05 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(PLAN60):=20CI=20=E3=81=AE=E4=BE=9D?= =?UTF-8?q?=E5=AD=98=E5=B0=8E=E5=85=A5=E3=82=92=20uv=20sync=20--locked=20?= =?UTF-8?q?=E3=81=AB=E3=81=97=20lock=20=E3=81=A8=20pyproject.toml=20?= =?UTF-8?q?=E3=81=AE=E9=A3=9F=E3=81=84=E9=81=95=E3=81=84=E3=81=A7=E8=90=BD?= =?UTF-8?q?=E3=81=A1=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= =?UTF-8?q?=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --frozen は lock と pyproject.toml の整合を検査しない。PLAN60 前提 2 を満たすため --locked に変え、 続く uv run も揃えて --locked にする。PLAN60 の記述も合わせる。 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 4 ++-- issues/PLAN60_ci-pytest.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 435132a5..ae8a7b4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies from uv.lock - run: uv sync --frozen + run: uv sync --locked - name: Run pytest # CI に DEVBASE_ROOT は無い。テストは自前の tmp を DEVBASE_ROOT に向ける (PLAN60 前提 4) - run: uv run --frozen pytest tests/ -q + run: uv run --locked pytest tests/ -q diff --git a/issues/PLAN60_ci-pytest.md b/issues/PLAN60_ci-pytest.md index 3683cb8b..f85de9e7 100644 --- a/issues/PLAN60_ci-pytest.md +++ b/issues/PLAN60_ci-pytest.md @@ -26,7 +26,7 @@ - 前提 1: CI の Python は `pyproject.toml` の `requires-python` の下限と、手元で使う版(2026-09-18 時点で uv が選ぶ 3.13)の 2 つで足りる。 既存の `python-syntax` ジョブの行列(3.10 / 3.11 / 3.12)には合わせない。pytest の実行時間が 3 倍になる 割に、版の差で落ちる箇所は `compileall` の行列が既に構文で拾っている -- 前提 2: 依存は `uv.lock` から `uv sync --frozen` で入れる。lock と `pyproject.toml` が食い違えば CI が落ちる +- 前提 2: 依存は `uv.lock` から `uv sync --locked` で入れる。lock と `pyproject.toml` が食い違えば CI が落ちる - 前提 3: 実 docker・実 OpenBao・ネットワークを要るテストは、CI では走らせない。手元で走る テストのうち CI で落ちるものは、**テストの側を直して**(環境の隔離・スタブ)CI でも通るようにする。 `skip` で逃がすのは、実機そのものを確かめるテストに限る @@ -51,7 +51,7 @@ ## 受け入れ条件 - [ ] 1. `main` 宛ての Pull Request と `main` への push で、`tests/` 全体の pytest を実行するジョブが走る -- [ ] 2. そのジョブは `uv.lock` から依存を入れ(`uv sync --frozen` 相当)、lock に無い依存を取りに行かない +- [ ] 2. そのジョブは `uv.lock` から依存を入れ(`uv sync --locked` 相当)、lock に無い依存を取りに行かない - [ ] 3. この Pull Request の CI で、そのジョブが成功する - [ ] 4. テストを 1 件わざと失敗させた状態で、そのジョブが失敗する(確かめた後にその変更は戻す) - [ ] 5. 手元(`DEVBASE_ROOT` を持つシェル)の `uv run pytest tests/` の結果が、この変更の前後で変わらない @@ -63,7 +63,7 @@ | 項目 | 手段 | | --- | --- | | テスト(手元) | `uv run pytest tests/ -q` | -| テスト(CI 相当) | `env -u DEVBASE_ROOT uv run --frozen pytest tests/ -q` | +| テスト(CI 相当) | `env -u DEVBASE_ROOT uv run --locked pytest tests/ -q` | | CI | Pull Request の checks(`gh pr checks`) | | 静的解析 | 既存の `python-syntax` / `lint` / `shellcheck` のジョブ |