Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,16 @@

## [Unreleased]

### Added
- **`plugin.yml` の `requires.devbase` をインストール時に検証**するようにしました。要件を
満たさない Plugin は `devbase plugin install` が中止します。これまでは値を読むだけで
比較しておらず、`project.yml` 形式の Plugin を 2.x へ入れられてしまい、`devbase up` の
段階で初めて失敗していました。既存のインストールに触れる前に検証するため、入れ替えに
失敗しても既存の Plugin は壊れません。解釈できない書式は警告に留めて続行し、
`DEVBASE_IGNORE_PLUGIN_REQUIRES=1` で検証を無効化できます。
- **`devbase plugin update` でも `requires.devbase` を確認**するようにしました。`git pull` で
要求が上がって満たさなくなった Plugin は警告で知らせます(更新は既に済んでいるため中止はしません)。

## [3.0.0] - 2026-08-23

プロジェクト設定を `project.yml` へ移行する破壊的変更を含みます。プラグイン側の
Expand Down
36 changes: 34 additions & 2 deletions docs/plugin-dev/plugin-yml-reference.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,12 +130,44 @@ requires:
| 値 | 意味 |
|----|------|
| `">=3.0.0"` | devbase 3.0.0 以上が必要 |
| `">=3.0.0,<4.0.0"` | 範囲指定(カンマ区切りは AND) |
| `"==3.0.0"` / `"3.0.0"` | 一致(演算子を省略すると `==` 扱い) |
| 省略 | バージョン要件なし(どの版でも導入を試みる) |

使える演算子は `>=` / `<=` / `>` / `<` / `==` / `!=` です。版数の要素数は自由で、
桁数が違う場合は短い方を `0` で埋めて比較します(`">=3.0"` と `3.0.0` は等しい、
`">=3.0.0.1"` を `3.0.0` は満たさない)。比較は数値で行うため `10.0.0` は `3.0.0` より新しく扱われます。

**devbase 3.0.0 以降のPluginは `">=3.0.0"` を指定してください。**
プロジェクト設定を `projects/<name>/project.yml` で記述する形式は devbase 3.0.0 で導入されたもので、
2.x 系の devbase は `project.yml` を読めません。要件を書かないまま 2.x へ導入されると、
インストールは成功するのに `devbase up` の段階で初めて失敗します。
2.x 系の devbase は `project.yml` を読めません。

### インストール時の検証

`devbase plugin install` は、要件を満たさない Plugin のインストールを**中止**します。

```
Error: プラグイン 'carmo-web' は devbase >=3.0.0 を要求していますが、現在の devbase は 2.2.0 です。
devbase を更新してから再度インストールしてください (検証を飛ばす場合は DEVBASE_IGNORE_PLUGIN_REQUIRES=1)。
```

- 既存のインストールに触れる**前**に検証するため、入れ替えに失敗しても既に入っている Plugin は壊れません
- 解釈できない書式(`"^3.0.0"` など)や版数のときは、**警告を出して検証せずに続行**します。独自記法を書いた Plugin をインストール不能にするより実害が小さいためです
- 検証側の判断が誤っているときは `DEVBASE_IGNORE_PLUGIN_REQUIRES=1` で無効化できます

> **必ずクォートしてください。** `devbase: 3.10` のようにクォート無しで書くと YAML が数値
> (float の `3.1`)として読み、元の表記へ戻せません。この場合は誤った版で比較せず、
> 警告を出して**検証をスキップ**します(`devbase: ">=3.10"` と書けば検証されます)。

### 更新時の警告

`devbase plugin update`(`git pull`)で Plugin 側の `requires.devbase` が上がることがあります。
更新自体は既に済んでいて中止できないため、要件を満たさなくなった Plugin は**警告**で知らせます。

```
WARNING プラグイン 'carmo-web' は devbase >=4.0.0 を要求していますが、現在の devbase は 3.0.0 です。
devbase 本体を更新してください (この警告を止める場合は DEVBASE_IGNORE_PLUGIN_REQUIRES=1)。
```

