From 235f72e68c0d5d44a7b43f1723f504fdabfa12c9 Mon Sep 17 00:00:00 2001 From: mole404 <248403041+mole404@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:20:43 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E7=9C=9F=E5=8D=87=E7=BA=A7=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=20+=20retag=20digest=20=E6=A0=A1=E9=AA=8C=20+=20OSV?= =?UTF-8?q?=20=E4=BE=9D=E8=B5=96=E9=97=A8=E7=A6=81=20+=20=E4=BF=AE?= =?UTF-8?q?=E8=AF=95=E6=9E=84=E5=BB=BA=20digest=20=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-image.yaml:试构建时按请求版本自动从 Registry API 解析 php-cli / php-apache 的 index digest 一并传参。实测 tag@digest 时 digest 优先且 tag 被静默忽略 → 旧写法会构建出 默认版本、再被『PHP 版本校验』判红,白烧一次全量构建。另加 deps-scan job 作为 build 前置。 - 新增 tools/upgrade-test.sh:旧镜像 → 新镜像真升级用例(独立容器 / 卷 / 端口 18090), 断言数据面逐字节不变(.env / installed.lock / SQLite 文件与指纹 / 缩略图 / 编译缓存)、 代码确实换成新镜像那一版、终局日志必须是『已同步进卷』而非『跳过同步』。 本机真跑通过,并已用变异副本证明用例真能红(改数据面会红、tag 被挪会红)。 - retag.yaml:retag 后断言目标 digest 与源逐字一致,不一致则打印两个 digest 并失败。 - osv-scanner.toml:11 条已知且接受的告警入库(laravel 9.x 那几条上游无修复版本、 dev-only 与 test-only 各若干),新增漏洞会让 CI 红;抽掉白名单实测确实会红。 --- .github/workflows/build-image.yaml | 156 +++++++++- .github/workflows/retag.yaml | 74 ++++- osv-scanner.toml | 99 ++++++ tools/upgrade-test.sh | 463 +++++++++++++++++++++++++++++ 4 files changed, 782 insertions(+), 10 deletions(-) create mode 100644 osv-scanner.toml create mode 100755 tools/upgrade-test.sh diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml index 9f3b3e4..9f81f3b 100644 --- a/.github/workflows/build-image.yaml +++ b/.github/workflows/build-image.yaml @@ -13,6 +13,15 @@ on: - master # 只用于「试构建」:临时换 PHP 版本验证兼容性,不改仓库里的默认值。 # 留空 = 用 Dockerfile 里的 ARG PHP_VERSION。 + # + # ⚠ 为什么还要 digest 参数(B5 修复):Dockerfile 的 FROM 写成 + # php:${PHP_VERSION}-cli-${DEBIAN_RELEASE}@${PHP_CLI_IMAGE_DIGEST}。 + # 带 `@digest` 时 **digest 优先、tag 只作装饰** —— 只把 PHP_VERSION 换成 8.2 而 + # 不换 digest,拉到的仍是 8.3 那份内容(见本次实测:8.2-cli 的 tag 配 8.3 的 digest + # 解析出来仍是 8.3.35),构建会「成功」但版本不对,随后被 PHP 版本校验判红。 + # 所以试构建必须一并给出与新版本匹配的 digest。 + # 这两个输入**可以留空**:留空时 Resolve 步骤会自动按请求的 PHP 版本从 Docker Hub + # 解析出对应 index digest(正常路径无需手填);只有在自动解析不可用时才手工指定。 workflow_dispatch: inputs: php_version: @@ -20,6 +29,16 @@ on: required: false default: '' type: string + php_cli_digest: + description: '(试构建用,可留空)PHP cli 基镜像 digest:sha256:…;留空=自动解析' + required: false + default: '' + type: string + php_apache_digest: + description: '(试构建用,可留空)PHP apache 基镜像 digest:sha256:…;留空=自动解析' + required: false + default: '' + type: string # 串行化:连着推两次时排队执行(不打断正在跑的构建), # 保证最后 promote 到 latest 的一定是最新那次提交的产物。 @@ -41,9 +60,68 @@ env: # 上游快照里的数据库迁移文件个数。换 pin 时若这个数变了,说明有 schema 变更, # 而 entrypoint 不会自动跑 migrate —— 那种情况必须人工处理,所以在 CI 里钉住提醒。 UPSTREAM_MIGRATION_COUNT: "10" + # 「真升级用例」(tools/upgrade-test.sh)的升级基线:**不可变** tag(sha-)。 + # 为什么用它、不用 :latest:immutable tag 公开可拉、永不变动,是唯一可复现的「旧版本」; + # latest 会随每次 promote 漂移(甚至可能正好就是本次这次构建的同 commit 产物 —— 那就成了 + # 自己升级自己)。它不算「事实的副本」:脚本会拿 tag 名里的 sha 去核镜像自带的 + # .code-revision(互相印证),谁把 tag 挪到别的版本上都会当场报错(这个仓库真的贴错过 tag)。 + UPGRADE_FROM_TAG: sha-cd14a3b9a5e176bf929c962d0575a418e9d80cd3 jobs: + # --------------------------------------------------------------------------- + # B8 依赖漏洞门禁:构建镜像**之前**先扫两个 lockfile,有「不在 osv-scanner.toml + # 白名单里」的漏洞就红。build `needs` 本 job → 带新漏洞的 lock 不可能产出新镜像, + # 更不可能推进 latest。 + # · 扫描器钉到 image digest(可复现;与 Dockerfile 钉基镜像 digest 同理)。 + # · 白名单与「接受理由」在仓库根的 osv-scanner.toml;osv-scanner 会在日志里打印 + # 「…filtered out because: 」,便于核对到底滤掉了什么。 + # · 结果同时进 CI 日志与 step summary;出现未白名单的漏洞(退出非 0)本 job 即失败。 + # --------------------------------------------------------------------------- + deps-scan: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: OSV scan lockfiles (pinned scanner image digest) + run: | + set -euo pipefail + # 扫描器:官方 google/osv-scanner 容器镜像,钉到具体版本 + digest。 + # 选它的理由:官方维护;直接吃 composer.lock / package-lock.json(无需先出 SBOM); + # 支持白名单文件(osv-scanner.toml);CLI 退出码可直接当门禁。 + # 更新方式(**只改下面这一行**):`docker buildx imagetools inspect ghcr.io/google/osv-scanner:<新版本>` + # 取顶层 Digest,把镜像引用写成 <新版本>@sha256:<新digest>。 + SCANNER="ghcr.io/google/osv-scanner:v2.2.2@sha256:fd62daecbaaed24442031574540e8af6dbaf0ab0a3e28c626bec0dd235a28346" + echo "扫描器:$SCANNER" + # 结果先落文件再整段打印(不在管道里接 head/grep:pipefail 下 SIGPIPE 会假红)。 + set +e + docker run --rm -v "$PWD:/src:ro" "$SCANNER" \ + scan source \ + -L /src/src/composer.lock \ + -L /src/test/package-lock.json \ + --config /src/osv-scanner.toml \ + --format table > "$RUNNER_TEMP/osv.txt" 2>&1 + RC=$? + set -e + cat "$RUNNER_TEMP/osv.txt" + { + echo "## OSV 依赖漏洞扫描(src/composer.lock + test/package-lock.json)" + echo "" + echo '```' + cat "$RUNNER_TEMP/osv.txt" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + if [ "$RC" != "0" ]; then + echo "::error::OSV 扫描发现「不在白名单里」的漏洞(扫描器退出 $RC)。若确属可接受,按 osv-scanner.toml 的格式加白名单并写明 reason / ignoreUntil;否则请升级依赖。" + exit 1 + fi + echo "OSV 扫描通过:命中的漏洞全部在 osv-scanner.toml 白名单内。" + build: + # 依赖门禁不过就不构建(见文件顶部注释与 osv-scanner.toml) + needs: deps-scan runs-on: ubuntu-latest permissions: contents: read @@ -59,22 +137,74 @@ jobs: env: # 走 env 传参,不把 input 直接拼进 shell 脚本(避免脚本注入) REQ_PHP_INPUT: ${{ inputs.php_version }} + REQ_CLI_DIGEST: ${{ inputs.php_cli_digest }} + REQ_APACHE_DIGEST: ${{ inputs.php_apache_digest }} run: | set -euo pipefail PIN=$(grep -oE '^ARG LSKY_COMMIT=[0-9a-f]+' Dockerfile | cut -d= -f2) DEF_PHP=$(grep -oE '^ARG PHP_VERSION=[0-9.]+' Dockerfile | cut -d= -f2) + DEB=$(grep -oE '^ARG DEBIAN_RELEASE=[a-z]+' Dockerfile | cut -d= -f2) [ -n "$PIN" ] || { echo "::error::Dockerfile 里没找到 ARG LSKY_COMMIT"; exit 1; } [ -n "$DEF_PHP" ] || { echo "::error::Dockerfile 里没找到 ARG PHP_VERSION"; exit 1; } + [ -n "$DEB" ] || { echo "::error::Dockerfile 里没找到 ARG DEBIAN_RELEASE"; exit 1; } REQ_PHP="${REQ_PHP_INPUT:-$DEF_PHP}" echo "pin=$PIN" >> "$GITHUB_OUTPUT" echo "php_version=$REQ_PHP" >> "$GITHUB_OUTPUT" echo "php_default=$DEF_PHP" >> "$GITHUB_OUTPUT" + echo "debian_release=$DEB" >> "$GITHUB_OUTPUT" + + # build-args 先落文件:默认只带 FORK_SHA / PHP_VERSION(基镜像 digest 用 Dockerfile 的 ARG 默认值)。 + # 试构建时再追加两条 digest 覆盖项。最后整体塞进一个**多行 output** 给 build 步骤, + # 这样非试构建时根本不会出现「空值的 digest build-arg」(空值会把 Dockerfile 默认 ARG 覆盖成空, + # 让 FROM 变成 php:8.3-cli-bookworm@ 这种非法串)。 + BA="$RUNNER_TEMP/build-args.txt" + printf 'FORK_SHA=%s\nPHP_VERSION=%s\n' "$GITHUB_SHA" "$REQ_PHP" > "$BA" + if [ "$REQ_PHP" != "$DEF_PHP" ]; then echo "is_test_build=true" >> "$GITHUB_OUTPUT" echo "::notice::试构建(PHP $REQ_PHP ≠ Dockerfile 默认 $DEF_PHP):本次不会提升 latest" + # B5:试构建必须换成与新 PHP 版本匹配的基镜像 digest(原因见 workflow_dispatch 上方注释)。 + # 解析方法 = Docker Hub Registry API 原始响应头(与 Dockerfile 注释里教的手工方法同源): + # 对 library/php 这个 repo 按 tag 取 manifest,读 Docker-Content-Digest。 + resolve_php_digest() { # $1 = tag,如 8.2-cli-bookworm + local tag="$1" + local tok + tok=$(curl -fsS \ + 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/php:pull' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])') + curl -fsSI \ + -H "Authorization: Bearer ${tok}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + "https://registry-1.docker.io/v2/library/php/manifests/${tag}" \ + | tr -d '\r' | awk 'tolower($1)=="docker-content-digest:"{print $2}' + } + CLI_DIGEST="${REQ_CLI_DIGEST:-}" + APACHE_DIGEST="${REQ_APACHE_DIGEST:-}" + [ -n "$CLI_DIGEST" ] || CLI_DIGEST=$(resolve_php_digest "${REQ_PHP}-cli-${DEB}" || true) + [ -n "$APACHE_DIGEST" ] || APACHE_DIGEST=$(resolve_php_digest "${REQ_PHP}-apache-${DEB}" || true) + # 断言格式:必须 sha256:<64 hex>,否则拼出来的 FROM 不是合法 repo:tag@sha256:… + [[ "$CLI_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] \ + || { echo "::error::拿不到 ${REQ_PHP}-cli-${DEB} 的合法 digest(得到:'$CLI_DIGEST')—— 可在 workflow_dispatch 里手工指定 php_cli_digest"; exit 1; } + [[ "$APACHE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] \ + || { echo "::error::拿不到 ${REQ_PHP}-apache-${DEB} 的合法 digest(得到:'$APACHE_DIGEST')—— 可在 workflow_dispatch 里手工指定 php_apache_digest"; exit 1; } + echo "php_cli_digest=$CLI_DIGEST" >> "$GITHUB_OUTPUT" + echo "php_apache_digest=$APACHE_DIGEST" >> "$GITHUB_OUTPUT" + printf 'PHP_CLI_IMAGE_DIGEST=%s\nPHP_APACHE_IMAGE_DIGEST=%s\n' "$CLI_DIGEST" "$APACHE_DIGEST" >> "$BA" + echo "试构建基镜像 digest:${REQ_PHP}-cli-${DEB}@$CLI_DIGEST" + echo "试构建基镜像 digest:${REQ_PHP}-apache-${DEB}@$APACHE_DIGEST" else echo "is_test_build=false" >> "$GITHUB_OUTPUT" fi + + # 多行 output(GITHUB_OUTPUT heredoc 语法)→ build 步骤直接 build-args: ${{ steps.pin.outputs.build_args }} + { + echo "build_args<<__BUILD_ARGS__" + cat "$BA" + echo "__BUILD_ARGS__" + } >> "$GITHUB_OUTPUT" + echo "--- 本次 build-args ---" + cat "$BA" + echo "上游 pin = $PIN" echo "本次构建 PHP = $REQ_PHP(Dockerfile 默认 $DEF_PHP)" # 迁移数守卫:换 pin 时若上游新增迁移,这里报红,提醒必须人工评估 schema 变更 @@ -134,9 +264,10 @@ jobs: push: true tags: | ${{ env.IMAGE }}:sha-${{ github.sha }} - build-args: | - FORK_SHA=${{ github.sha }} - PHP_VERSION=${{ steps.pin.outputs.php_version }} + # build-args 由 Resolve 步骤统一产出(含试构建时按 PHP 版本解析出的基镜像 digest): + # 非试构建 = 只有 FORK_SHA / PHP_VERSION(digest 走 Dockerfile 默认 ARG); + # 试构建 = 再追加 PHP_CLI_IMAGE_DIGEST / PHP_APACHE_IMAGE_DIGEST 两个覆盖项。 + build-args: ${{ steps.pin.outputs.build_args }} cache-from: type=gha cache-to: type=gha,mode=max @@ -730,6 +861,25 @@ jobs: docker rm -f lsky-c >/dev/null 2>&1 || true docker volume rm -f "$VOL" >/dev/null 2>&1 || true + # ------------------------------------------------------------------ + # 真升级用例(B6):用**已发布的旧镜像**把一个独立卷养成真实站点(真实代码树 + 真实数据面), + # 再换上本次构建的新镜像挂同一个卷起 —— 等价于线上 `docker compose pull && up -d` 那条 + # 真实路径。断言升级后数据逐字节不变、代码确实换成了新镜像那一版、日志走了同步分支。 + # A/B/C 三段是拿「手工改坏的标记 / 手工改坏的代码」在同一次构建里模拟升级,只证明得了 + # entrypoint 的分支逻辑;这一段才证明「从旧版镜像真的升上来」没问题。 + # 脚本单独放在 tools/upgrade-test.sh —— 本地可以原样复跑(见脚本头部的用法注释), + # 所以不存在「workflow 里抄一份、本地跑的是另一份」的漂移。 + # 名字/端口与其它步骤全错开:容器 lsky-up-old / lsky-up-new、卷 lsky-upgrade-vol、 + # 宿主端口 18090(smoke 用 18089),且严格串行(旧容器先 rm 再起新的)。 + # ------------------------------------------------------------------ + - name: Verify real upgrade from previous image (data byte-identical) + env: + IMAGE_REF: ${{ env.IMAGE }}:sha-${{ github.sha }} + UPGRADE_FROM_TAG: ${{ env.UPGRADE_FROM_TAG }} + run: | + set -euo pipefail + bash tools/upgrade-test.sh + # 自证全过之后才动 latest:验证失败时 latest 保持在上一个已知可用版本 - name: Promote verified image to latest # 试构建(手动指定 php_version 且与 Dockerfile 默认不同)只保留不可变 tag,不动 latest: diff --git a/.github/workflows/retag.yaml b/.github/workflows/retag.yaml index 7c3c6ee..dff9494 100644 --- a/.github/workflows/retag.yaml +++ b/.github/workflows/retag.yaml @@ -6,7 +6,9 @@ name: Retag (repoint a tag to an existing image) # 安全闸: # · 不允许把 `latest` 当目标(线上拉取目标只能由 build-image 的 Promote 步骤动) # · 源必须是不可变 tag(`sha-`),避免把某个浮动 tag 的当前指向误复制过去 -# · 打印改前/改后 digest,便于核对 +# · 改前/改后都打印 digest,并**断言目标 digest 与源逐字一致**(B7): +# digest 是内容寻址的,相等 ⇒ 只改了指向、没换内容;不一致就 fail(并把两个 digest 都打出来)。 +# 若目标本来就指向同一个 digest 则幂等短路、不做无谓写入。 on: workflow_dispatch: @@ -59,7 +61,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4 - - name: Retag(打印改前/改后 digest) + - name: Retag(改前/改后校验源与目标 digest 一致) env: FROM: ${{ inputs.from_tag }} TO: ${{ inputs.to_tag }} @@ -67,14 +69,72 @@ jobs: set -euo pipefail IMAGE="ghcr.io/mole404/lsky-pro-docker" # 注意:本步骤带 set -o pipefail,`cmd | head` 会因 SIGPIPE 退出 255 把整步搞红 - # (这个坑以前踩过)→ 一律先写文件再 head。 + # (这个坑以前踩过)→ 一律先写文件、再 head,不把 inspect 接到 head/grep 的管道里。 + # + # 取某 tag 的**顶层 digest**(multi-arch 时就是 index 的 digest)。取不到返回非 0。 + digest_of() { # $1=tag $2=落盘文件 + docker buildx imagetools inspect "${IMAGE}:$1" > "$2" 2>&1 + } + echo "== 改前::${TO} ==" - docker buildx imagetools inspect "${IMAGE}:${TO}" > /tmp/before.txt 2>&1 || echo "(该 tag 当前不存在)" > /tmp/before.txt - head -6 /tmp/before.txt || true + BEFORE="" + if digest_of "$TO" /tmp/before.txt; then + BEFORE=$(awk '$1=="Digest:"{print $2; exit}' /tmp/before.txt) + head -6 /tmp/before.txt || true + echo "改前 :${TO} digest = ${BEFORE:-(未解析到)}" + else + echo "(:${TO} 当前不存在)" + head -6 /tmp/before.txt || true + fi + echo "== 源::${FROM} ==" - docker buildx imagetools inspect "${IMAGE}:${FROM}" > /tmp/from.txt 2>&1 + if ! digest_of "$FROM" /tmp/from.txt; then + echo "::error::取不到源 :${FROM} 的 digest —— 源 tag 不存在或无法访问" + head -20 /tmp/from.txt || true + exit 1 + fi head -6 /tmp/from.txt || true + SRC_DIGEST=$(awk '$1=="Digest:"{print $2; exit}' /tmp/from.txt) + if ! [[ "$SRC_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::源 :${FROM} 的 digest 格式不对(解析到:'$SRC_DIGEST')" + head -20 /tmp/from.txt || true + exit 1 + fi + echo "源 :${FROM} digest = $SRC_DIGEST" + + # 幂等短路:目标已经是同一个 digest(内容一致)就没必要再改 + if [ -n "$BEFORE" ] && [ "$BEFORE" = "$SRC_DIGEST" ]; then + echo "::notice:::${TO} 已指向 $SRC_DIGEST(与源一致),无需变更" + exit 0 + fi + docker buildx imagetools create -t "${IMAGE}:${TO}" "${IMAGE}:${FROM}" + echo "== 改后::${TO} ==" - docker buildx imagetools inspect "${IMAGE}:${TO}" > /tmp/after.txt 2>&1 + if ! digest_of "$TO" /tmp/after.txt; then + echo "::error::retag 之后读不到 :${TO} 的 digest —— retag 可能没生效" + head -20 /tmp/after.txt || true + exit 1 + fi head -6 /tmp/after.txt || true + DST_DIGEST=$(awk '$1=="Digest:"{print $2; exit}' /tmp/after.txt) + + # 核心断言:目标 digest 必须**逐字等于**源 digest。 + # digest 是内容寻址的(同一 digest ⇒ 同一份内容),相等即证明 retag 只改指向、没换内容。 + # 不一致必须失败 —— 否则等于把一个「和源不同」的镜像悄悄挂到该 tag 上。 + if [ "$DST_DIGEST" != "$SRC_DIGEST" ]; then + echo "::error::retag 源/目标 digest 不一致!" + echo " 源 :${FROM} = $SRC_DIGEST" + echo " 目标 :${TO} = $DST_DIGEST" + echo "--- 源 :${FROM} 完整 inspect ---"; cat /tmp/from.txt || true + echo "--- 目标 :${TO} 完整 inspect ---"; cat /tmp/after.txt || true + exit 1 + fi + echo "✅ digest 校验通过::${TO} 与源 :${FROM} 内容一致($SRC_DIGEST)" + { + echo "## Retag digest 校验" + echo "" + echo "- 源 \`:${FROM}\` = \`$SRC_DIGEST\`" + echo "- 目标 \`:${TO}\` = \`$DST_DIGEST\`(改前:${BEFORE:-不存在})" + echo "- 结论:一致 ✅" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000..cddf288 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,99 @@ +# ============================================================================= +# OSV 依赖漏洞门禁 —— 白名单(B8) +# +# 用途:CI 的 `deps-scan` job 对 src/composer.lock 与 test/package-lock.json 跑 +# osv-scanner。**不在本文件里的**漏洞会让 CI 失败(退出非 0), +# 本文件里的(IgnoredVulns)会被过滤掉并在日志里打印「...filtered out because: 」。 +# +# 维护规则: +# · 只白名单「我们明确接受」的告警,每条都要写清 reason 与 ignoreUntil(复审日期)。 +# · `ignoreUntil` 到期后该条不再生效(漏洞会重新报出来),逼你复审 —— 别当永久豁免。 +# · 清单一旦变化(升级依赖 / 上游发新公告),CI 会红;那时要么修依赖,要么按下面格式加条目。 +# · 白名单条目的 id 用 GHSA-…(osv-scanner 会把 CVE 等别名一并归到 GHSA 上)。 +# +# 本文件内容 = 2026-09-30 真实扫描(osv-scanner v2.2.2)得到的全部命中, +# 逐条评估后接受;详见各条 reason。如何「修掉」而不是「白名单」见各组注释。 +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 1) laravel/framework 9.52.22 —— 生产依赖(会进镜像),Laravel 9 已 EOL +# 上游修复只落在 10.x / 11.x / 12.x / 13.x(见各条 fixed 版本),9.x 线拿不到补丁。 +# 真正升级 = 跨大版本重写应用,不在「只维护镜像」的范围内;接受。 +# 想根治:升到 laravel/framework 12.x 线(需同步改 src/ 应用代码)。 +# ----------------------------------------------------------------------------- +[[IgnoredVulns]] +id = "GHSA-5vg9-5847-vvmq" # CVE-2026-48019:默认 email 规则里的 CRLF 注入 +ignoreUntil = 2027-06-30 +reason = "laravel/framework 9.x:上游修复在 12.60.0/13.10.0,9.x(EOL) 无补丁;站点非公开注册、未用该 email 规则" + +[[IgnoredVulns]] +id = "GHSA-78fx-h6xr-vch4" # CVE-2025-27515:文件校验绕过 +ignoreUntil = 2027-06-30 +reason = "laravel/framework 9.x:上游修复在 10.48.29/11.44.1/12.1.1,9.x(EOL) 无补丁;控制上传入口的校验规则已自行加固" + +[[IgnoredVulns]] +id = "GHSA-crmm-hgp2-wgrp" # 临时签名 URL 路径混淆 +ignoreUntil = 2027-06-30 +reason = "laravel/framework 9.x:上游修复在 12.61.1/13.12.0,9.x(EOL) 无补丁;本站未使用临时签名 URL 功能" + +[[IgnoredVulns]] +id = "GHSA-jh5r-qr3c-85q8" # CVE-2026-102279:调试页 XSS +ignoreUntil = 2027-06-30 +reason = "laravel/framework 9.x:上游修复在 12.69.0/13.30.0,9.x(EOL) 无补丁;镜像里 APP_DEBUG 关闭,调试页不对外" + +# ----------------------------------------------------------------------------- +# 2) psy/psysh v0.11.12 —— 生产依赖(随 laravel/tinker 进镜像) +# 上游已在 **同一条 0.11 线** 发修复(0.11.23),是可修的。 +# 但改它必须动 src/composer.lock,而 Dockerfile 对 composer.lock 有 md5 自证, +# 牵一发动全身;不在本次「只加门禁」范围内 —— 记为待办,到期前处理。 +# 想根治:把 psy/psysh 升到 >=0.11.23 并同步更新 Dockerfile 里的 lock md5 自证。 +# ----------------------------------------------------------------------------- +[[IgnoredVulns]] +id = "GHSA-4486-gxhx-5mg7" # CVE-2026-25129:CWD 下 .psysh.php 自动加载导致本地提权 +ignoreUntil = 2026-12-31 +reason = "psy/psysh 0.11.12:同线 0.11.23 已修,但升级需改 composer.lock + Dockerfile md5 自证(待办);镜像内 psysh 仅本机可触达,无远程面" + +# ----------------------------------------------------------------------------- +# 3) maximebf/debugbar v1.18.2 —— **开发依赖**(packages-dev) +# Dockerfile 用 `composer install --no-dev` 构建镜像 → 不随镜像发布。 +# 命中的两条其实是它捆绑的 jQuery 的 XSS(CVE-2019-11358 / CVE-2020-11022), +# 对「镜像里有没有这个东西」无影响。 +# ----------------------------------------------------------------------------- +[[IgnoredVulns]] +id = "GHSA-6c3j-c64m-qhgq" # CVE-2019-11358:jQuery 原型污染 XSS(debugbar 捆绑) +ignoreUntil = 2027-06-30 +reason = "maximebf/debugbar 是 packages-dev 开发依赖,镜像用 --no-dev 构建 → 不进镜像;命中为捆绑的旧 jQuery" + +[[IgnoredVulns]] +id = "GHSA-gxr4-xjj5-5px2" # CVE-2020-11022:jQuery XSS(debugbar 捆绑) +ignoreUntil = 2027-06-30 +reason = "同上:dev-only,不进 --no-dev 镜像" + +# ----------------------------------------------------------------------------- +# 4) phpunit/phpunit 9.6.5 —— **开发依赖**(packages-dev),不进镜像。 +# 上游已发 9.6.33(同线可修),同 psy/psysh,升级需动 lock + md5 自证 → 待办。 +# ----------------------------------------------------------------------------- +[[IgnoredVulns]] +id = "GHSA-vvj3-c3rp-c85p" # CVE-2026-24765:PHPT 覆盖率处理里的不安全反序列化 +ignoreUntil = 2026-12-31 +reason = "phpunit 是 packages-dev 开发依赖,不进 --no-dev 镜像;同线 9.6.33 已修,升级需动 lock(待办)" + +# ----------------------------------------------------------------------------- +# 5) sweetalert2@11.4.6 —— 只被 test/ 的行为测试用(test/package-lock.json), +# 是测试工具链依赖,不随镜像发布(镜像里没有 sweetalert2)。 +# 上游已发 11.22.4(同线可修)→ 升级 test/package-lock.json 即可,属待办。 +# ----------------------------------------------------------------------------- +[[IgnoredVulns]] +id = "GHSA-457r-cqc8-9vj9" +ignoreUntil = 2026-12-31 +reason = "sweetalert2 仅为 test/ 行为测试依赖,不进镜像;同线 11.22.4 已修,升 test/package-lock.json 即消(待办)" + +[[IgnoredVulns]] +id = "GHSA-8jh9-wqpf-q52c" +ignoreUntil = 2026-12-31 +reason = "同上:test-only 依赖,不进镜像,同线可修" + +[[IgnoredVulns]] +id = "GHSA-pg98-6v7f-2xfv" +ignoreUntil = 2026-12-31 +reason = "同上:test-only 依赖,不进镜像,同线可修" diff --git a/tools/upgrade-test.sh b/tools/upgrade-test.sh new file mode 100755 index 0000000..fc06249 --- /dev/null +++ b/tools/upgrade-test.sh @@ -0,0 +1,463 @@ +#!/usr/bin/env bash +# ============================================================================ +# B6 真升级用例:「旧镜像 → 新镜像」在**同一个卷**上做一次真实升级,断言站点数据逐字节不变。 +# +# 为什么需要它:CI 里的 A/B/C 三段是拿「手工改坏的标记 / 手工改坏的代码」在**同一次构建**的 +# 镜像里模拟升级 —— 只能证明 entrypoint 的分支逻辑对,证明不了「从已发布的旧版镜像升上来 +# 会怎样」。本用例先用**已发布的旧镜像**把一个卷养成真实站点(真实代码树 + 真实数据布局), +# 再换上本次构建的新镜像挂同一个卷起,是端到端的真升级。 +# +# 关键设计(每条都有理由,改之前先读): +# 1) 升级基线用**不可变 tag**(`UPGRADE_FROM_TAG` = sha-),不用 :latest: +# immutable tag 永不变动、公开可拉,是唯一可复现的「旧版本」;:latest 会随每次 promote +# 漂移(甚至可能正好是本次这次构建的同 commit 产物 —— 那就成了自己升级自己)。 +# 防漂移:脚本拿 tag 名里的 sha 去核镜像自带的 .code-revision(互相印证), +# 谁把 tag 挪到别的版本上,这里当场报错(本仓库历史上真的贴错过 tag)。 +# 2) 「升级」= 删掉旧容器,再用新镜像挂**同一个卷**起 —— 与线上 +# `docker compose pull && docker compose up -d` 等价。刻意不用 --force-recreate 之类 +# 的花活:要验的就是那条真实路径。 +# 3) 数据哨兵覆盖真实站点的全部数据面:.env(多行 + UTF-8 + 特殊字符)/ installed.lock +# (丢了它整站会被重定向到 /install)/ database/database.sqlite(真建表 + 真写行 + BLOB, +# 不是个空文件)/ storage/app/uploads(真 PNG "缩略图")/ public/i(同步排除清单里的 +# 部署产物)。 +# 断言分三层:①逐文件 md5 不变 ②整个数据面清单(排序后的 md5 列表)diff 必须为空 +# ③用新镜像的 PHP + PDO 真的打开 SQLite 读出行指纹(文件级 + 逻辑级两重)。 +# 4) 代码确实换成新的了:.code-revision 的 fork_sha = 本次 commit、public/js/app.js 与 +# app/Services/ImageService.php 的 md5 = 新镜像里那份(升级前先把它们改成垃圾/手工修补, +# 所以这条断言与「两版镜像恰好差在哪」无关,永远有效)、编译视图缓存被清。 +# 再加一条端到端:Apache 实际吐给浏览器的 /js/app.js 就是新镜像那一份。 +# 5) 终局日志行:新容器必须打「已同步进卷:fork_sha=<本次 commit>」(不是「跳过同步」); +# 旧容器必须打过它那条分支的终局行(播种/同步),证明这个卷真是旧镜像养起来的。 +# 6) 清理一律走 trap;失败时先把两个容器的完整日志 dump 出来再退。 +# +# 与 workflow 里其它步骤错开的名字(改名前先确认没撞车): +# 已有:容器 lsky-smoke(宿主 18089)/ lsky-mpm-a..f + 卷 lsky-mpm-vol-a..f / lsky-a|lsky-b|lsky-c + 卷 lsky-sync-test +# 本步:容器 lsky-up-old / lsky-up-new、卷 lsky-upgrade-vol、宿主端口 18090,且严格串行 +# (旧容器先 rm 掉,再起新的 —— 任何时刻最多一个容器在跑 entrypoint)。 +# +# 本地复跑(不需要 docker build,公开镜像直接 docker pull): +# IMAGE_REF=ghcr.io/mole404/lsky-pro-docker:latest \ +# GITHUB_SHA=$(git -C rev-parse HEAD) bash tools/upgrade-test.sh +# ============================================================================ +set -euo pipefail + +IMAGE="${IMAGE:-ghcr.io/mole404/lsky-pro-docker}" +IMAGE_REF="${IMAGE_REF:-}" +# 旧版基线:不可变 tag。默认值是 2026-09-30 那次发布之前的那一版(sha-cd14a3b…)。 +UPGRADE_FROM_TAG="${UPGRADE_FROM_TAG:-sha-cd14a3b9a5e176bf929c962d0575a418e9d80cd3}" +EXPECT_SHA="${GITHUB_SHA:-}" # CI 里必有;本地跑可省(省了就只断言「新旧确实不同」) +EXP_CTX="${EXP_CTX:-}" # 补丁版 context-js.js 的 md5;空则从 Dockerfile 解析 + +if [ -z "$IMAGE_REF" ]; then + echo "用法:IMAGE_REF=<本次构建的镜像引用,如 ${IMAGE}:sha-> [UPGRADE_FROM_TAG=sha-] $0" >&2 + exit 2 +fi +OLD_REF="${IMAGE}:${UPGRADE_FROM_TAG}" +NEW_REF="$IMAGE_REF" + +# 没有从 GITHUB_ENV 传进来就从 Dockerfile 解析(单一事实来源,别在这里抄常量) +if [ -z "$EXP_CTX" ] && [ -f Dockerfile ]; then + EXP_CTX=$(grep -oE 'context_js_md5=[0-9a-f]{32}' Dockerfile | head -1 | cut -d= -f2 || true) +fi + +# ---------------------------------------------------------------- 名字(与其它步骤错开) +VOL=lsky-upgrade-vol +OLD_C=lsky-up-old +NEW_C=lsky-up-new +HOST_PORT=18090 # 宿主端口:smoke 用 18089,这里错开,且本步独占 + +LOGDIR=$(mktemp -d) +OLD_LOG=$LOGDIR/old.log +NEW_LOG=$LOGDIR/new.log +SEED_OUT=$LOGDIR/seed.out +AFTER_OUT=$LOGDIR/after.out +SNAP_BEFORE=$LOGDIR/snapshot-before.txt +SNAP_AFTER=$LOGDIR/snapshot-after.txt + +cleanup() { + docker rm -f "$OLD_C" "$NEW_C" >/dev/null 2>&1 || true + docker volume rm -f "$VOL" >/dev/null 2>&1 || true +} +on_err() { + local rc=$? + echo "::group::诊断:真升级用例($OLD_C / $NEW_C 容器日志)" + for c in "$OLD_C" "$NEW_C"; do + echo "--- $c" + docker logs "$c" 2>&1 | tail -60 || true + done + echo "::endgroup::" + cleanup + exit $rc +} +trap on_err ERR + +cleanup # 上一次跑残留的先清掉(幂等) + +echo "== 0) 升级基线自洽:tag 名里的 sha 必须等于旧镜像自带的 fork_sha ==" +for i in 1 2 3; do + if docker pull "$OLD_REF" >/dev/null 2>&1; then break; fi + echo "拉 $OLD_REF 失败,5 秒后重试($i/3)" + sleep 5 +done +docker image inspect "$OLD_REF" >/dev/null 2>&1 \ + || { echo "::error::拉不到升级基线镜像 $OLD_REF(公开包,应能匿名拉取)"; exit 1; } +docker image inspect "$NEW_REF" >/dev/null 2>&1 \ + || { echo "::error::本地没有本次构建的镜像 $NEW_REF(前一步应当已经 pull 过)"; exit 1; } + +# 镜像内文件的 md5(新镜像那份就是「同步之后卷里应该长成的样子」) +img_md5() { docker run --rm --entrypoint md5sum "$1" "$2" | awk '{print $1}'; } +# 镜像自带 .code-revision 里的 fork_sha +img_fork_sha() { + docker run --rm --entrypoint cat "$1" /var/www/lsky/.code-revision \ + | sed -n 's/^fork_sha=//p' | head -1 +} + +OLD_SHA=$(img_fork_sha "$OLD_REF") +NEW_SHA=$(img_fork_sha "$NEW_REF") +TAG_SHA="${UPGRADE_FROM_TAG#sha-}" +NEW_APP_JS=$(img_md5 "$NEW_REF" /var/www/lsky/public/js/app.js) +OLD_APP_JS=$(img_md5 "$OLD_REF" /var/www/lsky/public/js/app.js) +NEW_IMGSVC=$(img_md5 "$NEW_REF" /var/www/lsky/app/Services/ImageService.php) + +[ -n "$OLD_SHA" ] || { echo "::error::旧镜像 $OLD_REF 里没有 .code-revision(太老的镜像不能当基线)"; exit 1; } +[ -n "$NEW_SHA" ] || { echo "::error::新镜像 $NEW_REF 里没有 .code-revision"; exit 1; } +if [ "$OLD_SHA" != "$TAG_SHA" ]; then + echo "::error::基线 tag $UPGRADE_FROM_TAG 指向的镜像里 fork_sha=$OLD_SHA —— tag 被挪到别的版本上了(不可变 tag 必须指向它自己那一版)" + exit 1 +fi +if [ -n "$EXPECT_SHA" ] && [ "$NEW_SHA" != "$EXPECT_SHA" ]; then + echo "::error::新镜像里的 fork_sha=$NEW_SHA ≠ 本次 commit $EXPECT_SHA(build-arg FORK_SHA 没生效?)" + exit 1 +fi +if [ "$OLD_SHA" = "$NEW_SHA" ]; then + echo "::warning::基线 $UPGRADE_FROM_TAG 与本次构建是同一个 commit($NEW_SHA)—— 没有『旧版本』可升,本用例跳过(不阻断发布)" + cleanup + exit 0 +fi +echo "升级基线 $OLD_REF(fork_sha=$OLD_SHA) → 新镜像 $NEW_REF(fork_sha=$NEW_SHA)" +if [ "$OLD_APP_JS" = "$NEW_APP_JS" ]; then + echo "::notice::这一对镜像的 public/js/app.js 恰好相同(本次改动没碰前端产物)——『代码换新』改由 .code-revision 的 fork_sha / ImageService.php 承担" +else + echo "两版的前端产物确实不同:app.js $OLD_APP_JS → $NEW_APP_JS" +fi + +# --------------------------------------------------------------------------- +echo "== 1) 用旧镜像 + 独立卷起一个真实部署(这个卷随后要被升级)==" +docker run -d --name "$OLD_C" -v "$VOL":/var/www/html \ + -p "127.0.0.1:${HOST_PORT}:8089" -e WEB_PORT=8089 "$OLD_REF" >/dev/null + +# 等该分支**终局**日志行(播种分支最后打的就是这行;别等「第一条 [lsky]」—— +# 自签证书 / MPM 那些行排在它前面,等早了会读到中间状态)。最多 120 秒。 +old_ready=0 +for i in $(seq 1 60); do + docker logs "$OLD_C" > "$OLD_LOG" 2>&1 || true + if grep -q '已把镜像应用播种到' "$OLD_LOG"; then old_ready=1; break; fi + st=$(docker inspect -f '{{.State.Status}}' "$OLD_C" 2>/dev/null || echo unknown) + if [ "$st" != running ]; then + echo "::error::旧镜像容器在播种完成前就退出了(status=$st)" + tail -40 "$OLD_LOG" || true + exit 1 + fi + sleep 2 +done +if [ "$old_ready" != 1 ]; then + echo "::error::旧镜像 120 秒内没打完播种的终局日志行(基线必须支持标记式播种)" + tail -40 "$OLD_LOG" || true + exit 1 +fi +grep '\[lsky\]' "$OLD_LOG" || true + +# 基线得是个真能跑的部署(不只是「文件拷完了」):安装向导 200 说明 Apache 也起来了。 +old_code=000 +for i in $(seq 1 60); do + old_code=$(curl -s -o /dev/null -w '%{http_code}' -L "http://127.0.0.1:${HOST_PORT}/install" || true) + if [ "$old_code" = 200 ]; then break; fi + sleep 2 +done +if [ "$old_code" != 200 ]; then + echo "::error::旧镜像的部署没起来(GET /install → ${old_code:-空})" + tail -40 "$OLD_LOG" || true + exit 1 +fi +echo "旧镜像部署就绪:播种终局行 + GET /install = 200" + +# 关掉旧容器再动卷:免得和 entrypoint 末尾的 chown -R / chmod -R 抢同一批文件 +docker rm -f "$OLD_C" >/dev/null + +# --------------------------------------------------------------------------- +echo "== 2) 播种哨兵数据(用旧镜像挂同一卷写;模拟真实站点的数据面)==" +# 用 with-stdin 的方式喂整段脚本:外层 heredoc 带引号 → 里面的 $ 和引号都原样交给容器的 sh, +# 不必跟 docker run --entrypoint sh '...' 的单引号套娃较劲。 +docker run --rm -i -v "$VOL":/var/www/html --entrypoint sh "$OLD_REF" > "$SEED_OUT" 2>&1 <<'EOSH' +set -eu +cd /var/www/html + +# ① 站点配置:多行 + 注释 + 引号 + UTF-8(升级绝不能碰它,覆盖 = 丢 APP_KEY / 存储密钥) +cat > .env <<'ENVEOF' +APP_NAME="Lsky Pro 图床" +APP_ENV=production +APP_KEY=base64:U0VOVElORUxfS0VZX0RPX05PVF9UT1VDSF9fQE1ZTE9OR0JBU0U2NA== +APP_DEBUG=false +APP_URL=http://127.0.0.1:18090 +LOG_CHANNEL=stack +DB_CONNECTION=sqlite +DB_DATABASE=/var/www/html/database/database.sqlite +# 升级哨兵行:必须逐字节活下来(含中文与引号 " ' 反斜杠 \ +UPGRADE_SENTINEL=KEEP_ME_byte-for-byte +ENVEOF + +# ② SQLite 数据库:真建表 + 真写行 + 真 BLOB(不是个空文件) +rm -f database/database.sqlite +php -r ' +$db = new PDO("sqlite:/var/www/html/database/database.sqlite"); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$db->exec("PRAGMA journal_mode=DELETE"); +$db->exec("CREATE TABLE images (id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT NOT NULL, bytes INTEGER NOT NULL, payload BLOB, created_at TEXT)"); +$st = $db->prepare("INSERT INTO images (path, bytes, payload, created_at) VALUES (?,?,?,?)"); +for ($i = 1; $i <= 20; $i++) { + $st->execute(["uploads/2026/09/sentinel-$i.png", 1024 * $i, random_bytes(64), "2026-09-30 12:00:00"]); +} +$st->execute(["中文路径/升级哨兵.png", 4096, random_bytes(128), "2026-09-30 12:00:09"]); +$db->exec("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)"); +$us = $db->prepare("INSERT INTO users (name, email) VALUES (?, ?)"); +$us->execute(["徐老师", "keep-me@example.com"]); +' + +# ③ 图片数据(真 PNG,2x2)+ 缩略图目录 +mkdir -p storage/app/uploads/thumbnails +printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFElEQVR42mP4z8DAAMIM////ZwAAHu8E/HMcU8wAAAAASUVORK5CYII=' \ + | base64 -d > storage/app/uploads/thumbnails/sentinel.png + +# ④ public/i:本地存储策略的部署产物,在 entrypoint 的同步排除清单里(绝不能被镜像代码覆盖) +mkdir -p public/i/deploy +echo "DEPLOY_ARTIFACT:public/i 属于部署产物,换镜像不许动它" > public/i/deploy/keep.txt + +# ⑤ 站点自己的配置缓存 + 已安装锁(installed.lock 丢了 → 整站被重定向到 /install) +mkdir -p bootstrap/cache +printf '%s\n' ' ["name" => "SENTINEL_CONFIG_CACHE"]];' > bootstrap/cache/config.php +: > installed.lock + +# ⑥ 纯缓存(按设计会被作废):编译后的视图 —— 用它反证「同步分支真跑过」 +mkdir -p storage/framework/views +echo ' storage/framework/views/deadbeef.php + +# ⑦ 代码侧哨兵:升级必须被新镜像那一版覆盖(证明代码确实换源了,而不是「恰好没变」) +echo ' app/Services/ImageService.php +echo '// STALE_APP_JS_MUST_BE_REPLACED' > public/js/app.js + +# ⑧ 两个探针写在 ./database 下(同步排除清单里的数据目录 → 升级后还在): +# 于是一个阶段跑的是**同一份**快照/探针定义,不存在「测试代码抄两份会漂移」的问题。 +cat > database/.upgrade-probe.php <<'PHPEOF' +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$rows = 0; +$h = hash_init('sha256'); +foreach ($db->query('SELECT id, path, bytes, length(payload) AS plen FROM images ORDER BY id') as $r) { + $rows++; + hash_update($h, $r['id'] . '|' . $r['path'] . '|' . $r['bytes'] . '|' . $r['plen'] . "\n"); +} +echo 'DB_ROWS=' . $rows . PHP_EOL; +echo 'DB_FINGERPRINT=' . hash_final($h) . PHP_EOL; +$u = $db->query('SELECT name, email FROM users ORDER BY id')->fetch(PDO::FETCH_NUM); +echo 'DB_FIRST_USER=' . $u[0] . '|' . $u[1] . PHP_EOL; +PHPEOF + +cat > database/.upgrade-snapshot.sh <<'SNAPEOF' +#!/bin/sh +# 数据面快照 —— 升级前后各跑一次,两份输出必须逐字节相同(CI 直接 diff)。 +set -eu +cd /var/www/html +md5of() { md5sum "$1" | awk '{print $1}'; } + +echo "逻辑级(用本镜像的 PHP+PDO 真读 SQLite):" +php database/.upgrade-probe.php + +echo "数据文件:" +echo "ENV_MD5=$(md5of .env)" +echo "ENV_LINES=$(wc -l < .env | tr -d ' ')" +echo "ENV_BYTES=$(wc -c < .env | tr -d ' ')" +echo "ENV_SENTINEL_HITS=$(grep -c '^UPGRADE_SENTINEL=KEEP_ME_byte-for-byte$' .env || true)" +echo "ENV_UTF8_HITS=$(grep -c 'Lsky Pro 图床' .env || true)" +echo "INSTALLED_LOCK_MD5=$(md5of installed.lock)" +echo "DB_FILE_MD5=$(md5of database/database.sqlite)" +echo "DB_FILE_BYTES=$(wc -c < database/database.sqlite | tr -d ' ')" +echo "PNG_MD5=$(md5of storage/app/uploads/thumbnails/sentinel.png)" +echo "PNG_MAGIC=$(od -An -tx1 -N8 storage/app/uploads/thumbnails/sentinel.png | tr -d ' \n')" +echo "CONFIG_CACHE_MD5=$(md5of bootstrap/cache/config.php)" +echo "DEPLOY_ARTIFACT_MD5=$(md5of public/i/deploy/keep.txt)" + +echo "数据面整份清单(排序后的 md5 列表,逐字节比对):" +echo "-- INVENTORY_BEGIN --" +{ + md5sum .env installed.lock bootstrap/cache/config.php + find database storage/app public/i -type f -print0 | sort -z | xargs -0 -r md5sum +} +echo "-- INVENTORY_END --" +echo "INVENTORY_LINES=$({ md5sum .env installed.lock bootstrap/cache/config.php; find database storage/app public/i -type f; } | wc -l | tr -d ' ')" +SNAPEOF + +echo "== 播种后的数据面快照(升级前)==" +echo "-- DATA_SNAPSHOT_BEGIN --" +sh database/.upgrade-snapshot.sh +echo "-- DATA_SNAPSHOT_END --" +EOSH +sed -n '/-- DATA_SNAPSHOT_BEGIN --/,/-- DATA_SNAPSHOT_END --/p' "$SEED_OUT" | sed '1d;$d' > "$SNAP_BEFORE" +grep -vE '^(-- DATA_SNAPSHOT_|-- INVENTORY_)' "$SNAP_BEFORE" | sed 's/^/ /' +[ -s "$SNAP_BEFORE" ] || { echo "::error::升级前的数据面快照是空的(播种阶段没跑成)"; cat "$SEED_OUT"; exit 1; } + +# --------------------------------------------------------------------------- +echo "== 3) 换新镜像、挂同一个卷起(= 升级;旧容器此刻已经 rm 掉了)==" +docker run -d --name "$NEW_C" -v "$VOL":/var/www/html \ + -p "127.0.0.1:${HOST_PORT}:8089" -e WEB_PORT=8089 "$NEW_REF" >/dev/null + +# 等**同步分支**的终局行(tar 写完之后才 echo 这行);「先落文件再 grep」避开 pipefail+SIGPIPE 假红。 +new_ready=0 +for i in $(seq 1 60); do + docker logs "$NEW_C" > "$NEW_LOG" 2>&1 || true + if grep -q '已同步进卷' "$NEW_LOG"; then new_ready=1; break; fi + st=$(docker inspect -f '{{.State.Status}}' "$NEW_C" 2>/dev/null || echo unknown) + if [ "$st" != running ]; then + echo "::error::新镜像容器在同步完成前就退出了(status=$st)" + tail -40 "$NEW_LOG" || true + exit 1 + fi + sleep 2 +done +if [ "$new_ready" != 1 ]; then + echo "::error::新镜像 120 秒内没有走同步分支(应当打『检测到镜像代码版本变化,已同步进卷』)" + tail -40 "$NEW_LOG" || true + exit 1 +fi +grep '\[lsky\]' "$NEW_LOG" || true +grep -q "已同步进卷:fork_sha=$NEW_SHA" "$NEW_LOG" \ + || { echo "::error::同步的终局行里没有点名本次 commit(期望 fork_sha=$NEW_SHA)"; exit 1; } +if grep -q '跳过同步' "$NEW_LOG"; then + echo "::error::新镜像走了『跳过同步』分支(标记不一致时不该跳过)" + exit 1 +fi +echo "新镜像走了同步分支,终局行:$(grep -o '已同步进卷:.*' "$NEW_LOG" | head -1)" + +# 端到端:Apache 实际吐出的 /js/app.js 必须就是新镜像里那一份(不是磁盘上「看着对」) +if [ -z "$EXP_CTX" ]; then + echo "::warning::没有 EXP_CTX(补丁 JS 的期望 md5),跳过对外服务校验" +fi +served_ok=0 +for i in $(seq 1 60); do + served=$( { curl -s "http://127.0.0.1:${HOST_PORT}/js/app.js" || true; } | md5sum | awk '{print $1}') + if [ "$served" = "$NEW_APP_JS" ]; then served_ok=1; break; fi + sleep 2 +done +if [ "$served_ok" != 1 ]; then + echo "::error::Apache 吐出的 /js/app.js($served)不是新镜像里那份($NEW_APP_JS)" + tail -40 "$NEW_LOG" || true + exit 1 +fi +echo "端到端:GET /js/app.js = 新镜像里的 app.js($NEW_APP_JS)" +if [ -n "$EXP_CTX" ]; then + served_ctx=$( { curl -s "http://127.0.0.1:${HOST_PORT}/js/context-js/context-js.js" || true; } | md5sum | awk '{print $1}') + if [ "$served_ctx" != "$EXP_CTX" ]; then + echo "::error::Apache 吐出的补丁 JS($served_ctx)不是补丁版($EXP_CTX)" + exit 1 + fi + echo "端到端:GET /js/context-js/context-js.js = 补丁版($EXP_CTX)" +fi + +# --------------------------------------------------------------------------- +echo "== 4) 升级后:代码状态 + 数据面快照 ==" +docker run --rm -i -v "$VOL":/var/www/html --entrypoint sh "$NEW_REF" > "$AFTER_OUT" 2>&1 <<'EOSH' +set -eu +cd /var/www/html + +echo "代码状态:" +echo "MARKER_FORK_SHA=$(sed -n 's/^fork_sha=//p' .code-revision | head -1)" +echo "MARKER_LINES=$(wc -l < .code-revision | tr -d ' ')" +echo "APP_JS_MD5=$(md5sum public/js/app.js | awk '{print $1}')" +echo "IMAGESERVICE_MD5=$(md5sum app/Services/ImageService.php | awk '{print $1}')" +echo "HANDBOOK_LEFT=$(grep -c HANDBOOK_EDIT_MUST_BE_REPLACED app/Services/ImageService.php || true)" +echo "STALE_APP_JS_LEFT=$(grep -c STALE_APP_JS_MUST_BE_REPLACED public/js/app.js || true)" +echo "COMPILED_VIEWS=$(ls storage/framework/views/*.php 2>/dev/null | wc -l | tr -d ' ')" +echo "ENV_EXAMPLE-present=$([ -f .env.example ] && echo yes || echo no)" + +echo "-- DATA_SNAPSHOT_BEGIN --" +sh database/.upgrade-snapshot.sh +echo "-- DATA_SNAPSHOT_END --" +EOSH +sed -n '/-- DATA_SNAPSHOT_BEGIN --/,/-- DATA_SNAPSHOT_END --/p' "$AFTER_OUT" | sed '1d;$d' > "$SNAP_AFTER" +sed -n '1,/-- DATA_SNAPSHOT_BEGIN --/p' "$AFTER_OUT" | sed '$d' | grep -v '^$' | sed 's/^/ /' +[ -s "$SNAP_AFTER" ] || { echo "::error::升级后的数据面快照是空的"; cat "$AFTER_OUT"; exit 1; } + +# --- 断言① 代码确实换成了新镜像那一版 ------------------------------------- +key() { sed -n "s/^$2=//p" "$1" | head -1; } +assert_eq() { # $1=文件 $2=key $3=期望 $4=说明 + local actual + actual=$(key "$1" "$2") + if [ "$actual" != "$3" ]; then + echo "::error::$4($2:实测 '$actual',期望 '$3')" + cat "$1" + exit 1 + fi + echo " ✓ $4($2=$actual)" +} +assert_eq "$AFTER_OUT" MARKER_FORK_SHA "$NEW_SHA" "卷里的 .code-revision 已更新成本次 commit 的 fork_sha" +assert_eq "$AFTER_OUT" APP_JS_MD5 "$NEW_APP_JS" "卷里的 public/js/app.js 已是新镜像那一份" +assert_eq "$AFTER_OUT" IMAGESERVICE_MD5 "$NEW_IMGSVC" "卷里的 app/Services/ImageService.php 已被新镜像覆盖(升级前是手工修补版)" +assert_eq "$AFTER_OUT" HANDBOOK_LEFT "0" "旧的手工修补没有残留" +assert_eq "$AFTER_OUT" STALE_APP_JS_LEFT "0" "旧的脏 app.js 没有残留" +assert_eq "$AFTER_OUT" COMPILED_VIEWS "0" "编译视图缓存已作废(blade 改动才会生效)" + +# --- 断言② 数据面逐文件 md5 不变 ------------------------------------------ +echo "数据面逐项比对(升级前 → 升级后):" +DATA_KEYS="DB_ROWS DB_FINGERPRINT DB_FIRST_USER DB_FILE_MD5 DB_FILE_BYTES \ + ENV_MD5 ENV_LINES ENV_BYTES ENV_SENTINEL_HITS ENV_UTF8_HITS \ + INSTALLED_LOCK_MD5 PNG_MD5 PNG_MAGIC CONFIG_CACHE_MD5 DEPLOY_ARTIFACT_MD5 \ + INVENTORY_LINES" +for k in $DATA_KEYS; do + before=$(key "$SNAP_BEFORE" "$k") + after=$(key "$SNAP_AFTER" "$k") + if [ -z "$before" ] || [ -z "$after" ]; then + echo "::error::快照里缺少 $k(before='$before' after='$after')—— 快照脚本被改坏?" + exit 1 + fi + if [ "$before" != "$after" ]; then + echo "::error::升级改了数据面:$k $before → $after" + exit 1 + fi + printf ' ✓ %-20s %s\n' "$k" "$before" +done +# 几个关键值顺手做一个「真值」检查,避免快照脚本写错却两边一致(假绿的经典形态) +[ "$(key "$SNAP_AFTER" DB_ROWS)" = "21" ] || { echo "::error::SQLite 里的行数不是 21(播种没写进去?)"; exit 1; } +[ "$(key "$SNAP_AFTER" ENV_UTF8_HITS)" = "1" ] || { echo "::error::.env 里的 UTF-8 行不见了"; exit 1; } +[ "$(key "$SNAP_AFTER" PNG_MAGIC)" = "89504e470d0a1a0a" ] || { echo "::error::缩略图不再是 PNG"; exit 1; } + +# --- 断言③ 整个数据面清单 diff 必须为空 ------------------------------------ +if ! diff -u "$SNAP_BEFORE" "$SNAP_AFTER" > "$LOGDIR/snapshot.diff" 2>&1; then + echo "::error::升级前后数据面快照不一致(含整份 md5 清单)" + cat "$LOGDIR/snapshot.diff" + exit 1 +fi +echo " ✓ 数据面快照(含整份 md5 清单)逐字节相同" + +echo "== 5) 清理 ==" +cleanup + +echo "真升级用例通过:$OLD_REF → $NEW_REF;数据面逐字节不变,代码已换成新镜像那一版" + +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "## 真升级用例(旧镜像 → 新镜像,数据逐字节不变)" + echo "" + echo '```' + echo "升级基线 $OLD_REF(fork_sha=$OLD_SHA)" + echo "新镜像 $NEW_REF(fork_sha=$NEW_SHA)" + echo "" + echo "== 升级后的代码状态 ==" + sed -n '1,/-- DATA_SNAPSHOT_BEGIN --/p' "$AFTER_OUT" | sed '$d' | grep -v '^$' + echo "" + echo "== 数据面快照(升级前后 diff 为空)==" + grep -vE '^(-- DATA_SNAPSHOT_|-- INVENTORY_)' "$SNAP_AFTER" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" +fi