diff --git a/.woodpecker/web-build.yml b/.woodpecker/web-build.yml index 0e629d0..ce1e8f3 100644 --- a/.woodpecker/web-build.yml +++ b/.woodpecker/web-build.yml @@ -7,6 +7,8 @@ clone: image: plugin-git settings: lfs: false + # [CI] 构建要算开发版本号(ci/version.sh:自最近 tag 的提交数),要拿到 tag + tags: true when: - event: pull_request @@ -21,4 +23,10 @@ steps: NPM_CONFIG_CACHE: /home/justme/.npm commands: # node_modules 按锁文件哈希缓存,展示端和管理端并行构建,再检查 CSS,见 ci/web-build.sh - - python3 ci/guard-step.py web-build 900 bash ci/web-build.sh + # main 上带 [CI] 的提交:先写入开发版本号,构建完把产物交给同一条流水线的开发镜像(ci/shared-web.sh),前端只构建一次 + - | + if [ "$CI_PIPELINE_EVENT" = push ] && printf '%s' "$CI_COMMIT_MESSAGE" | grep -qF '[CI]'; then + export WEB_PUBLISH_SHARED=1 + bash ci/version.sh apply + fi + python3 ci/guard-step.py web-build 900 bash ci/web-build.sh diff --git a/ci/release-linux.sh b/ci/release-linux.sh index 68f38b3..85392b5 100644 --- a/ci/release-linux.sh +++ b/ci/release-linux.sh @@ -56,11 +56,11 @@ trap cleanup EXIT export MOSHIRO_COMMIT="${MOSHIRO_COMMIT:-${CI_COMMIT_SHA:0:10}}" 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 步骤,这里自己构建 +# 前端先就位:Backend.Api 在 web/*/dist 存在时把它们内嵌进程序(展示端 /,管理端 /admin/),这样 passkey 能绑定在面板自己的域名上 +# 发版流水线里有单独的 web-build 步骤先构建好了;main 的 [CI] 构建用同一条流水线里 web-build workflow 的产物(带这次的版本号), +# 等不到(它没跑或失败了)才自己构建兜底,依赖共用 node_modules 缓存 if [[ ! -f "$root/web/display/dist/index.html" || ! -f "$root/web/admin/dist/index.html" ]]; then - # 这里的前端带着这次构建的版本号(apply_version 已经改了 package.json),不能用 web-build workflow 的产物;但依赖共用它的 node_modules 缓存(flock 防止同时读写),不再 npm ci - WEB_SKIP_LINT=1 bash "$root/ci/web-build.sh" + bash "$root/ci/shared-web.sh" fetch || 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; } diff --git a/ci/shared-web.sh b/ci/shared-web.sh new file mode 100755 index 0000000..e19ef16 --- /dev/null +++ b/ci/shared-web.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# 同一条流水线里,前端只构建一次:web-build workflow 构建完把 dist 放到流水线的共享目录,开发镜像的 Backend 步骤直接拿来用。 +# 用法: +# shared-web.sh publish 把 web/display/dist 和 web/admin/dist 放进共享目录(先写临时目录,最后用一次 rename 加上 ready 标记) +# shared-web.sh fetch 等 ready 标记出现(最多 MOSHIRO_SHARED_WEB_WAIT 秒,默认 600),再复制回工作区; +# 退出码 0 = 已复制,1 = 没等到或流水线已经失败(调用方自己构建兜底) +# 共享目录和 ci/guard-step.py 用的是同一个:${MOSHIRO_CI_STATE}/${CI_REPO_ID}-${CI_PIPELINE_NUMBER}。 +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +state="${MOSHIRO_CI_STATE:-/home/justme/woodpecker/cache/pipeline-state}/${CI_REPO_ID:-0}-${CI_PIPELINE_NUMBER:-0}" +shared="$state/web-dist" +apps=(display admin) + +case "${1:-}" in + publish) + mkdir -p "$state" + tmp="$state/web-dist.tmp.$$" + rm -rf "$tmp" "$shared" + for app in "${apps[@]}"; do + [[ -f "$root/web/$app/dist/index.html" ]] || { echo "web/$app/dist is missing" >&2; exit 1; } + mkdir -p "$tmp/$app" + cp -a "$root/web/$app/dist/." "$tmp/$app/" + done + mv "$tmp" "$shared" + ;; + fetch) + deadline=$((SECONDS + ${MOSHIRO_SHARED_WEB_WAIT:-600})) + until [[ -f "$shared/display/index.html" && -f "$shared/admin/index.html" ]]; do + if [[ -e "$state/failed" ]]; then echo "the pipeline already failed; no shared web build" >&2; exit 1; fi + if ((SECONDS >= deadline)); then echo "the shared web build did not appear in time" >&2; exit 1; fi + sleep 2 + done + for app in "${apps[@]}"; do + rm -rf "$root/web/$app/dist" + mkdir -p "$root/web/$app/dist" + cp -a "$shared/$app/." "$root/web/$app/dist/" + done + ;; + *) + echo "usage: shared-web.sh publish|fetch" >&2 + exit 2 + ;; +esac diff --git a/ci/web-build.sh b/ci/web-build.sh index d37f166..bbf13e1 100644 --- a/ci/web-build.sh +++ b/ci/web-build.sh @@ -11,7 +11,8 @@ 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)" +# 开发构建会把版本号写进 package.json;缓存键里去掉 version 行,否则每个提交都是新的键,缓存永远命中不了 +key="$(cat "$web/package-lock.json" "$web/package.json" "$web/display/package.json" "$web/admin/package.json" | grep -v '^ "version":' | sha256sum | cut -c1-16)" restore() { [[ -d "$cache/$key/node_modules" ]] || return 1 @@ -57,3 +58,5 @@ failed=0 for pid in "${pids[@]}"; do wait "$pid" || failed=1; done [[ "$failed" == 0 ]] || { echo "web build failed" >&2; exit 1; } [[ -n "${WEB_SKIP_LINT:-}" ]] || npm run lint:css --prefix "$web" +# 开发构建(main 上带 [CI] 的提交):前端是这条流水线的唯一一份,放进共享目录,开发镜像的 Backend 直接用(见 ci/shared-web.sh) +[[ -z "${WEB_PUBLISH_SHARED:-}" ]] || bash "$root/ci/shared-web.sh" publish diff --git a/scripts/test-linux-agent-build.py b/scripts/test-linux-agent-build.py index c814c83..d89d3db 100644 --- a/scripts/test-linux-agent-build.py +++ b/scripts/test-linux-agent-build.py @@ -218,6 +218,27 @@ esac env['MOSHIRO_AOT_SLOT_WAIT'] = '5' self.assertEqual(subprocess.run(['bash', script, 'sh', '-c', 'exit 7'], env=env).returncode, 7) + def test_web_build_is_shared_with_the_backend_of_the_same_pipeline(self): + with tempfile.TemporaryDirectory() as temp: + temp = Path(temp) + env = dict(os.environ, MOSHIRO_CI_STATE=str(temp / 'state'), CI_REPO_ID='1', CI_PIPELINE_NUMBER='2', + MOSHIRO_SHARED_WEB_WAIT='1') + script = ROOT / 'ci/shared-web.sh' + # 复制一份脚本到假的仓库根,让它读写假的 web 目录 + fake = temp / 'repo' + (fake / 'ci').mkdir(parents=True) + (fake / 'ci/shared-web.sh').write_text(script.read_text()) + for app in ('display', 'admin'): + (fake / 'web' / app / 'dist').mkdir(parents=True) + (fake / 'web' / app / 'dist/index.html').write_text(app) + run = lambda *args: subprocess.run(['bash', str(fake / 'ci/shared-web.sh'), *args], env=env, capture_output=True, text=True) + self.assertEqual(run('fetch').returncode, 1) + self.assertEqual(run('publish').returncode, 0) + for app in ('display', 'admin'): + (fake / 'web' / app / 'dist/index.html').unlink() + self.assertEqual(run('fetch').returncode, 0) + self.assertEqual((fake / 'web/admin/dist/index.html').read_text(), 'admin') + if __name__ == '__main__': unittest.main(verbosity=2)