> `requires.devbase` を上げるのは、**Plugin が `project.yml` 形式へ移行したタイミング**です。
> 本体の版数と一緒に自動では上がりません。
Expand Down
20 changes: 18 additions & 2 deletions issues/PLAN32_multi-repo-project.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -360,7 +360,23 @@ plugin repo の PR は本体 release PR の merge 直後に merge する (flag d
- **フックへの値の受け渡し (#110)**: 計画に無かった。結合検証で、`GIT_REPO` / `WORK_DIR` を `source ./env` で読んでいた `pre-up` / `deploy` が壊れることが判明したため、devbase 側から `DEVBASE_PRIMARY_DIR` / `DEVBASE_PRIMARY_URL` / `DEVBASE_WORK_DIR` / `DEVBASE_REPO_DIRS` を渡す形にした。plugin リポジトリ側の該当フック 2 本も追随させた。
- **確定仕様の置き場所**: `docs/user/project-yml.md` を恒久リファレンスとした (plan-to-spec 相当)。

### リリース後に実施したこと (2026-08-23)

plan の範囲外で、結合検証と運用の中で出てきた対応。

| 内容 | 結果 |
|---|---|
| pilot の実機起動確認 | `uttaro-system` (3 repo) / `project-trygroup-prd` (2 repo・scale=2 の両インスタンス) で確認済み。AC9 充足 |
| 統合で消えたプロジェクトの後片付け | `projects/` の symlink・残留ディレクトリ・停止済みコンテナを削除済み |
| 全プロジェクトのイメージ更新 | 32 件を再ビルドし、稼働中コンテナを新 entrypoint で作り直した |
| v3.0.0 のタグとリリース | 作成済み (CHANGELOG のリンク切れも解消) |
| `requires.devbase` の検証 | 宣言だけで比較していなかったため、`plugin install` 時に検証する実装を追加 |
| 関連リポジトリのさらなる集約 | `carmo-ai` (4 repo) / `bi-tools` (3 repo) / `ai-plugins` (2 repo) へ統合し、重複していた `predict_contract` を削除。dev コンテナ 18 → 13 |
| `uttarov2` → `uttaro-system` へ改名 | 本体リポジトリの移行に合わせてプロジェクト名を実態へ |
| `carmo-screening` の `init.sh` | 7.5 か月壊れたまま誰も困っていなかったため削除 (volareinc/carmo-screening#314) |

### 残作業

- 統合した pilot (`uttarov2` / `project-trygroup-prd`) の実機起動確認。`devbase build --no-cache` → `devbase up` が必要で、稼働中コンテナの再起動を伴う。
- 統合で消えたプロジェクト (`uttarov2-doc` / `uttarov2migration` / `project-trygroup-prd-customer`) の後片付け。`projects/` のシンボリックリンクと、生成物だけが残ったディレクトリ、および停止済みコンテナが残っている。
- `devbase plugin sync` を実行すると、`devbase-samples` に定義を残した `tmllib` / `devbase` の
プロジェクト symlink が復活する。使わないプロジェクトが一覧に出るだけで実害は無い。
気になる場合は該当プラグインをローカルから uninstall する。
27 changes: 23 additions & 4 deletions lib/devbase/plugin/installer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,10 @@
RegistryInfo, RegistryEntry,
)
from .registry import PluginRegistry
from .requirements import (
check_devbase_requirement,
warn_unmet_devbase_requirement,
)
from .syncer import sync_projects, load_plugin_info

logger = get_logger("devbase.plugin.installer")
Expand DownExpand Up@@ -252,6 +256,12 @@ def _link_plugin(
plugins_dir: Path,
) -> None:
"""Create a symlink for a local plugin (--link install only)"""
# 互換性は既存インストールに触れる前に確かめる。後から落とすと、入れ替えの
# ために消した既存プラグインが戻らないまま失敗する。
info = load_plugin_info(plugin_path)
check_devbase_requirement(info)
version = info.version if info else '0.1.0'

dest = plugins_dir / name
if dest.exists() or dest.is_symlink():
logger.warning("Removing existing plugin '%s'", name)
Expand All@@ -262,9 +272,6 @@ def _link_plugin(

dest.symlink_to(plugin_path.resolve())

info = load_plugin_info(plugin_path)
version = info.version if info else '0.1.0'

registry.add(InstalledPlugin(
name=name,
version=version,
Expand DownExpand Up@@ -437,12 +444,24 @@ def _register_repo_plugin(
plugin_path: Path,
source_url: str,
repo_local_path: str,
enforce_requirements: bool = True,
) -> None:
"""Register a plugin from repos/ (no file copy, just metadata)."""
"""Register a plugin from repos/ (no file copy, just metadata).

``enforce_requirements=False`` にすると ``requires.devbase`` 違反を警告に
留める。update 由来の呼び出し (プラグイン分割の移行) は旧登録を削除した
あとに呼ばれるため、ここで例外にすると移行先が登録されないまま旧登録も
失われる。git pull は済んでおり止めても整合は取れないので、警告で知らせて
登録は進める。
"""
if not plugin_path.is_dir():
raise PluginError(f"Plugin directory not found: {plugin_path}")

info = load_plugin_info(plugin_path)
if enforce_requirements:
check_devbase_requirement(info)
else:
warn_unmet_devbase_requirement(info)
version = info.version if info else '0.1.0'

# Use the actual plugin_path relative to devbase_root so that
Expand Down
167 changes: 167 additions & 0 deletions lib/devbase/plugin/requirements.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
"""``plugin.yml`` の ``requires.devbase`` を devbase 本体の版と突き合わせる。

プラグインは devbase の機能に依存する。例えば PLAN32 (devbase 3.0.0) 以降の
``project.yml`` 形式のプロジェクト定義は 2.x では読めず、インストールできて
しまうと ``devbase up`` の段階で初めて失敗する。要求を宣言だけで終わらせず、
インストール時に確かめて先に止める。

対応する書式は PEP 440 の部分集合::

">=3.0.0" # 以上
">=3.0.0,<4.0.0" # 範囲 (カンマ区切りは AND)
"==3.0.0" / "3.0.0" # 一致 (演算子なしは == 扱い)
">" / "<" / "<=" / "!="

解釈できない書式や版番号は**エラーにしない**。独自記法を書いたプラグインを
インストール不能にするより、検証できなかったことを警告で知らせて先へ進める方が
実害が小さい (依存が本当に足りなければ後段で失敗する)。
"""

from __future__ import annotations

import os
import re
from typing import List, Optional, Tuple

from devbase.errors import PluginError
from devbase.log import get_logger
from devbase.plugin.models import PluginInfo

logger = get_logger(__name__)

#: 検証を丸ごと無効化する環境変数 (検証側の誤りで作業が詰まらないための逃げ道)
IGNORE_ENV = "DEVBASE_IGNORE_PLUGIN_REQUIRES"

_CLAUSE = re.compile(r'^\s*(>=|<=|==|!=|>|<)?\s*([0-9][0-9.]*)\s*$')

_OPERATORS = {
">=": lambda a, b: a >= b,
"<=": lambda a, b: a <= b,
">": lambda a, b: a > b,
"<": lambda a, b: a < b,
"==": lambda a, b: a == b,
"!=": lambda a, b: a != b,
}


def check_devbase_requirement(info: Optional[PluginInfo],
current_version: Optional[str] = None) -> None:
"""``requires.devbase`` を満たしていなければ :class:`PluginError` を送出する。

Args:
info: ``plugin.yml`` の内容 (``None`` や要求未指定なら何もしない)
current_version: 比較に使う devbase の版 (既定は動作中の devbase)
"""
unmet = _unmet_requirement(info, current_version)
if unmet is None:
return
spec, current_version = unmet
raise PluginError(
f"プラグイン '{info.name}' は devbase {spec} を要求していますが、"
f"現在の devbase は {current_version} です。"
"devbase を更新してから再度インストールしてください "
f"(検証を飛ばす場合は {IGNORE_ENV}=1)。"
)


def warn_unmet_devbase_requirement(info: Optional[PluginInfo],
current_version: Optional[str] = None) -> None:
"""要件違反を **警告だけ** で知らせる (中止できない場面向け)。

``devbase plugin update`` は git pull 済みの作業ツリーを追認するだけなので、
要求が上がっていても止められない。気づけないまま ``devbase up`` で失敗するより、
更新の場で本体の更新を促す方がよい。
"""
unmet = _unmet_requirement(info, current_version)
if unmet is None:
return
spec, current_version = unmet
logger.warning(
"プラグイン '%s' は devbase %s を要求していますが、現在の devbase は "
"%s です。devbase 本体を更新してください "
"(この警告を止める場合は %s=1)。",
info.name, spec, current_version, IGNORE_ENV)


def _unmet_requirement(info: Optional[PluginInfo],
current_version: Optional[str]) -> Optional[Tuple[str, str]]:
"""要件を満たさないとき ``(要求, 現在の版)`` を返す。満たす/検証不能なら ``None``。"""
if info is None or info.requires_devbase is None:
return None
# 文字列でない値は YAML が数値化した結果で元の表記を復元できない
# (`devbase: 3.10` → 3.1)。誤判定を避けるため検証しない。
# 通常は load_plugin_info が警告つきで落としているのでここには来ない。
if not isinstance(info.requires_devbase, str):
logger.warning(
"プラグイン '%s' の requires.devbase (%r) が文字列ではないため"
"検証せずに続行します", info.name, info.requires_devbase)
return None
spec = info.requires_devbase.strip()
if not spec:
return None
if os.environ.get(IGNORE_ENV, "").strip().lower() not in ("", "0", "false", "no"):
logger.warning(
"%s が設定されているため、プラグイン '%s' の requires.devbase (%s) を"
"検証しませんでした", IGNORE_ENV, info.name, spec)
return None

if current_version is None:
from devbase import __version__
current_version = __version__

clauses = _parse_spec(spec)
if clauses is None:
logger.warning(
"プラグイン '%s' の requires.devbase (%s) を解釈できないため、"
"互換性を検証せずに続行します", info.name, spec)
return None

current = _parse_version(current_version)
if current is None:
logger.warning(
"devbase の版 '%s' を解釈できないため、プラグイン '%s' の "
"requires.devbase (%s) を検証せずに続行します",
current_version, info.name, spec)
return None

for operator, required in clauses:
left, right = _align(current, required)
if not _OPERATORS[operator](left, right):
return spec, current_version
return None


def _parse_spec(spec: str) -> Optional[List[Tuple[str, tuple]]]:
"""``">=3.0.0,<4.0.0"`` を ``[(">=", (3,0,0)), ("<", (4,0,0))]`` にする。"""
clauses = []
for part in spec.split(","):
matched = _CLAUSE.match(part)
if not matched:
return None
version = _parse_version(matched.group(2))
if version is None:
return None
clauses.append((matched.group(1) or "==", version))
return clauses or None


def _parse_version(version: str) -> Optional[tuple]:
"""``"3.0.0.1"`` → ``(3, 0, 0, 1)``。数値以外が混ざっていたら ``None``。

要素は切り捨てない。``">=3.0.0.1"`` を ``3.0.0`` が満たすと誤判定しないよう、
桁合わせは比較時に :func:`_align` で行う。
"""
parts = str(version).strip().split(".")
if not all(part.isdigit() for part in parts):
return None
return tuple(int(part) for part in parts)


def _align(a: tuple, b: tuple) -> Tuple[tuple, tuple]:
"""短い方を 0 で埋めて桁数を揃える (``">=3.0"`` と ``"3.0.0"`` の比較用)。"""
width = max(len(a), len(b))
return a + (0,) * (width - len(a)), b + (0,) * (width - len(b))


__all__ = ["IGNORE_ENV", "check_devbase_requirement",
"warn_unmet_devbase_requirement"]
23 changes: 22 additions & 1 deletion lib/devbase/plugin/syncer.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -24,10 +24,31 @@ def load_plugin_info(plugin_dir: Path) -> Optional[PluginInfo]:
version=data.get('version', '0.1.0'),
description=data.get('description', ''),
priority=data.get('priority', 0),
requires_devbase=data.get('requires', {}).get('devbase') if isinstance(data.get('requires'), dict) else None,
requires_devbase=_requires_devbase(data),
)


def _requires_devbase(data: dict) -> Optional[str]:
"""requires.devbase を文字列で返す。文字列でなければ None (検証不能)。

`devbase: 3.10` とクォート無しで書かれると YAML が float の 3.1 にしてしまい、
元の版を復元できない (str へ寄せると "3.1" となり 3.10 とは別物になる)。
誤った版で比較するより、検証できないものとして扱い、クォートを促す。
"""
requires = data.get('requires')
if not isinstance(requires, dict) or requires.get('devbase') is None:
return None
value = requires['devbase']
if not isinstance(value, str):
logger.warning(
"plugin.yml の requires.devbase (%r) が文字列ではないため検証できません。"
"YAML が数値として解釈しており元の表記を復元できません "
'(例: 3.10 → 3.1)。クォートして書いてください: devbase: ">=3.10"',
value)
return None
return value


def discover_projects(plugin_dir: Path) -> list[str]:
"""Discover project directories within a plugin"""
projects_dir = plugin_dir / 'projects'
Expand Down
7 changes: 7 additions & 0 deletions lib/devbase/plugin/updater.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from .models import InstalledPlugin, RegistryInfo
from .registry import PluginRegistry
from .repo_manager import _git_pull
from .requirements import warn_unmet_devbase_requirement
from .syncer import sync_projects, discover_projects

logger = get_logger("devbase.plugin.updater")
Expand DownExpand Up@@ -93,9 +94,12 @@ def _migrate_removed_plugin(
if entry and repo_local_path:
from .installer import _register_repo_plugin
plugin_path = clone_dir / entry.path.rstrip('/')
# 旧登録は上で削除済み。ここで例外にすると移行先が登録されないまま
# 旧登録も失われるため、要件違反は警告に留める。
_register_repo_plugin(
registry, entry.name, plugin_path,
plugin.source, repo_local_path,
enforce_requirements=False,
)

return True
Expand DownExpand Up@@ -179,6 +183,9 @@ def _update_repo_plugins(
from .syncer import load_plugin_info
info = load_plugin_info(plugin_path)
version = info.version if info else '0.1.0'
# pull で requires.devbase が上がることがある。更新はもう済んでいて
# 止められないので、気づけるように警告だけ出す。
warn_unmet_devbase_requirement(info)

rel_path = str(plugin_path.relative_to(registry.devbase_root))
registry.add(InstalledPlugin(
Expand Down
Loading
Loading