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
113 changes: 87 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ permissions: {}
on:
workflow_dispatch:
inputs:
version:
description: "Version number (X.Y.Z)"
bump:
description: "Version component to release"
required: true
type: string
default: patch
type: choice
options:
- major
- minor
- patch

jobs:
release:
Expand All @@ -24,41 +29,71 @@ jobs:
with:
version: "0.11.28"
enable-cache: true
- name: Validate version
- name: Create release and next development commits
env:
WEATHER_BRIEFING_VERSION: ${{ github.event.inputs.version }}
RELEASE_BUMP: ${{ inputs.bump }}
RELEASE_GH_PAT: ${{ secrets.RELEASE_GH_PAT }}
run: |
if ! echo "${WEATHER_BRIEFING_VERSION}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: version must be in X.Y.Z format"
set -euo pipefail

VERSIONS=$(python3 -c "
import os, pathlib, tomllib

bump = os.environ['RELEASE_BUMP']
current = tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version']
base = current.removesuffix('-dev')
is_development = base != current
major, minor, patch = map(int, base.split('.'))

if bump == 'major':
release = (major + 1, 0, 0)
elif bump == 'minor':
release = (major, minor + 1, 0)
elif bump == 'patch':
release = (major, minor, patch if is_development else patch + 1)
else:
raise ValueError(f'unsupported version component: {bump}')

release_version = '.'.join(map(str, release))
next_development_version = f'{release[0]}.{release[1]}.{release[2] + 1}-dev'
print(release_version, next_development_version)
")
read -r WEATHER_BRIEFING_VERSION NEXT_DEVELOPMENT_VERSION <<< "${VERSIONS}"

if git rev-parse "refs/tags/${WEATHER_BRIEFING_VERSION}" >/dev/null 2>&1; then
echo "Refusing to reuse existing release tag ${WEATHER_BRIEFING_VERSION}"
exit 1
fi
- name: Update files
env:
WEATHER_BRIEFING_VERSION: ${{ github.event.inputs.version }}
RELEASE_GH_PAT: ${{ secrets.RELEASE_GH_PAT }}
run: |

python3 -c "
import tomllib, pathlib
import pathlib, re, tomllib

version = '${WEATHER_BRIEFING_VERSION}'

toml_path = pathlib.Path('pyproject.toml')
with open(toml_path, 'rb') as f:
data = tomllib.load(f)
prev = data['project']['version']
content = toml_path.read_text().replace(f'version = \"{prev}\"', f'version = \"{version}\"')
toml_path.write_text(content)
version_entry = f'version = \"{prev}\"'
content = toml_path.read_text()
if content.count(version_entry) != 1:
raise RuntimeError(f'pyproject.toml must contain exactly one {version_entry}')
toml_path.write_text(content.replace(version_entry, f'version = \"{version}\"', 1))

init_path = pathlib.Path('weather_briefing/__init__.py')
init_path.write_text(init_path.read_text().replace(f'__version__ = \"{prev}\"', f'__version__ = \"{version}\"'))
version_assignment = f'__version__ = \"{prev}\"'
init_content = init_path.read_text()
if init_content.count(version_assignment) != 1:
raise RuntimeError(f'weather_briefing/__init__.py must contain exactly one {version_assignment}')
init_path.write_text(init_content.replace(version_assignment, f'__version__ = \"{version}\"', 1))

readme_path = pathlib.Path('README.md')
readme_version = f'WEATHER_BRIEFING_VERSION=\"{prev}\"'
readme_content = readme_path.read_text()
if readme_version not in readme_content:
raise RuntimeError(f'README.md does not contain {readme_version}')
readme_version = re.compile(r'WEATHER_BRIEFING_VERSION=\"[0-9]+\.[0-9]+\.[0-9]+\"')
if len(readme_version.findall(readme_content)) != 1:
raise RuntimeError('README.md must contain exactly one stable deployment version')
readme_path.write_text(
readme_content.replace(readme_version, f'WEATHER_BRIEFING_VERSION=\"{version}\"', 1)
readme_version.sub(f'WEATHER_BRIEFING_VERSION=\"{version}\"', readme_content, count=1)
)
"

Expand All @@ -70,15 +105,41 @@ jobs:

git add pyproject.toml weather_briefing/__init__.py README.md uv.lock
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "chore: bump version to ${WEATHER_BRIEFING_VERSION}"
echo "Release version update produced no changes"
exit 1
fi
git commit -m "chore: release ${WEATHER_BRIEFING_VERSION}"
git tag -a "${WEATHER_BRIEFING_VERSION}" -m "Release ${WEATHER_BRIEFING_VERSION}"

if git rev-parse "refs/tags/${WEATHER_BRIEFING_VERSION}" >/dev/null 2>&1; then
echo "Refusing to reuse existing release tag ${WEATHER_BRIEFING_VERSION}"
python3 -c "
import pathlib

release_version = '${WEATHER_BRIEFING_VERSION}'
development_version = '${NEXT_DEVELOPMENT_VERSION}'

toml_path = pathlib.Path('pyproject.toml')
version_entry = f'version = \"{release_version}\"'
content = toml_path.read_text()
if content.count(version_entry) != 1:
raise RuntimeError(f'pyproject.toml must contain exactly one {version_entry}')
toml_path.write_text(content.replace(version_entry, f'version = \"{development_version}\"', 1))

init_path = pathlib.Path('weather_briefing/__init__.py')
version_assignment = f'__version__ = \"{release_version}\"'
init_content = init_path.read_text()
if init_content.count(version_assignment) != 1:
raise RuntimeError(f'weather_briefing/__init__.py must contain exactly one {version_assignment}')
init_path.write_text(
init_content.replace(version_assignment, f'__version__ = \"{development_version}\"', 1)
)
"

uv lock
git add pyproject.toml weather_briefing/__init__.py uv.lock
if git diff --cached --quiet; then
echo "Development version update produced no changes"
exit 1
fi
git commit -m "chore: start ${NEXT_DEVELOPMENT_VERSION} development"
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

git tag -a "${WEATHER_BRIEFING_VERSION}" -m "Release ${WEATHER_BRIEFING_VERSION}"
git push --atomic origin HEAD:master "refs/tags/${WEATHER_BRIEFING_VERSION}"
8 changes: 8 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,11 @@ Docker `run --env-file` 接受的是 `KEY=value` 列表,不按 shell 语义解
项目使用 uv 原生 `uv_build` 构建后端。Dockerfile 使用多阶段构建:先从官方 Distroless uv 镜像取得 uv/uvx,再复制到 Debian 13 Distroless Python nonroot 镜像,并直接以该镜像按 `uv.lock` 创建生产虚拟环境。锁文件中的 `docker` dependency group 只为官方镜像预装 any-llm 的 `deepseek`、`openai` 和 `openrouter` extras,不扩大 Python 包本身的基础运行依赖。最终阶段基于 digest 固定的 `gcr.io/distroless/python3-debian13`,从独立 assets 镜像加入 Bash 与 Toybox,并只复制运行环境和应用。builder 与 runtime 使用相同 Debian 版本及系统 Python,并通过镜像探针验证;最终进程以无特权用户运行。`.dockerignore` 不继承 `.gitignore`,因此使用独立白名单,只让 Dockerfile 实际需要的项目元数据、锁文件和包源码进入 BuildKit 上下文;`.env`、Git 历史、测试和文档不会发送给 builder。

镜像工作流实现 [requirements.md](requirements.md#运行环境) 定义的标签通道,并用一次 manifest 创建命令同时更新当前事件对应的全部标签。

## 版本与发布

release workflow 的手动输入是 GitHub Actions `choice`,只允许 major、minor 或 patch 三选一。工作流从 `pyproject.toml` 读取当前版本,在正式版上递增选中的组件;当前版本为 `-dev` 且选择 patch 时,去掉后缀后直接发布已声明的目标版本。major 始终把 minor、patch 归零,minor 始终把 patch 归零。

工作流先同步 `pyproject.toml`、包内版本、`uv.lock` 和 README 的稳定部署版本,生成正式版 commit 并让同名 tag 指向该 commit;再只把代码与锁文件推进到下一 patch 的 `-dev` commit。最后 atomic push `master` 与 tag,使远端不会只接收其中一部分。

开发版 `--version` 以包源码位置的父目录作为预期仓库根,并要求 Git `--show-toplevel` 返回同一目录后才附加 commit SHA 和 dirty 状态。因此从其他 Git 仓库启动 CLI,或把普通安装放在其他仓库的虚拟环境中,都不会误报外部仓库信息。
10 changes: 0 additions & 10 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,6 @@ Nominatim 限速使用 `await asyncio.sleep()`,它只挂起当前协程并把

最新值和窗口基线是累计变化判断的最低证据,因此二者在摘要后仍无法容纳时不能静默处理;应用继续生成简报,同时发送按 source 与内容指纹去重的运维告警。这样接受一次简报可能缺少部分历史比较的降级,但不因可选历史输入阻断当前天气与预警投递。最近变化节点的丢弃不告警,避免正常预算裁剪造成噪声。若告警长期出现,应先让对应 adapter 提供更紧凑的领域摘要;只有确定性摘要无法满足实际质量时,才评估缓存式、可观测且与主调用隔离的 LLM 压缩。

## OCI 标签作为发布通道

镜像标签通道的规范以[运行环境要求](requirements.md#运行环境)为准。`master` 事件更新 `edge` 与 commit SHA 标签,版本 tag 事件更新同名版本、`latest` 与 commit SHA 标签;manifest 步骤不预查 registry,也不主动阻止标签被覆盖。

release 工作流的单一 job 只在手动选择 `master` 时运行,并把生成的版本提交与同名 Git tag atomic push 到 `master`。镜像工作流不提供手动发布或 merge queue 入口;Renovate 更新依赖 branch automerge 后直接触发 `master` 镜像构建,未合并的 Renovate PR 不构建镜像。

连续的 `master` 事件共用可取消的构建组,使旧请求尽早停止,只有最新的 master 请求继续更新 `edge`。每个版本 tag 使用独立的可取消构建组,避免后续 master 请求或其他版本取消正式发布;同一版本的重复事件只保留最新一次。不能让所有发布共用一个 concurrency group,因为 GitHub 只保留一个 pending run,短时间多个版本可能使中间版本未经构建即被替换。

master run 可能在平台 digest 已推送、manifest 尚未创建时被取消,从而暂时留下无标签 digest。当前接受由 registry 清理这类中间产物,以换取快速淘汰过时构建;若 registry 存储持续增长、清理策略不足或产生额外费用,应把取消边界移到 registry 写入之前,或合并构建与发布阶段。

## 异步编排中的同步本地持久化

### 当前选择
Expand Down
2 changes: 1 addition & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@

## 运行环境

目标为自有服务器。应用支持 Python 3.11–3.14,以当前最新稳定 Python 作为首选开发和测试版本,并在 CI 中覆盖全部受支持版本;项目元数据不设置未经验证的未来 Python 版本上限。项目使用 uv 原生 `uv_build` 构建后端并提交 `uv.lock`,不引入 PDM 工具链。Distroless Debian 13 镜像使用其系统 Python。应用以内置调度器常驻运行,SQLite 位于外部持久目录或卷中。项目维护单一、非 root、由 `uv.lock` 锁定依赖的 OCI 镜像,不使用 Docker Compose;GitHub Actions 仅用于 CI 和独立镜像发布,不预设生产运行 secrets。每次 `X.Y.Z` Git tag 构建更新同名 OCI 标签、`latest` 和 commit SHA 标签;`master` 构建更新 `edge` 和 commit SHA 标签,不修改 `latest` 或版本标签。
目标为自有服务器。应用支持 Python 3.11–3.14,以当前最新稳定 Python 作为首选开发和测试版本,并在 CI 中覆盖全部受支持版本;项目元数据不设置未经验证的未来 Python 版本上限。项目使用 uv 原生 `uv_build` 构建后端并提交 `uv.lock`,不引入 PDM 工具链。Distroless Debian 13 镜像使用其系统 Python。应用以内置调度器常驻运行,SQLite 位于外部持久目录或卷中。项目维护单一、非 root、由 `uv.lock` 锁定依赖的 OCI 镜像,不使用 Docker Compose;GitHub Actions 仅用于 CI 和独立镜像发布,不预设生产运行 secrets。每次 `X.Y.Z` Git tag 构建更新同名 OCI 标签、`latest` 和 commit SHA 标签;`master` 构建更新 `edge` 和 commit SHA 标签,不修改 `latest` 或版本标签。发布工作流不接收自由格式版本号,由用户单选 major、minor 或 patch:major 递增并把 minor、patch 归零,minor 递增并把 patch 归零,patch 在正式版上递增、在 `-dev` 版上发布其已声明的目标版本。工作流先创建正式版提交和同名 tag,再创建下一个 patch 的 `-dev` 提交,并将 master 与 tag 原子推送;README 部署示例保留最新正式版。开发版本只在包代码位于 weather-briefing 自身 Git worktree 时通过 `--version` 附加短 commit SHA,有未提交或未跟踪改动时同时标记 dirty;从其他 Git 仓库运行、正式版或无法读取自身 Git 状态时只显示内置版本。
93 changes: 92 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@
import io
import logging
import sqlite3
import subprocess
from collections.abc import AsyncIterator
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, call

import httpx
import pendulum
import pytest

import weather_briefing.cli as cli_module
from weather_briefing.cli import (
_LOGGER,
_SENSITIVE_SDK_LOGGERS,
Expand Down Expand Up @@ -268,11 +271,99 @@ def test_briefing_matches_cron_range(self, monkeypatch) -> None:
assert not _in_schedule("briefing", now_out, settings)


def test_version_flag() -> None:
def test_version_flag_uses_embedded_release_version(monkeypatch, capsys) -> None:
monkeypatch.setattr(cli_module, "__version__", "1.1.0")
parser = build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["--version"])

assert capsys.readouterr().out == "pytest 1.1.0\n"


def test_development_version_does_not_probe_git_for_other_commands(monkeypatch) -> None:
monkeypatch.setattr(cli_module, "__version__", "1.1.1-dev")
monkeypatch.setattr(
cli_module.subprocess,
"run",
Mock(side_effect=AssertionError("Git must only be inspected for --version")),
)

args = build_parser().parse_args(["run", "forecast"])

assert args.command == "run"


@pytest.mark.parametrize(("status", "expected"), (("", "1.1.1-g1234567"), (" M file.py\n", "1.1.1-dirty-g1234567")))
def test_development_version_includes_git_revision(monkeypatch, capsys, status: str, expected: str) -> None:
repository_root = Path(cli_module.__file__).resolve().parents[1]
results = iter(
(
subprocess.CompletedProcess((), 0, f"{repository_root}\n1234567\n", ""),
subprocess.CompletedProcess((), 0, status, ""),
)
)
monkeypatch.setattr(cli_module, "__version__", "1.1.1-dev")
git = Mock(side_effect=lambda *args, **kwargs: next(results))
monkeypatch.setattr(cli_module.subprocess, "run", git)

with pytest.raises(SystemExit):
build_parser().parse_args(["--version"])

assert capsys.readouterr().out == f"pytest {expected}\n"
assert git.call_args_list == [
call(
(
"git",
"-C",
str(repository_root),
"rev-parse",
"--show-toplevel",
"--short=7",
"HEAD",
),
check=True,
capture_output=True,
text=True,
),
call(
("git", "-C", str(repository_root), "status", "--porcelain"),
check=True,
capture_output=True,
text=True,
),
]


@pytest.mark.parametrize("metadata", ("/unrelated/repository\n1234567\n", "unexpected\n"))
def test_development_version_rejects_unrelated_git_metadata(monkeypatch, capsys, metadata: str) -> None:
monkeypatch.setattr(cli_module, "__version__", "1.1.1-dev")
git = Mock(return_value=subprocess.CompletedProcess((), 0, metadata, ""))
monkeypatch.setattr(cli_module.subprocess, "run", git)

with pytest.raises(SystemExit):
build_parser().parse_args(["--version"])

assert capsys.readouterr().out == "pytest 1.1.1-dev\n"
git.assert_called_once()


@pytest.mark.parametrize(
"error",
(FileNotFoundError(), subprocess.CalledProcessError(128, ("git", "rev-parse"))),
)
def test_development_version_falls_back_outside_git(monkeypatch, capsys, error: Exception) -> None:
monkeypatch.setattr(cli_module, "__version__", "1.1.1-dev")

def fail(*args, **kwargs):
raise error

monkeypatch.setattr(cli_module.subprocess, "run", fail)

with pytest.raises(SystemExit):
build_parser().parse_args(["--version"])

assert capsys.readouterr().out == "pytest 1.1.1-dev\n"


def test_rendered_text_diagnostics_parser_accepts_bounded_duration() -> None:
args = build_parser().parse_args(["diagnostics", "rendered-text", "enable", "--for", "15m"])
Expand Down
Loading