diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index ca396dbd..480ddf86 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -5,18 +5,21 @@ on: push: branches: - "master" + tags: + - "[0-9]+.[0-9]+.[0-9]+" paths: - ".github/workflows/image.yml" - "weather_briefing/**" - "Dockerfile" - "pyproject.toml" - "uv.lock" - workflow_dispatch: - merge_group: + +concurrency: + group: weather-briefing-image-${{ github.ref_type == 'branch' && 'edge' || format('release-{0}', github.ref_name) }} + cancel-in-progress: true env: WEATHER_BRIEFING_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/weather-briefing - WEATHER_BRIEFING_VERSION: "1.0.0" jobs: weather-briefing: @@ -95,20 +98,41 @@ jobs: - name: Create manifest list and push working-directory: /tmp/weather-briefing-digests/ env: + COMMIT_TAG: sha-${{ github.sha }} REGISTRY_IMAGE: ${{ env.WEATHER_BRIEFING_IMAGE }} - WEATHER_BRIEFING_VERSION: ${{ env.WEATHER_BRIEFING_VERSION }} + RELEASE_VERSION: ${{ github.ref_type == 'tag' && github.ref_name || '' }} run: | - # shellcheck disable=SC2046 - docker buildx imagetools create --tag "${REGISTRY_IMAGE}:latest" --tag "${REGISTRY_IMAGE}:${WEATHER_BRIEFING_VERSION}" \ - $(printf "${REGISTRY_IMAGE}@sha256:%s " *) + set -euo pipefail + shopt -s nullglob + digest_files=(*) + if (( ${#digest_files[@]} == 0 )); then + echo "No platform digest artifacts were downloaded" + exit 1 + fi + sources=() + for digest_file in "${digest_files[@]}"; do + if ! [[ "${digest_file}" =~ ^[a-f0-9]{64}$ ]]; then + echo "Unexpected digest artifact filename: ${digest_file}" + exit 1 + fi + sources+=("${REGISTRY_IMAGE}@sha256:${digest_file}") + done + + tags=(--tag "${REGISTRY_IMAGE}:${COMMIT_TAG}") + if [[ -n "${RELEASE_VERSION}" ]]; then + tags+=(--tag "${REGISTRY_IMAGE}:${RELEASE_VERSION}" --tag "${REGISTRY_IMAGE}:latest") + else + tags+=(--tag "${REGISTRY_IMAGE}:edge") + fi + docker buildx imagetools create "${tags[@]}" "${sources[@]}" - name: Inspect image env: - REGISTRY_IMAGE: ${{ env.WEATHER_BRIEFING_IMAGE }}:latest + REGISTRY_IMAGE: ${{ env.WEATHER_BRIEFING_IMAGE }}:sha-${{ github.sha }} run: | docker buildx imagetools inspect "${REGISTRY_IMAGE}" \ | grep -Po '[^ \r\n\t\v]+@sha256:[a-z0-9]+' | xargs -r -t -n 1 docker buildx imagetools inspect --raw - name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: "${{ env.WEATHER_BRIEFING_IMAGE }}:latest" + image-ref: "${{ env.WEATHER_BRIEFING_IMAGE }}:sha-${{ github.sha }}" format: "table" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83f7f81e..ebed576a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,7 @@ on: jobs: release: + if: github.ref == 'refs/heads/master' runs-on: ubuntu-24.04 permissions: contents: write @@ -51,9 +52,6 @@ jobs: init_path = pathlib.Path('weather_briefing/__init__.py') init_path.write_text(init_path.read_text().replace(f'__version__ = \"{prev}\"', f'__version__ = \"{version}\"')) - image_path = pathlib.Path('.github/workflows/image.yml') - image_path.write_text(image_path.read_text().replace(f'WEATHER_BRIEFING_VERSION: \"{prev}\"', f'WEATHER_BRIEFING_VERSION: \"{version}\"')) - readme_path = pathlib.Path('README.md') readme_version = f'WEATHER_BRIEFING_VERSION=\"{prev}\"' readme_content = readme_path.read_text() @@ -70,7 +68,7 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git remote set-url origin "https://x-access-token:${RELEASE_GH_PAT}@github.com/${GITHUB_REPOSITORY}.git" - git add pyproject.toml weather_briefing/__init__.py .github/workflows/image.yml README.md uv.lock + git add pyproject.toml weather_briefing/__init__.py README.md uv.lock if git diff --cached --quiet; then echo "No changes to commit" else @@ -78,10 +76,9 @@ jobs: fi if git rev-parse "refs/tags/${WEATHER_BRIEFING_VERSION}" >/dev/null 2>&1; then - echo "Tag ${WEATHER_BRIEFING_VERSION} already exists, skipping" - else - git tag -a "${WEATHER_BRIEFING_VERSION}" -m "Release ${WEATHER_BRIEFING_VERSION}" - git push origin "${WEATHER_BRIEFING_VERSION}" + echo "Refusing to reuse existing release tag ${WEATHER_BRIEFING_VERSION}" + exit 1 fi - git push origin HEAD:master + git tag -a "${WEATHER_BRIEFING_VERSION}" -m "Release ${WEATHER_BRIEFING_VERSION}" + git push --atomic origin HEAD:master "refs/tags/${WEATHER_BRIEFING_VERSION}" diff --git a/docs/design.md b/docs/design.md index da6fab4b..a23fb9eb 100644 --- a/docs/design.md +++ b/docs/design.md @@ -149,3 +149,5 @@ Docker `run --env-file` 接受的是 `KEY=value` 列表,不按 shell 语义解 ## 镜像与构建上下文 项目使用 uv 原生 `uv_build` 构建后端。Dockerfile 使用多阶段构建:先从官方 Distroless uv 镜像取得 uv/uvx,再复制到 Debian 13 Distroless Python nonroot 镜像,并直接以该镜像按 `uv.lock` 创建生产虚拟环境;最终阶段基于 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 创建命令同时更新当前事件对应的全部标签。 diff --git a/docs/notes.md b/docs/notes.md index a876fef7..1a3df8f1 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -50,6 +50,16 @@ 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 写入之前,或合并构建与发布阶段。 + ## 异步编排中的同步本地持久化 ### 当前选择 diff --git a/docs/requirements.md b/docs/requirements.md index 1887ebf2..722a0d27 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -59,4 +59,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。 +目标为自有服务器。应用支持 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` 或版本标签。