Build the frontend once per pipeline and reuse it in the dev backend image [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was canceled
ci/woodpecker/push/dev-image Pipeline was canceled

This commit is contained in:
justme committed 2026-10-21 15:17:37 +08:00
1 parent 3654d37da5
commit d1ece67a6a
5 files changed
+82 -6

No files matched your search

+9 -1
View File
@@ -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
+4 -4
View File
@@ -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; }
+44
View File
@@ -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
+4 -1
View File
@@ -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
+21
View File
@@ -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)