ci: 真升级用例 + retag digest 校验 + OSV 依赖门禁 + 修试构建 digest 冲突
- 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 红;抽掉白名单实测确实会红。
This commit is contained in:
1 parent
5865d04816
commit
235f72e68c
4 files changed
+782
-10
No files matched your search
@@ -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-<commit>)。
|
||||
# 为什么用它、不用 :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: <reason>」,便于核对到底滤掉了什么。
|
||||
# · 结果同时进 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:
|
||||
|
||||
@@ -6,7 +6,9 @@ name: Retag (repoint a tag to an existing image)
|
||||
# 安全闸:
|
||||
# · 不允许把 `latest` 当目标(线上拉取目标只能由 build-image 的 Promote 步骤动)
|
||||
# · 源必须是不可变 tag(`sha-<commit>`),避免把某个浮动 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"
|
||||
@@ -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>」。
|
||||
#
|
||||
# 维护规则:
|
||||
# · 只白名单「我们明确接受」的告警,每条都要写清 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 依赖,不进镜像,同线可修"
|
||||
Executable
+463
@@ -0,0 +1,463 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================================
|
||||
# B6 真升级用例:「旧镜像 → 新镜像」在**同一个卷**上做一次真实升级,断言站点数据逐字节不变。
|
||||
#
|
||||
# 为什么需要它:CI 里的 A/B/C 三段是拿「手工改坏的标记 / 手工改坏的代码」在**同一次构建**的
|
||||
# 镜像里模拟升级 —— 只能证明 entrypoint 的分支逻辑对,证明不了「从已发布的旧版镜像升上来
|
||||
# 会怎样」。本用例先用**已发布的旧镜像**把一个卷养成真实站点(真实代码树 + 真实数据布局),
|
||||
# 再换上本次构建的新镜像挂同一个卷起,是端到端的真升级。
|
||||
#
|
||||
# 关键设计(每条都有理由,改之前先读):
|
||||
# 1) 升级基线用**不可变 tag**(`UPGRADE_FROM_TAG` = sha-<commit>),不用 :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 <repo> 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-<commit>> [UPGRADE_FROM_TAG=sha-<commit>] $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' '<?php return ["app" => ["name" => "SENTINEL_CONFIG_CACHE"]];' > bootstrap/cache/config.php
|
||||
: > installed.lock
|
||||
|
||||
# ⑥ 纯缓存(按设计会被作废):编译后的视图 —— 用它反证「同步分支真跑过」
|
||||
mkdir -p storage/framework/views
|
||||
echo '<?php // STALE_COMPILED_VIEW' > storage/framework/views/deadbeef.php
|
||||
|
||||
# ⑦ 代码侧哨兵:升级必须被新镜像那一版覆盖(证明代码确实换源了,而不是「恰好没变」)
|
||||
echo '<?php // HANDBOOK_EDIT_MUST_BE_REPLACED' > app/Services/ImageService.php
|
||||
echo '// STALE_APP_JS_MUST_BE_REPLACED' > public/js/app.js
|
||||
|
||||
# ⑧ 两个探针写在 ./database 下(同步排除清单里的数据目录 → 升级后还在):
|
||||
# 于是一个阶段跑的是**同一份**快照/探针定义,不存在「测试代码抄两份会漂移」的问题。
|
||||
cat > database/.upgrade-probe.php <<'PHPEOF'
|
||||
<?php
|
||||
// 逻辑级探针:用当前镜像的 PHP + PDO 真的打开卷里的 SQLite,读出行指纹。
|
||||
$p = '/var/www/html/database/database.sqlite';
|
||||
$db = new PDO('sqlite:' . $p);
|
||||
$db->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
|
||||
Reference in new issue
Block a user