From 1ccbb847b53e6eab8ec8c2e2d3a60b5948d0821f Mon Sep 17 00:00:00 2001 From: JustMe Date: Mon, 19 Oct 2026 13:58:21 +0800 Subject: [PATCH] ci: build the dev images in parallel with the checks and gate only the push [CI] --- .woodpecker/dev-image.yml | 6 ++-- ci/build-dev-image.sh | 4 +++ ci/release-linux.sh | 6 ++-- ci/stage-node-image.py | 17 +++++++++- ci/wait-for-checks.sh | 68 +++++++++++++++++++++++++++++++++++++++ ci/web-build.sh | 6 +++- 6 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 ci/wait-for-checks.sh diff --git a/.woodpecker/dev-image.yml b/.woodpecker/dev-image.yml index 7f01019..cebc429 100644 --- a/.woodpecker/dev-image.yml +++ b/.woodpecker/dev-image.yml @@ -15,10 +15,8 @@ when: branch: main evaluate: 'CI_COMMIT_MESSAGE contains "[CI]"' -# 先等后端测试和前端构建都通过再出镜像(提交信息写 [SKIP TESTS] 只跳过后端测试) -depends_on: - - verify - - web-build +# 镜像构建和 verify(后端快速测试)、web-build 并行跑,不再等它们:构建好之后、推送之前由 ci/wait-for-checks.sh 等这两个 workflow 都成功 +# (任何一个失败就不推;提交信息写 [SKIP TESTS] 只跳过后端测试,verify 照样是成功)。机器有 6 个 workflow 槽(WOODPECKER_MAX_WORKFLOWS),三个同时跑放得下。 concurrency: 1 diff --git a/ci/build-dev-image.sh b/ci/build-dev-image.sh index 8b8b05d..233a4f2 100644 --- a/ci/build-dev-image.sh +++ b/ci/build-dev-image.sh @@ -96,6 +96,7 @@ if [[ "$component" == backend ]]; then ZOT_IMAGE_REPOSITORY="$repository" \ IMAGE_TAGS="latest,$image_version" \ IMAGE_PUBLISH=true \ + MOSHIRO_PUSH_GATE="$root/ci/wait-for-checks.sh" \ run_bounded 30m bash "$root/ci/release-linux.sh" "$arch" backend exit 0 fi @@ -150,6 +151,9 @@ run_bounded 30m buildah bud --layers --network host --tls-verify=true --ulimit n -f "$context/Containerfile" \ -t "$local_image" "$context" +# 构建和测试是并行的:镜像已经好了,推送之前等 verify 和 web-build 都过(任何一个失败就不推) +bash "$root/ci/wait-for-checks.sh" + logged_in=false for attempt in 1 2 3 4; do if run_bounded 2m buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then diff --git a/ci/release-linux.sh b/ci/release-linux.sh index 481ed72..81d7f39 100644 --- a/ci/release-linux.sh +++ b/ci/release-linux.sh @@ -45,8 +45,8 @@ export MOSHIRO_BUILT_AT="${MOSHIRO_BUILT_AT:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" # 前端先构建:Backend.Api 在 web/*/dist 存在时把它们内嵌进程序(展示端 /,管理端 /admin/),这样 passkey 能绑定在面板自己的域名上 # 发版流水线里有单独的 web-build 步骤先构建好了(并行的两个架构不能同时 npm ci);main 的 [CI] 构建只有一个 Backend 步骤,这里自己构建 if [[ ! -f "$root/web/display/dist/index.html" || ! -f "$root/web/admin/dist/index.html" ]]; then - if [[ -z "${NPM_CONFIG_CACHE:-}" && -d /home/justme/.npm ]]; then export NPM_CONFIG_CACHE=/home/justme/.npm; fi - (cd "$root" && npm ci --prefix web --prefer-offline && npm run build --prefix web) + # 这里的前端带着这次构建的版本号(apply_version 已经改了 package.json),不能用 web-build workflow 的产物;但依赖共用它的 node_modules 缓存(flock 防止同时读写),不再 npm ci + WEB_SKIP_LINT=1 bash "$root/ci/web-build.sh" fi [[ -f "$root/web/display/dist/index.html" && -f "$root/web/admin/dist/index.html" ]] || { echo "the web build did not produce both apps" >&2; exit 1; } @@ -110,6 +110,8 @@ if [[ "$image_publish" == false ]]; then fi export REGISTRY_AUTH_FILE="$root/.release/auth/zot-linux-$arch.json" mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")" +# 开发镜像的构建和测试是并行的:镜像已经好了,推送之前等 verify 和 web-build 都过(见 ci/wait-for-checks.sh;发版流水线不设这个) +[[ -z "${MOSHIRO_PUSH_GATE:-}" ]] || bash "$MOSHIRO_PUSH_GATE" logged_in=false for attempt in 1 2 3 4; do if buildah login --tls-verify=true --username "$ZOT_USERNAME" --password-stdin "$registry" <<< "$ZOT_SECRET" >/dev/null; then diff --git a/ci/stage-node-image.py b/ci/stage-node-image.py index d5b3b94..b5b1b64 100644 --- a/ci/stage-node-image.py +++ b/ci/stage-node-image.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import shutil +import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -28,6 +29,20 @@ CONTAINERFILES = { } +def copy_for_restore(path: Path, relative: Path, target: Path) -> None: + """The restore layer must not change when only the build version does: CI rewrites in the workspace + Directory.Build.props (ci/version.sh), which used to invalidate the cached dependency restore on every commit. + Restore only needs the committed props (package versions); the source copy still carries the real version.""" + if path.name == "Directory.Build.props": + committed = subprocess.run( + ["git", "show", f"HEAD:backend/{relative.as_posix()}"], cwd=ROOT, capture_output=True, check=False + ) + if committed.returncode == 0: + target.write_bytes(committed.stdout) + return + shutil.copy2(path, target) + + def stage(component: str, runtime_identifier: str, destination: Path) -> None: if destination.exists(): shutil.rmtree(destination) @@ -49,7 +64,7 @@ def stage(component: str, runtime_identifier: str, destination: Path) -> None: ): target = restore / relative target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(path, target) + copy_for_restore(path, relative, target) target = source / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, target) diff --git a/ci/wait-for-checks.sh b/ci/wait-for-checks.sh new file mode 100644 index 0000000..336f265 --- /dev/null +++ b/ci/wait-for-checks.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# 推镜像之前的闸门:等这个提交的 verify(后端快速测试)和 web-build 两个 workflow 都成功。 +# 镜像构建不再等它们(开发镜像的构建和测试并行跑,关键路径少了测试那一段);构建好之后、推送之前才来这里等结果, +# 任何一个失败就不推(和以前「测试没过不出镜像」一样)。 +# 用法:wait-for-checks.sh (环境:CI_COMMIT_SHA、CI_REPO、CI_FORGE_URL,都是 Woodpecker 提供的) +set -euo pipefail + +sha="${CI_COMMIT_SHA:?CI_COMMIT_SHA is required}" +repo="${CI_REPO:?CI_REPO is required}" +forge="${CI_FORGE_URL:-https://git.oeo.one}" +required=("ci/woodpecker/push/verify" "ci/woodpecker/push/web-build") +# 总共最多等这么久(秒);一个 workflow 在这么久之后还没出现,说明它没有被触发,当作失败,免得一直挂着 +timeout_s="${MOSHIRO_CHECKS_TIMEOUT:-1200}" +interval_s="${MOSHIRO_CHECKS_INTERVAL:-5}" +auth=() +[[ -n "${GITEA_TOKEN:-}" ]] && auth=(-H "Authorization: token $GITEA_TOKEN") + +status_of() { + # 每个 context 的最新状态;没有这个 context 输出 absent + local body="$1" context="$2" + python3 - "$context" "$body" <<'PY' +import json, sys +context, body = sys.argv[1], sys.argv[2] +try: + statuses = json.loads(body).get("statuses") or [] +except ValueError: + print("unknown"); sys.exit(0) +matches = [s for s in statuses if s.get("context") == context] +print(matches[0].get("status", "unknown") if matches else "absent") +PY +} + +deadline=$((SECONDS + timeout_s)) +last="" +unreachable=0 +while :; do + # 先直连(git.oeo.one 就在这台机器上,走代理回环反而可能不通),不行再用环境里的代理 + url="$forge/api/v1/repos/$repo/commits/$sha/status" + body="$(curl -fsS --noproxy "*" --max-time 20 "${auth[@]}" "$url" 2>/dev/null || curl -fsS --max-time 20 "${auth[@]}" "$url" 2>/dev/null || true)" + if [[ -z "$body" ]]; then + unreachable=$((unreachable + 1)) + if ((unreachable >= 12)); then echo "could not read the commit status from $forge; not pushing the images" >&2; exit 1; fi + sleep "$interval_s" + continue + fi + unreachable=0 + summary="" + pending=0 + for context in "${required[@]}"; do + state="$(status_of "$body" "$context")" + summary+="${context##*/}=$state " + case "$state" in + success) ;; + failure|error|killed|declined) + echo "check $context did not pass ($state); not pushing the images" >&2 + exit 1 + ;; + *) pending=1 ;; + esac + done + [[ "$summary" == "$last" ]] || { echo "checks: $summary"; last="$summary"; } + [[ "$pending" == 0 ]] && exit 0 + if ((SECONDS >= deadline)); then + echo "checks did not finish within ${timeout_s}s ($summary); not pushing the images" >&2 + exit 1 + fi + sleep "$interval_s" +done diff --git a/ci/web-build.sh b/ci/web-build.sh index c0cfbb3..d37f166 100644 --- a/ci/web-build.sh +++ b/ci/web-build.sh @@ -9,6 +9,7 @@ export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-/home/justme/.npm}" web="$root/web" dirs=(node_modules display/node_modules admin/node_modules) mkdir -p "$cache" +exec 9> "$cache/.lock" key="$(cat "$web/package-lock.json" "$web/package.json" "$web/display/package.json" "$web/admin/package.json" | sha256sum | cut -c1-16)" @@ -36,6 +37,8 @@ save() { (cd "$cache" && ls -1t | grep -v '\.tmp\.' | tail -n +4 | xargs -r rm -rf) } +# 并行的 workflow(web-build 和开发镜像里的前端构建)会同时读写这份缓存:恢复和保存期间持锁 +flock -w 600 9 || { echo "could not get the node_modules cache lock" >&2; exit 1; } if restore; then echo "restored node_modules from the cache ($key)" else @@ -44,6 +47,7 @@ else npm ci --prefix "$web" --prefer-offline save || echo "could not save the node_modules cache" >&2 fi +flock -u 9 # 展示端和管理端并行构建,再检查 CSS pids=() @@ -52,4 +56,4 @@ pids=() failed=0 for pid in "${pids[@]}"; do wait "$pid" || failed=1; done [[ "$failed" == 0 ]] || { echo "web build failed" >&2; exit 1; } -npm run lint:css --prefix "$web" +[[ -n "${WEB_SKIP_LINT:-}" ]] || npm run lint:css --prefix "$web"