老师 2026-10-03 定:所有版本号逻辑去掉 v 前缀。 · src/config/app.php: 'v3.5.1' → '3.5.1' · build-image.yaml / backfill-version-tag.yaml 的格式守卫:^v[0-9]+… → ^[0-9]+\.[0-9]+\.[0-9]+$ · 新增 delete-image-tag.yaml:手动删 tag 入口(本机 token 无 packages 权限,只能走 CI) 镜像 tag 由 app.php 的 version 单一事实来源自动跟随 ⇒ 一处改、两处生效。 GHCR 上旧的 v3.5.0 / v3.5.1 用该入口删除,此后版本号只保留 X.Y.Z 形式。
1044 lines
66 KiB
YAML
1044 lines
66 KiB
YAML
name: Build and push image to GHCR
|
||
|
||
# 上游 workflow 是「每天定时拉 master 重新构建并推到 DockerHub」,本 fork 改成:
|
||
# - 只在 master 有推送时构建(源码已钉死,定时重建没有意义)
|
||
# - 推到 GHCR,用仓库自带的 GITHUB_TOKEN,不需要任何 secret
|
||
# - 只构建 linux/amd64(部署机是 x86_64,arm64 白烧一半时间)
|
||
# - 构建前先跑 test/ 里的 jsdom 行为测试(iOS 长按 / 菜单收放 / 触摸端二级菜单)
|
||
# - 先推到不可变的 sha tag → 真机自证 → 自证通过才把 latest 指过去
|
||
# 这样自证失败时 latest 仍停在上一个已知可用的镜像,线上拉到的不会是坏版本
|
||
on:
|
||
push:
|
||
branches:
|
||
- master
|
||
# 测试版分支(老师用来先把改动放上分支验证,master 保持 = 线上稳定版):
|
||
# 分支构建**只产出不可变的 sha-<commit> 镜像**,绝不动 :latest ——
|
||
# 由下面的「Promote verified image to latest」步骤用 `if: … github.ref == 'refs/heads/master'`
|
||
# 把关(否则分支一推就把线上在用的 latest 顶成测试版)。
|
||
# 名字就叫 test(2026-10-01 由 test/memory-tuning 改名,避免被当成"测试套件目录"式命名);
|
||
# 同时保留 test/* 模式:历史分支名与以后带后缀的测试分支仍能触发构建。
|
||
- test
|
||
- 'test/*'
|
||
# 只跳过「纯文档改动」的推送(README / LICENSE / .gitignore 这类:不进镜像、也不被构建或自证读取的文件)。
|
||
#
|
||
# 语义(GitHub 官方):**只有本次推送的所有改动文件都命中下面列表时**才跳过构建;
|
||
# 只要碰到任何一个代码文件,照旧完整构建。所以这是 fail-open 设计 —— 拿不准时一定构建,不会漏掉该有的构建。
|
||
#
|
||
# 背景(2026-10-02 加):此前任何 push 都会跑完整构建:约 10 分钟 runner + 一个约 1GB 的新镜像版本
|
||
# (GHCR 版本就是这么堆到 100+ 个的),改个 README 也会把 :latest 顶成新 manifest ——
|
||
# 而镜像其实只 COPY src/ + 3 个 *.template + entrypoint.sh,README 根本不进镜像,应用内容一字未变
|
||
# (digest 不同只是因为构建把 commit SHA 烤进了 build-arg / 标签)。
|
||
#
|
||
# ⚠️ 不要往这里加(都是真构建输入,加了会漏构建):
|
||
# src/**、Dockerfile、.dockerignore、*.template、entrypoint.sh、
|
||
# .github/workflows/**、test/**(构建前跑 jsdom 行为测试)、tools/**(升级自证)、
|
||
# osv-scanner.toml(依赖门禁白名单)、patches/**(偏差清单,自证引用)。
|
||
paths-ignore:
|
||
- '**.md'
|
||
- 'LICENSE'
|
||
- '.gitignore'
|
||
# 只用于「试构建」:临时换 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:
|
||
description: '试构建用的 PHP 版本(如 8.2 / 8.3;留空则用 Dockerfile 里的值)'
|
||
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 的一定是最新那次提交的产物。
|
||
concurrency:
|
||
group: ${{ github.workflow }}-${{ github.ref }}
|
||
cancel-in-progress: false
|
||
|
||
env:
|
||
TZ: Asia/Shanghai
|
||
IMAGE: ghcr.io/mole404/lsky-pro-docker
|
||
# 注意:上游 pin 与 PHP 版本**不在这里写死**,由「Resolve pinned values from Dockerfile」
|
||
# 从 Dockerfile 解析(单一事实来源)。
|
||
# 原因(真事故):这里曾硬编码 911275c,Dockerfile 升到 38d52c46 后它没跟着改,
|
||
# 于是新镜像被贴上了「911275c」这个 represent-旧快照的 tag → 用那个 tag 回滚会拿到新代码。
|
||
# 补丁文件(context-js.js / images.blade.php)的期望 md5 **不在这里写死**,
|
||
# 由下方校验步骤从 Dockerfile 的 .code-revision 生成段解析(单一事实来源)。
|
||
# 原因(同类事故第二次):这里曾硬编码 f4e100c8,界面改造改了 blade 之后没跟着改,
|
||
# 于是镜像明明是对的(构建期 md5sum -c 已断言过),却被这一步判成失败。
|
||
# 上游快照里的数据库迁移文件个数。换 pin 时若这个数变了,说明有 schema 变更,
|
||
# 而 entrypoint 不会自动跑 migrate —— 那种情况必须人工处理,所以在 CI 里钉住提醒。
|
||
# 12 = 上游 pin 快照自带的 10 + 本 fork 标签功能新增的 2 个(2026-10-01:
|
||
# 2026_10_01_000001_create_tags_table / _000002_create_image_tag_table,都是新增表、不动原表)。
|
||
# 这两条同样要人工 migrate —— 线上升级流程里「起新镜像后立刻 php artisan migrate --force」。
|
||
UPSTREAM_MIGRATION_COUNT: "12"
|
||
# 「真升级用例」(tools/upgrade-test.sh)的升级基线:**不再写死**,由脚本向 GHCR 匿名 API
|
||
# 现查「最新的 sha-<commit> tag,且 ≠ 本次构建的 commit」当基线。
|
||
# 为什么改(原来的写法确实会红):这里曾硬编码 sha-cd14a3b9…,那个 tag 一旦从 GHCR 消失
|
||
# (或被删),这一步就红 —— 而红的理由与本次改动毫无关系(纯假红,还挡住发布)。
|
||
# 为什么不用 :latest / 不选浮动 tag:immutable tag 永不变动、公开可拉,是唯一可复现的
|
||
# 「旧版本」;latest 会随每次 promote 漂移(甚至可能正好是本次构建的同 commit 产物 —— 那就
|
||
# 成了「自己升级自己」)。解析出的 tag 仍会被脚本用它自带的 .code-revision 反核(互相印证),
|
||
# 谁把 tag 挪到别的版本上都会当场报错(本仓库真的贴错过 tag)。
|
||
# 覆盖手段:给仓库变量 vars.UPGRADE_FROM_TAG 设一个 sha-<commit>(脚本里环境变量优先),
|
||
# 仅用于「GHCR 匿名 API 挂了又要立刻出包」这种应急场景。
|
||
# 解析失败时脚本**明确报错**、不会静默跳过(一次没跑成的升级验证等于没验证)。
|
||
|
||
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
|
||
packages: write
|
||
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||
|
||
# 上游 pin 与 PHP 版本从 Dockerfile 解析 —— 单一事实来源,杜绝 workflow/Dockerfile 漂移
|
||
- name: Resolve pinned values from Dockerfile
|
||
id: pin
|
||
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 变更
|
||
N=$(ls src/database/migrations/*.php 2>/dev/null | wc -l | tr -d ' ')
|
||
echo "上游迁移文件数 = $N(基线 ${UPSTREAM_MIGRATION_COUNT})"
|
||
if [ "$N" != "${UPSTREAM_MIGRATION_COUNT}" ]; then
|
||
echo "::error::上游迁移文件数从 ${UPSTREAM_MIGRATION_COUNT} 变成 $N —— entrypoint 不会自动 migrate,必须人工处理"
|
||
exit 1
|
||
fi
|
||
|
||
# ⚠ 必须自带 Node:ubuntu-latest 预装的是 Node 18/20,而 test/ 的 jsdom 30 在 engines 里
|
||
# 要求 ^22.22.2 || ^24.15.0 || >=26。版本不够时 `npm test` 不是「用例失败」,是直接
|
||
# ERR_REQUIRE_ESM 跑不起来(整条流水线红得毫无信息量)。这里显式装 Node 22 线
|
||
# (22 的最新补丁版已 ≥ 22.22;下面再打一行 node --version 留证)。
|
||
- name: Set up Node.js
|
||
# 钉到 actions/setup-node v7.0.0 的 commit SHA(不是可移动的 tag):
|
||
# gh api repos/actions/setup-node/releases/latest --jq .tag_name # → v7.0.0
|
||
# gh api repos/actions/setup-node/git/ref/tags/v7.0.0 --jq .object.sha
|
||
# 为什么必须换(Node 20 弃用警告):旧的 49933ea…(v4)action.yml 里是
|
||
# runs.using: node20 → runner 每次都会打 "Node.js 20 is deprecated"。
|
||
# v7.0.0 的 action.yml 是 runs.using: node24(该 release 的主旨就是迁移到新
|
||
# runtime),警告消失。node-version / cache / cache-dependency-path 三个输入
|
||
# 在 v7 里名字与语义都没变(已核 action.yml),with: 段无需改动。
|
||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||
with:
|
||
node-version: '22'
|
||
cache: npm
|
||
cache-dependency-path: test/package-lock.json
|
||
|
||
# 补丁的行为测试(jsdom + jQuery)。镜像里那份 context-js.js 就是 src/ 里的补丁版本,
|
||
# 所以这里跑绿 = iOS 长按 / 菜单收放 / 触摸端二级菜单的每条分支都过了一遍。
|
||
# 放在构建之前:补丁写错了就早点红,不用白等十几分钟构建。
|
||
# 依赖全部在 test/package.json 里声明(含 sweetalert2 / less —— swal-theme 那条用例要用),
|
||
# 所以新拉的 checkout 里 `npm ci` 之后就能跑,不依赖 src/node_modules。
|
||
- name: Behavior tests (jsdom)
|
||
run: |
|
||
set -euo pipefail
|
||
node --version # 留证:jsdom 30 要求 ≥ 22.22
|
||
cd test
|
||
npm ci --no-audit --no-fund
|
||
npm test
|
||
|
||
# 说明(本次「Node 20 弃用」清扫的覆盖面):下面 docker/* 系列与上面的 actions/checkout
|
||
# 都核对过 —— 它们钉的 commit SHA 就是各自**最新 release** 指向的 commit,且 action.yml 里
|
||
# 是 runs.using: node24,不会再触发 Node.js 20 弃用警告,故保持不变(不为动而动)。
|
||
# 核验命令(对每个 repo):gh api repos/<repo>/releases/latest --jq .tag_name →
|
||
# gh api repos/<repo>/git/ref/tags/<tag> --jq .object.sha(annotated tag 再 deref 一层)
|
||
# → 用该 SHA 取 action.yml 看 runs.using。本次据此只换了 setup-node(见上)。
|
||
- name: Set up QEMU
|
||
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4
|
||
|
||
- name: Set up Docker Buildx
|
||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||
|
||
- name: Login to GitHub Container Registry
|
||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||
with:
|
||
registry: ghcr.io
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
# 只推不可变 tag;latest 由最后的 Promote 步骤在自证通过后才指过来
|
||
# 只构建 amd64:老师这台 VPS 是 x86_64,arm64 白烧一半构建时间(2026-09-28 定)。
|
||
# 以后真要 arm64,把 platforms 改回 linux/amd64,linux/arm64 即可,其余步骤不用动。
|
||
- name: Build and push (linux/amd64)
|
||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||
with:
|
||
context: .
|
||
file: ./Dockerfile
|
||
platforms: linux/amd64
|
||
push: true
|
||
tags: |
|
||
${{ env.IMAGE }}:sha-${{ github.sha }}
|
||
# 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
|
||
|
||
# ------------------------------------------------------------------
|
||
# 自证:不只看「构建成功」,而是把推上去的镜像拉回来实测
|
||
# 1. 核对补丁文件 md5(确认 src/ 里的补丁真的进了镜像)
|
||
# 2. 记录 PHP / 扩展 / Debian / ImageMagick 版本(换基础镜像时的对比基线)
|
||
# 3. 扩展自证(**只把 pdo_sqlite 当硬断言** —— 缺了线上 SQLite 站点直接挂;
|
||
# sqlite3 / imagick / redis / pdo_mysql / bcmath / ftp 只打印 =0/1 供人工比对,
|
||
# 不因缺失而判红:它们对本站不是必需,钉死会在合理变动时误报)
|
||
# 4. 真起一个容器 curl 首页,确认 Apache + PHP + 应用真的能跑
|
||
# 注意:本段整体被外层单引号包着(sh -c '...'),里面只能出现双引号,
|
||
# 写单引号会把脚本提前截断 → syntax error near unexpected token `('
|
||
# ------------------------------------------------------------------
|
||
- name: Verify published image
|
||
env:
|
||
IMAGE_REF: ${{ env.IMAGE }}:sha-${{ github.sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
docker pull "$IMAGE_REF"
|
||
|
||
REPORT=$(docker run --rm --entrypoint sh "$IMAGE_REF" -c '
|
||
set -e
|
||
echo "== /etc/os-release =="
|
||
grep -E "^(PRETTY_NAME|VERSION_ID)=" /etc/os-release
|
||
echo "== php -v(含启动警告)=="
|
||
php -v 2>&1 | head -3
|
||
echo "== php -m =="
|
||
php -m | sort | tr "\n" " "; echo
|
||
echo "== 扩展快照(=1 已装 / =0 缺失;下方只硬断言 pdo_sqlite)=="
|
||
# ftp 是 2026-09-28 换 PHP 8.3 时踩到的:官方镜像不再自带 ftp,必须显式装 + 断言
|
||
for ext in pdo_sqlite sqlite3 imagick redis pdo_mysql bcmath ftp; do
|
||
if php -m | grep -qix "$ext"; then echo "$ext=1"; else echo "$ext=0"; fi
|
||
done
|
||
echo "== opcache 加载情况排查 =="
|
||
echo -n "php -m 里匹配 opcache 的行数:"
|
||
php -m | grep -ci opcache || true
|
||
echo "-- conf.d 下的 ini --"
|
||
ls /usr/local/etc/php/conf.d/
|
||
echo "-- 含 zend_extension 的 ini 内容 --"
|
||
grep -rn zend_extension /usr/local/etc/php/conf.d/ || true
|
||
echo "-- php -m 里段头与 opcache 的行号(判断是重复加载还是分段显示)--"
|
||
php -m | grep -n -e "^\[" -e opcache || true
|
||
echo "-- php --ini --"
|
||
php --ini
|
||
echo "== apache2 -v =="
|
||
apache2 -v | head -1
|
||
echo "== imagick =="
|
||
php -r "print_r(Imagick::getVersion());" 2>/dev/null | tr -d "\n" | head -c 300; echo
|
||
echo "== 长按阈值 =="
|
||
grep -n "LONG_PRESS_DELAY = " /var/www/lsky/public/js/context-js/context-js.js
|
||
echo "== 菜单交互补丁(标记行)=="
|
||
grep -n -e "MENU_OPEN_GRACE = " -e "installMenuGuard()" -e "touch-open" -e "isMenuOpen" \
|
||
-e "MENU_OPEN_IGNORE_INPUT" -e "function menuOnScreen" -e "debugDump" \
|
||
/var/www/lsky/public/js/context-js/context-js.js | head -20
|
||
echo "== 补丁文件 md5 =="
|
||
md5sum /var/www/lsky/public/js/context-js/context-js.js
|
||
md5sum /var/www/lsky/resources/js/context-js.js
|
||
md5sum /var/www/lsky/resources/views/user/images.blade.php
|
||
echo "== 应用入口 =="
|
||
ls -l /var/www/lsky/public/index.php
|
||
echo "== 代码版本标记 .code-revision =="
|
||
cat /var/www/lsky/.code-revision
|
||
echo "== PHP 版本 =="
|
||
php -v | head -1
|
||
echo "== 已装扩展(整份 dump,供人工比对 lock 的 ext-* 要求)=="
|
||
php -m | sort | paste -sd, -
|
||
')
|
||
echo "$REPORT"
|
||
|
||
# 期望值从 Dockerfile 解析(单一事实来源;构建期已用 md5sum -c 断言过这些常量)
|
||
EXP_CTX=$(grep -oE 'context_js_md5=[0-9a-f]{32}' Dockerfile | head -1 | cut -d= -f2)
|
||
EXP_BLADE=$(grep -oE 'images_blade_md5=[0-9a-f]{32}' Dockerfile | head -1 | cut -d= -f2)
|
||
if [ -z "$EXP_CTX" ] || [ -z "$EXP_BLADE" ]; then
|
||
echo "::error::无法从 Dockerfile 解析补丁 md5(.code-revision 生成段里的 *_md5= 常量)"
|
||
exit 1
|
||
fi
|
||
echo "期望值(取自 Dockerfile):context-js=$EXP_CTX blade=$EXP_BLADE"
|
||
# 后面还有其它步骤(同步/数据安全校验)也要用这两个值 —— 导出到 GITHUB_ENV,
|
||
# 避免"删了 env 常量但某个步骤还在引用"这种漏改(本次就是这么红的)
|
||
echo "EXP_CTX=$EXP_CTX" >> "$GITHUB_ENV"
|
||
echo "EXP_BLADE=$EXP_BLADE" >> "$GITHUB_ENV"
|
||
|
||
ACTUAL=$(echo "$REPORT" | awk '/context-js\/context-js.js$/ {print $1}')
|
||
if [ "$ACTUAL" != "$EXP_CTX" ]; then
|
||
echo "::error::镜像里的 context-js.js($ACTUAL)与预期补丁($EXP_CTX)不一致"
|
||
exit 1
|
||
fi
|
||
echo "补丁文件 md5 校验通过:$ACTUAL"
|
||
|
||
# blade 也要核:它决定浏览器拿到的资源版本串,漏了就白改
|
||
BLADE_ACTUAL=$(echo "$REPORT" | awk '/user\/images.blade.php$/ {print $1}')
|
||
if [ "$BLADE_ACTUAL" != "$EXP_BLADE" ]; then
|
||
echo "::error::镜像里的 images.blade.php($BLADE_ACTUAL)与预期($EXP_BLADE)不一致"
|
||
exit 1
|
||
fi
|
||
echo "blade md5 校验通过:$BLADE_ACTUAL"
|
||
|
||
# 长按阈值必须是 250ms(老师要求 500 -> 250)
|
||
if ! echo "$REPORT" | grep -q 'LONG_PRESS_DELAY = 250'; then
|
||
echo "::error::补丁里的长按阈值不是 250ms"
|
||
exit 1
|
||
fi
|
||
echo "长按阈值 250ms 校验通过"
|
||
|
||
# 菜单交互修复必须在镜像里(点菜单外只关菜单、触摸端二级菜单、滚动/缩放/Esc 关闭)
|
||
for marker in 'installMenuGuard' 'MENU_OPEN_GRACE' 'touch-open' 'isMenuOpen' \
|
||
'MENU_OPEN_IGNORE_INPUT' 'menuOnScreen' 'debugDump'; do
|
||
if ! echo "$REPORT" | grep -q "$marker"; then
|
||
echo "::error::镜像里的补丁缺少菜单交互修复标记:$marker"
|
||
exit 1
|
||
fi
|
||
done
|
||
echo "菜单交互修复标记校验通过(installMenuGuard / MENU_OPEN_GRACE / touch-open / isMenuOpen / MENU_OPEN_IGNORE_INPUT / menuOnScreen / debugDump)"
|
||
|
||
# 线上是 SQLite 数据库,扩展缺失会让站点直接挂,钉死这条断言
|
||
if ! echo "$REPORT" | grep -q pdo_sqlite=1; then
|
||
echo "::error::新镜像里 pdo_sqlite 没加载(线上是 SQLite 数据库,不能丢)"
|
||
exit 1
|
||
fi
|
||
echo "关键扩展断言通过(硬断言只有 pdo_sqlite;快照里的 sqlite3/imagick/redis/pdo_mysql/bcmath/ftp 仅打印 =0/1 供人工比对,不判红)"
|
||
|
||
# 镜像里的上游 pin 必须等于 Dockerfile 里的 pin(防止再次出现"标记漂移")
|
||
MARKER_PIN=$(echo "$REPORT" | awk -F= '/^lsky_commit=/ {print $2}')
|
||
if [ "$MARKER_PIN" != "${{ steps.pin.outputs.pin }}" ]; then
|
||
echo "::error::镜像里的 lsky_commit($MARKER_PIN)与 Dockerfile 的 pin(${{ steps.pin.outputs.pin }})不一致"
|
||
exit 1
|
||
fi
|
||
echo "上游 pin 校验通过:$MARKER_PIN"
|
||
|
||
# PHP 版本必须是本次请求的那个(build-arg 真的生效了)
|
||
# 注意:报告里不止一行以 "PHP " 开头 → 必须 head -1,否则变量会变成 "8.1\n8.1"
|
||
PHP_ACTUAL=$(echo "$REPORT" | sed -n 's/^PHP \([0-9]\{1,2\}\.[0-9]\{1,2\}\).*/\1/p' | head -1)
|
||
if [ "$PHP_ACTUAL" != "${{ steps.pin.outputs.php_version }}" ]; then
|
||
echo "::error::镜像里的 PHP 版本($PHP_ACTUAL)与本次请求的(${{ steps.pin.outputs.php_version }})不一致"
|
||
exit 1
|
||
fi
|
||
echo "PHP 版本校验通过:$PHP_ACTUAL"
|
||
|
||
# 真起一次容器:默认 8089 端口,入口脚本会把应用播种到 /var/www/html
|
||
docker run -d --name lsky-smoke -p 18089:8089 -e WEB_PORT=8089 "$IMAGE_REF"
|
||
CODE=000
|
||
for i in $(seq 1 30); do
|
||
CODE=$(curl -s -L -o /tmp/install.html -w '%{http_code}' http://127.0.0.1:18089/install || true)
|
||
[ "$CODE" = "200" ] && break
|
||
sleep 2
|
||
done
|
||
echo "安装页 HTTP 状态:$CODE"
|
||
if [ "$CODE" != "200" ]; then
|
||
docker logs lsky-smoke || true
|
||
echo "::error::容器起来了但安装页返回 $CODE"
|
||
exit 1
|
||
fi
|
||
curl -s -o /tmp/root.html -w 'GET / → %{http_code}\n' -L http://127.0.0.1:18089/
|
||
|
||
# 路由与静态目录撞名的回归防护(曾把默认头像放进 public/images/ 撞上 /images 路由:
|
||
# Apache 目录重定向 301 → /images/ 无 index → 403 → 首页 302→/images 整站打不开)
|
||
IMG_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18089/images || true)
|
||
IMG_FINAL=$(curl -s -o /dev/null -w '%{http_code}' -L http://127.0.0.1:18089/images || true)
|
||
AVATAR=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18089/static/default-avatar.svg || true)
|
||
echo "GET /images → 首跳 $IMG_CODE / 跟随重定向后 $IMG_FINAL ;默认头像 → $AVATAR"
|
||
if [ "$IMG_CODE" = "301" ]; then
|
||
echo "::error::/images 返回 301 —— public/ 下有与路由同名的真实目录,Apache 目录重定向会把页面打挂"
|
||
exit 1
|
||
fi
|
||
if [ "$AVATAR" != "200" ]; then
|
||
echo "::error::默认头像资源 /static/default-avatar.svg 返回 $AVATAR(应为 200)"
|
||
exit 1
|
||
fi
|
||
# 注:不做「跟随重定向必须 200」的断言 —— 未安装状态下 /images 会 302 到需要配置/登录的
|
||
# 下游页,那里可能 500(预置空库,非本次回归)。真正的回归是首跳 301,上面已钉住。
|
||
if [ "$IMG_FINAL" = "500" ]; then
|
||
echo "::warning::/images 跟随重定向后 500(未安装状态的下游页,非回归);dump 容器日志供参考"
|
||
docker logs lsky-smoke 2>&1 | tail -20 || true
|
||
fi
|
||
|
||
# F23:响应头加固自证 —— 验的是**真 Apache 吐出来的头**,不是看配置文件写了没写。
|
||
# ① 不许出现 X-Powered-By(PHP 版本号外泄;镜像里 zz-lsky-hardening.ini 关掉 expose_php)
|
||
# ② Server 头不许带具体版本号(镜像里 conf-enabled/zz-lsky-hardening.conf 设 ServerTokens Prod)
|
||
# 先把响应头落成文件再 grep —— 不写 `curl … | grep -q`:命中即退会 SIGPIPE(见上面的坑)。
|
||
curl -sS -D /tmp/hdr.txt -o /dev/null http://127.0.0.1:18089/
|
||
echo "== 响应头(GET /,即浏览器真正收到的头)=="
|
||
cat /tmp/hdr.txt
|
||
if [ ! -s /tmp/hdr.txt ]; then
|
||
echo "::error::拿不到响应头(/tmp/hdr.txt 是空的),响应头加固无法自证"
|
||
exit 1
|
||
fi
|
||
if grep -qi '^X-Powered-By:' /tmp/hdr.txt; then
|
||
echo "::error::响应头里仍有 X-Powered-By(PHP 版本号外泄)—— 检查 zz-lsky-hardening.ini 的 expose_php=0"
|
||
exit 1
|
||
fi
|
||
SRV_HDR=$(grep -i '^Server:' /tmp/hdr.txt | head -1 || true)
|
||
echo "Server 头:$SRV_HDR"
|
||
if [ -z "$SRV_HDR" ]; then
|
||
echo "::error::响应头里没有 Server 头,响应头加固无法自证(容器没起来或头被吞了)"
|
||
exit 1
|
||
fi
|
||
if grep -Eq '[0-9]+\.[0-9]+' <<< "$SRV_HDR"; then
|
||
echo "::error::Server 头泄露了具体版本号:$SRV_HDR(期望是 Server: Apache)"
|
||
exit 1
|
||
fi
|
||
echo "响应头加固自证通过:无 X-Powered-By,Server 头不含版本号"
|
||
|
||
# 端到端:验 Apache 真正吐给浏览器的 JS(不是磁盘上的文件)
|
||
SERVED_JS=$(curl -s http://127.0.0.1:18089/js/context-js/context-js.js | md5sum | awk '{print $1}')
|
||
echo "Apache 对外吐出的 context-js.js md5:$SERVED_JS"
|
||
if [ "$SERVED_JS" != "$EXP_CTX" ]; then
|
||
echo "::error::Apache 吐出的 JS($SERVED_JS)不是补丁版($EXP_CTX)"
|
||
exit 1
|
||
fi
|
||
docker exec lsky-smoke php -r 'echo "artisan 可用:"; echo file_exists("/var/www/html/artisan") ? "yes" : "no"; echo PHP_EOL;'
|
||
echo "== 播种到卷里的补丁文件(部署后真正被 Apache 读的那份)=="
|
||
docker exec lsky-smoke md5sum /var/www/html/public/js/context-js/context-js.js
|
||
|
||
# 测试版内存收紧自证(2026-10-01):
|
||
# ① PHP 生效的 memory_limit 必须是 64M —— 真读回 ini_get(),不是看配置文件写了没写
|
||
# (CLI 与 Apache 模块共用同一份 conf.d,entrypoint 在启动时把 PHP_MEMORY_LIMIT 渲染进去);
|
||
# ② MaxConnectionsPerChild 默认必须是 5 —— 真读回容器里被 entrypoint 渲染出的 mpm.conf。
|
||
# 都按本仓规矩「先落文件再比较」—— 不写 `… | grep -q`:命中即退会 SIGPIPE(141),
|
||
# 上面带了 `set -o pipefail` 会把「命中」误判成失败(本仓踩过这个坑)。
|
||
docker exec lsky-smoke sh -c 'php -r "echo ini_get(\"memory_limit\");"' > /tmp/php-memlimit.txt 2>&1 || true
|
||
echo "容器里 memory_limit 生效值:$(cat /tmp/php-memlimit.txt 2>/dev/null)"
|
||
if [ "$(cat /tmp/php-memlimit.txt 2>/dev/null)" != "64M" ]; then
|
||
echo "::error::PHP memory_limit 不是 64M(读到:$(cat /tmp/php-memlimit.txt 2>/dev/null))—— 检查 zz-lsky-hardening.ini 与 entrypoint 的 PHP_MEMORY_LIMIT 渲染"
|
||
exit 1
|
||
fi
|
||
docker exec lsky-smoke cat /etc/apache2/conf-enabled/mpm.conf > /tmp/smoke-mpm.txt 2>&1 || true
|
||
echo "== 容器里渲染出的 mpm.conf =="
|
||
cat /tmp/smoke-mpm.txt 2>/dev/null || true
|
||
if ! grep -qx 'MaxConnectionsPerChild 5' /tmp/smoke-mpm.txt; then
|
||
echo "::error::MaxConnectionsPerChild 默认不是 5(容器 mpm.conf 里没有这一行)"
|
||
exit 1
|
||
fi
|
||
echo "测试版内存收紧自证通过:memory_limit=64M,MaxConnectionsPerChild=5"
|
||
docker rm -f lsky-smoke
|
||
|
||
{
|
||
echo "## 镜像自证(linux/amd64)"
|
||
echo ""
|
||
echo '```'
|
||
echo "$REPORT"
|
||
echo "安装页 HTTP:$CODE"
|
||
echo '```'
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Apache MPM 自证(真容器里跑)
|
||
# 镜像内置低配默认值 + APACHE_* 逐项覆盖 + 脏值兜底。渲染发生在入口脚本里,
|
||
# 所以必须真的起容器(不能用 --entrypoint sh,那会跳过 entrypoint.sh)。
|
||
# ① 不设任何 APACHE_* → mpm.conf 就是那 6 行(多一行少一行都算失败)
|
||
# ② 只设 APACHE_MAX_REQUEST_WORKERS=17 → 只有那一项变成 17,其余仍是默认
|
||
# ③ 设成 abc → 退回默认 5,且日志里有警告
|
||
# ④ APACHE_KEEP_ALIVE=On:不设两个可选变量 → 有 KeepAlive On、没有那两行;
|
||
# 设 timeout=2 + max=50 → 两行都出现且值正确;大小写不敏感(oN → On)
|
||
# 另外:渲染结果必须能被 Apache 接受(apache2ctl -t),并 dump 运行期取值供人工核对。
|
||
# ✅ 并发纪律(本次修复):六个用例**绝不并发**。每个用例独立容器 + **独立卷**,
|
||
# 并严格串行:run → 等就绪 → 断言 → rm 容器+卷 → 下一个。任何时刻最多只有一个容器
|
||
# 在跑 entrypoint。原来六个容器共用同一个卷一起跑 entrypoint 时,A 的
|
||
# `chown -R www-data /var/www/html`(播种/同步之后)会和 B 对同一批文件的 tar 写入互踩,
|
||
# 报 `chown: ... No such file or directory`;entrypoint 是 set -eu,chown 一失败容器当场
|
||
# 退出,后续 docker exec 就报 `No such container`。真实用户只跑一个容器,所以这是纯
|
||
# 测试编排问题 —— 产品代码(entrypoint / Dockerfile / 模板)一律不动。
|
||
# ------------------------------------------------------------------
|
||
- name: Verify Apache MPM defaults and overrides
|
||
env:
|
||
IMAGE_REF: ${{ env.IMAGE }}:sha-${{ github.sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
MPM=/etc/apache2/conf-enabled/mpm.conf
|
||
# 容器名与卷名一一对应;严格串行执行 → 任何时刻最多只有一个容器在跑 entrypoint。
|
||
NAMES="lsky-mpm-a lsky-mpm-b lsky-mpm-c lsky-mpm-d lsky-mpm-e lsky-mpm-f"
|
||
VOLS="lsky-mpm-vol-a lsky-mpm-vol-b lsky-mpm-vol-c lsky-mpm-vol-d lsky-mpm-vol-e lsky-mpm-vol-f"
|
||
CUR=""
|
||
cleanup_all() {
|
||
docker rm -f $NAMES >/dev/null 2>&1 || true
|
||
docker volume rm -f $VOLS >/dev/null 2>&1 || true
|
||
}
|
||
# 失败时 dump 全部容器日志(docker logs 对已停止容器仍可用,比 exec 稳),再清理
|
||
trap 'rc=$?; echo "::group::诊断:MPM 容器日志(当前 $CUR)"; for c in $NAMES; do echo "--- $c"; docker logs "$c" 2>&1 | tail -30 || true; done; echo "::endgroup::"; cleanup_all; exit $rc' ERR
|
||
|
||
cleanup_all
|
||
|
||
# CMD 换成「打 READY 再 sleep」:entrypoint 末尾是 exec "$@",所以 READY 一定出现在
|
||
# 播种/同步 + chown -R / chgrp -R / chmod -R 全部完成之后 —— 它既是「就绪」信号,
|
||
# 也证明这一轮 entrypoint 不会再往卷里写东西(串行的关键)。
|
||
start_mpm() { # $1=容器名 $2=卷名;其余参数原样传给 docker run
|
||
local name="$1" vol="$2"
|
||
shift 2
|
||
docker rm -f "$name" >/dev/null 2>&1 || true
|
||
docker volume rm -f "$vol" >/dev/null 2>&1 || true
|
||
CUR="$name"
|
||
docker run -d --name "$name" -v "$vol":/var/www/html "$@" "$IMAGE_REF" \
|
||
sh -c 'echo "[lsky-mpm] READY"; sleep 300' >/dev/null
|
||
local i st logs ready=0
|
||
# 独立卷 → 每个用例都会重新播种(115MB tar)+ chown -R,所以给足超时(180s);
|
||
# 一切就绪时会提前 break,不会白等。
|
||
for i in $(seq 1 180); do
|
||
st=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo unknown)
|
||
if [ "$st" = "exited" ] || [ "$st" = "dead" ]; then
|
||
echo "::error::$name 启动后已退出(entrypoint 失败?)"
|
||
docker logs "$name" 2>&1 | tail -40 || true
|
||
return 1
|
||
fi
|
||
logs=$(docker logs "$name" 2>&1 || true)
|
||
# ① MPM 摘要行(mpm.conf 已落盘,可断言内容)② READY(entrypoint 已跑完)
|
||
if printf '%s' "$logs" | grep -q 'Apache MPM' \
|
||
&& printf '%s' "$logs" | grep -q '\[lsky-mpm\] READY'; then
|
||
ready=1; break
|
||
fi
|
||
sleep 1
|
||
done
|
||
if [ "$ready" != 1 ]; then
|
||
echo "::error::$name 启动后没等到 MPM 摘要行 + READY"
|
||
docker logs "$name" 2>&1 | tail -40 || true
|
||
return 1
|
||
fi
|
||
}
|
||
# 任何 docker exec 之前先确认容器仍在 running(串行 + 就绪轮询已保证,这里双保险)
|
||
need_running() { # $1=容器名
|
||
if [ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || echo false)" != "true" ]; then
|
||
echo "::error::$1 已不在运行,无法对其断言"
|
||
docker logs "$1" 2>&1 | tail -40 || true
|
||
return 1
|
||
fi
|
||
}
|
||
assert_line() { # $1=容器 $2=必须存在的整行
|
||
need_running "$1"
|
||
if ! docker exec "$1" grep -qxF "$2" "$MPM"; then
|
||
echo "::error::$1 的 $MPM 里没有这一行:$2"
|
||
docker exec "$1" cat "$MPM" || true
|
||
exit 1
|
||
fi
|
||
}
|
||
assert_no_line() { # $1=容器 $2=不该匹配到的正则
|
||
need_running "$1"
|
||
if docker exec "$1" grep -qE "$2" "$MPM"; then
|
||
echo "::error::$1 的 $MPM 里不该出现匹配 $2 的行"
|
||
docker exec "$1" cat "$MPM" || true
|
||
exit 1
|
||
fi
|
||
}
|
||
finish_case() { # $1=容器名 $2=卷名:断言完立刻清掉,保证下一个启动时它是唯一在场的容器
|
||
docker rm -f "$1" >/dev/null 2>&1 || true
|
||
docker volume rm -f "$2" >/dev/null 2>&1 || true
|
||
CUR=""
|
||
}
|
||
|
||
echo "== 1) 不设任何 APACHE_*:mpm.conf 必须是内置默认的 6 行(逐字比对)=="
|
||
start_mpm lsky-mpm-a lsky-mpm-vol-a -e WEB_PORT=8089
|
||
printf '%s\n' \
|
||
'StartServers 2' \
|
||
'MinSpareServers 1' \
|
||
'MaxSpareServers 3' \
|
||
'MaxRequestWorkers 5' \
|
||
'MaxConnectionsPerChild 5' \
|
||
'KeepAlive Off' > /tmp/mpm-default.txt
|
||
need_running lsky-mpm-a
|
||
docker exec lsky-mpm-a cat "$MPM" > /tmp/mpm-actual.txt
|
||
cat /tmp/mpm-actual.txt
|
||
if ! diff -u /tmp/mpm-default.txt /tmp/mpm-actual.txt; then
|
||
echo "::error::不设 APACHE_* 时 mpm.conf 不是内置默认的 6 行"
|
||
exit 1
|
||
fi
|
||
echo "1) 通过:默认 6 行逐字一致(行数 $(wc -l < /tmp/mpm-actual.txt))"
|
||
|
||
echo "== 渲染出的配置必须能被 Apache 接受(apache2ctl -t)+ 运行期取值 =="
|
||
CT=$(docker exec lsky-mpm-a apache2ctl -t 2>&1 || true)
|
||
echo "$CT"
|
||
if echo "$CT" | grep -qi 'syntax error'; then
|
||
echo "::error::渲染出的 mpm.conf 让 Apache 配置语法出错"
|
||
exit 1
|
||
fi
|
||
if ! echo "$CT" | grep -q 'Syntax OK'; then
|
||
echo "::warning::apache2ctl -t 没打印 Syntax OK(容器环境差异?以现有 smoke 的安装页 200 为准)"
|
||
fi
|
||
echo "-- Apache 运行期实际取到的值(DUMP_RUN_CFG,供人工核对)--"
|
||
docker exec lsky-mpm-a apache2ctl -D DUMP_RUN_CFG 2>&1 \
|
||
| grep -iE 'startservers|minspareservers|maxspareservers|maxrequestworkers|maxconnectionsperchild|keepalive' || true
|
||
finish_case lsky-mpm-a lsky-mpm-vol-a
|
||
|
||
echo "== 2) 单项覆盖:APACHE_MAX_REQUEST_WORKERS=17,其余项必须仍是默认 =="
|
||
start_mpm lsky-mpm-b lsky-mpm-vol-b -e WEB_PORT=8089 -e APACHE_MAX_REQUEST_WORKERS=17
|
||
need_running lsky-mpm-b
|
||
docker exec lsky-mpm-b cat "$MPM"
|
||
for line in 'StartServers 2' 'MinSpareServers 1' 'MaxSpareServers 3' \
|
||
'MaxRequestWorkers 17' 'MaxConnectionsPerChild 5' 'KeepAlive Off'; do
|
||
assert_line lsky-mpm-b "$line"
|
||
done
|
||
assert_no_line lsky-mpm-b '^KeepAliveTimeout'
|
||
assert_no_line lsky-mpm-b '^MaxKeepAliveRequests'
|
||
echo "2) 通过:只有 MaxRequestWorkers 变成 17,其余 5 项仍是默认"
|
||
finish_case lsky-mpm-b lsky-mpm-vol-b
|
||
|
||
echo "== 3) 脏值:APACHE_MAX_REQUEST_WORKERS=abc → 退回 5 + 日志警告 =="
|
||
start_mpm lsky-mpm-c lsky-mpm-vol-c -e WEB_PORT=8089 -e APACHE_MAX_REQUEST_WORKERS=abc
|
||
need_running lsky-mpm-c
|
||
docker exec lsky-mpm-c cat "$MPM"
|
||
assert_line lsky-mpm-c 'MaxRequestWorkers 5'
|
||
# 用两段独立 grep(不写成一条带 .* 的正则):日志是 UTF-8,避免正则/编码耦合
|
||
MPM_LOGS=$(docker logs lsky-mpm-c 2>&1)
|
||
if ! echo "$MPM_LOGS" | grep -q '警告'; then
|
||
echo "::error::脏值没有打警告(期望形如 [lsky] 警告:…)"
|
||
echo "$MPM_LOGS" | tail -20 || true
|
||
exit 1
|
||
fi
|
||
if ! echo "$MPM_LOGS" | grep -q 'APACHE_MAX_REQUEST_WORKERS'; then
|
||
echo "::error::警告里没点名是哪个变量(应在警告里出现 APACHE_MAX_REQUEST_WORKERS)"
|
||
echo "$MPM_LOGS" | tail -20 || true
|
||
exit 1
|
||
fi
|
||
echo "$MPM_LOGS" | grep '警告' || true
|
||
echo "3) 通过:脏值退回默认 5,且日志里有指名道姓的警告"
|
||
finish_case lsky-mpm-c lsky-mpm-vol-c
|
||
|
||
echo "== 4a) APACHE_KEEP_ALIVE=On 且不设两个可选变量:只有 KeepAlive On =="
|
||
start_mpm lsky-mpm-d lsky-mpm-vol-d -e WEB_PORT=8089 -e APACHE_KEEP_ALIVE=On
|
||
need_running lsky-mpm-d
|
||
docker exec lsky-mpm-d cat "$MPM"
|
||
assert_line lsky-mpm-d 'KeepAlive On'
|
||
assert_no_line lsky-mpm-d '^KeepAliveTimeout'
|
||
assert_no_line lsky-mpm-d '^MaxKeepAliveRequests'
|
||
echo "4a) 通过:没写 KeepAliveTimeout / MaxKeepAliveRequests(即跟随 Apache 默认 5s / 100)"
|
||
finish_case lsky-mpm-d lsky-mpm-vol-d
|
||
|
||
echo "== 4b) APACHE_KEEP_ALIVE=On + timeout=2 + max=50:两行都出现且值正确 =="
|
||
start_mpm lsky-mpm-e lsky-mpm-vol-e -e WEB_PORT=8089 -e APACHE_KEEP_ALIVE=On \
|
||
-e APACHE_KEEPALIVE_TIMEOUT=2 -e APACHE_MAX_KEEPALIVE_REQUESTS=50
|
||
need_running lsky-mpm-e
|
||
docker exec lsky-mpm-e cat "$MPM" | tee /tmp/mpm-keepalive.txt
|
||
assert_line lsky-mpm-e 'KeepAlive On'
|
||
assert_line lsky-mpm-e 'KeepAliveTimeout 2'
|
||
assert_line lsky-mpm-e 'MaxKeepAliveRequests 50'
|
||
echo "4b) 通过:两行都出现且值正确"
|
||
finish_case lsky-mpm-e lsky-mpm-vol-e
|
||
|
||
echo "== 4c) 大小写不敏感:APACHE_KEEP_ALIVE=oN 也渲染成 KeepAlive On =="
|
||
start_mpm lsky-mpm-f lsky-mpm-vol-f -e WEB_PORT=8089 -e APACHE_KEEP_ALIVE=oN
|
||
need_running lsky-mpm-f
|
||
assert_line lsky-mpm-f 'KeepAlive On'
|
||
echo "4c) 通过:oN 归一化成 On"
|
||
finish_case lsky-mpm-f lsky-mpm-vol-f
|
||
|
||
cleanup_all
|
||
|
||
{
|
||
echo "## Apache MPM 自证"
|
||
echo ""
|
||
echo '```'
|
||
echo "== 默认(不设 APACHE_*)=="
|
||
cat /tmp/mpm-default.txt
|
||
echo "== APACHE_KEEP_ALIVE=On + timeout=2 / max=50 =="
|
||
cat /tmp/mpm-keepalive.txt
|
||
echo '```'
|
||
} >> "$GITHUB_STEP_SUMMARY"
|
||
|
||
echo "Apache MPM 自证通过:默认 6 行 / 单项覆盖 / 脏值兜底 / KeepAlive 条件渲染 / 大小写归一"
|
||
|
||
# ------------------------------------------------------------------
|
||
# 代码同步自证(数据安全关键路径;按镜像版本标记条件触发)
|
||
# A) 空卷:必须播种;且绝不能把镜像里的 .env 带进空卷
|
||
# (实测踩过:带进去了 → Lsky 安装向导 500,全新建站直接挂)
|
||
# B) 已有卷 + 旧版本标记:代码必须同步成镜像版本,.env / database /
|
||
# storage / bootstrap/cache 一个都不许被碰,编译视图缓存要清
|
||
# C) 已有卷 + 同版本标记:必须跳过同步(不白写整棵应用树,手动修补保留)
|
||
# 任何一条不满足就 exit 1 —— 自证不过就不会 promote latest。
|
||
# ------------------------------------------------------------------
|
||
- name: Verify code sync (revision-gated) and data safety
|
||
env:
|
||
IMAGE_REF: ${{ env.IMAGE }}:sha-${{ github.sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
VOL=lsky-sync-test
|
||
# 失败时自动把三个容器的日志打出来,省得下次还得翻 run 日志
|
||
trap 'echo "::group::诊断:容器日志"; for c in lsky-a lsky-b lsky-c; do echo "--- $c"; docker logs "$c" 2>&1 | tail -25 || true; done; echo "::endgroup::"' ERR
|
||
docker rm -f lsky-a lsky-b lsky-c >/dev/null 2>&1 || true
|
||
docker volume rm -f "$VOL" >/dev/null 2>&1 || true
|
||
|
||
echo "== A) 空卷首次启动:应播种,且播种阶段不能把镜像的 .env 带进卷 =="
|
||
# 关键:Lsky 的安装向导自己会创建 .env,所以必须在**发任何 HTTP 请求之前**
|
||
# 检查 .env 是否已经存在,否则分不清"镜像泄漏"和"向导生成"。
|
||
docker run -d --name lsky-a -v "$VOL":/var/www/html -e WEB_PORT=8089 "$IMAGE_REF"
|
||
# 完成信号必须用「该分支最后写的东西」:播种分支的顺序是 cp 整个应用树(115MB,
|
||
# 其中 index.php 在字母序里很早就拷完)→ 再拷 .env.example → 最后拷版本标记。
|
||
# 拿 index.php 当完成信号会假失败(实测踩过),所以盯标记。
|
||
for i in $(seq 1 60); do
|
||
if docker exec lsky-a test -f /var/www/html/.code-revision; then break; fi
|
||
sleep 2
|
||
done
|
||
docker exec lsky-a test -f /var/www/html/public/index.php || { echo "::error::空卷没被播种"; exit 1; }
|
||
docker exec lsky-a test -f /var/www/html/.code-revision || { echo "::error::空卷里没有版本标记"; exit 1; }
|
||
EARLY=$(docker exec lsky-a sh -c '
|
||
if [ -e /var/www/html/.env ]; then
|
||
echo "ENVFILE=present"
|
||
echo "ENV_MD5=$(md5sum /var/www/html/.env | cut -d" " -f1)"
|
||
echo "EXAMPLE_MD5=$(md5sum /var/www/html/.env.example 2>/dev/null | cut -d" " -f1)"
|
||
echo "IMAGE_ENV_MD5=$(md5sum /var/www/lsky/.env 2>/dev/null | cut -d" " -f1)"
|
||
else
|
||
echo "ENVFILE=absent"
|
||
fi
|
||
')
|
||
echo "播种后(未发任何请求):"; echo "$EARLY"
|
||
echo "$EARLY" | grep -q "ENVFILE=absent" || { echo "::error::播种阶段把镜像的 .env 带进了空卷(会让安装向导 500)"; exit 1; }
|
||
|
||
echo "== A2) 全新建站可用性:安装向导应能正常打开 =="
|
||
A=000
|
||
for i in $(seq 1 30); do
|
||
A=$(docker exec lsky-a sh -c 'curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8089/install || true' 2>/dev/null || true)
|
||
[ "$A" = "200" ] && break
|
||
sleep 2
|
||
done
|
||
echo "全新建站安装页 HTTP:$A"
|
||
if [ "$A" != "200" ]; then
|
||
docker logs lsky-a || true
|
||
echo "::error::空卷播种后安装页不是 200(HTTP $A)"
|
||
exit 1
|
||
fi
|
||
echo "(此时 .env 由安装向导自己生成,属正常)"
|
||
docker exec lsky-a sh -c 'test -e /var/www/html/.env && echo "向导生成 .env:present" || echo "向导生成 .env:absent"'
|
||
echo "A) 通过:播种不带 .env、有版本标记、安装页 200"
|
||
docker rm -f lsky-a >/dev/null
|
||
|
||
echo "== B) 已有卷 + 旧版本标记:应自动同步代码,数据与配置一项都不许变 =="
|
||
docker run --rm -v "$VOL":/var/www/html --entrypoint sh "$IMAGE_REF" -c '
|
||
set -e
|
||
echo "fork_sha=STALE" > /var/www/html/.code-revision
|
||
echo "// OLD cached js" > /var/www/html/public/js/context-js/context-js.js
|
||
echo "<?php // 手动修补过" > /var/www/html/app/Services/ImageService.php
|
||
printf "APP_KEY=SENTINEL_KEEP_ME\n" >> /var/www/html/.env
|
||
chmod 640 /var/www/html/.env
|
||
mkdir -p /var/www/html/database /var/www/html/storage/framework/views /var/www/html/storage/app/uploads /var/www/html/bootstrap/cache
|
||
echo "SENTINEL_DB" > /var/www/html/database/database.sqlite
|
||
echo "SENTINEL_UPLOAD" > /var/www/html/storage/app/uploads/keepme.txt
|
||
echo "SENTINEL_COMPILED_VIEW" > /var/www/html/storage/framework/views/deadbeef.php
|
||
echo "SENTINEL_CONFIG_CACHE" > /var/www/html/bootstrap/cache/config.php
|
||
'
|
||
|
||
# 种一份"过期"的包发现缓存:模拟真实升级 —— 卷里的 packages.php 是上一次镜像
|
||
# (那时还装着 debugbar 等开发包)生成的。--no-dev 之后这些类不存在,
|
||
# 若 entrypoint 不作废该清单,Laravel 引导时就会 Class not found → 整站 500。
|
||
docker run --rm -v "$VOL":/var/www/html --entrypoint php "$IMAGE_REF" -r 'file_put_contents("/var/www/html/bootstrap/cache/packages.php", "<?php return [\"barryvdh/laravel-debugbar\" => [\"providers\" => [\"Barryvdh\\\\Debugbar\\\\ServiceProvider\"]]];");'
|
||
|
||
docker run -d --name lsky-b -v "$VOL":/var/www/html -e WEB_PORT=8089 "$IMAGE_REF"
|
||
# 同样:同步分支的日志行是在 tar 写完之后才 echo 的,拿它当完成信号,
|
||
# 避免在 tar 还在写卷的时候就去断言(会读到中间状态)。
|
||
# 本步骤开了 `set -euo pipefail`:`docker logs | grep -q` 会在 grep 提前命中退出时
|
||
# 让 docker logs 吃到 SIGPIPE(exit 141)→ 整条管道非 0 → 假红(真踩过)。
|
||
# 所以统一「先落文件、再 grep」;等待信号也改成等该分支**最后**打的那行。
|
||
BLOG=/tmp/lsky-b.log
|
||
for i in $(seq 1 60); do
|
||
docker logs lsky-b > "$BLOG" 2>&1 || true
|
||
grep -q '已同步进卷' "$BLOG" && break
|
||
sleep 2
|
||
done
|
||
docker logs lsky-b 2>&1 | grep '\[lsky\]' || true
|
||
grep -q '已同步进卷' "$BLOG" || { echo "::error::旧标记时没有走同步分支"; echo '--- lsky-b 日志 ---'; cat "$BLOG"; exit 1; }
|
||
docker exec lsky-b md5sum /var/www/html/public/js/context-js/context-js.js | grep -q "$EXP_CTX" || { echo "::error::同步后卷里的 JS 不是镜像版本"; exit 1; }
|
||
|
||
B=$(docker exec lsky-b sh -c '
|
||
echo "JS_MD5=$(md5sum /var/www/html/public/js/context-js/context-js.js | cut -d" " -f1)"
|
||
echo "BLADE_MD5=$(md5sum /var/www/html/resources/views/user/images.blade.php | cut -d" " -f1)"
|
||
echo "HANDBOOK_GONE=$(grep -c 手动修补过 /var/www/html/app/Services/ImageService.php || true)"
|
||
echo "ENV_SENTINEL=$(grep -c SENTINEL_KEEP_ME /var/www/html/.env || true)"
|
||
echo "ENV_MODE_INFO=$(stat -c %a /var/www/html/.env)"
|
||
echo "DB_SENTINEL=$(cat /var/www/html/database/database.sqlite)"
|
||
echo "UPLOAD_SENTINEL=$(cat /var/www/html/storage/app/uploads/keepme.txt)"
|
||
echo "CONFIG_CACHE=$(cat /var/www/html/bootstrap/cache/config.php)"
|
||
echo "COMPILED_VIEWS=$(ls /var/www/html/storage/framework/views/ | wc -l)"
|
||
')
|
||
echo "$B"
|
||
echo "$B" | grep -q "JS_MD5=$EXP_CTX" || { echo "::error::已有卷里的补丁 JS 没被同步成镜像版本"; exit 1; }
|
||
echo "$B" | grep -q "BLADE_MD5=$EXP_BLADE" || { echo "::error::已有卷里的 blade 没被同步"; exit 1; }
|
||
echo "$B" | grep -q "HANDBOOK_GONE=0" || { echo "::error::旧版本的手工改动没被同步覆盖(换镜像应等于换代码)"; exit 1; }
|
||
echo "$B" | grep -q "ENV_SENTINEL=1" || { echo "::error::.env 被覆盖了(会丢 APP_KEY / S3 配置)"; exit 1; }
|
||
# 注:.env 的权限位不在这里断言 —— 上游 entrypoint 自带 `chmod -R 755 /var/www/html/`,
|
||
# 它会把 .env 归一化成 755,与同步无关。这里只关心内容没被动。
|
||
echo "$B" | grep -q "DB_SENTINEL=SENTINEL_DB" || { echo "::error::数据库文件被覆盖了"; exit 1; }
|
||
echo "$B" | grep -q "UPLOAD_SENTINEL=SENTINEL_UPLOAD" || { echo "::error::上传文件被覆盖了"; exit 1; }
|
||
echo "$B" | grep -q "CONFIG_CACHE=SENTINEL_CONFIG_CACHE" || { echo "::error::bootstrap/cache 被动过"; exit 1; }
|
||
echo "$B" | grep -q "COMPILED_VIEWS=0" || { echo "::error::编译视图缓存没清,blade 改动不会生效"; exit 1; }
|
||
echo "B) 通过:代码已同步,.env / 数据库 / 上传 / 配置缓存均未被碰,视图缓存已清"
|
||
|
||
# --- 包发现缓存 + --no-dev 的回归断言 ---------------------------------
|
||
echo "== 开发包是否真的没进镜像(--no-dev)=="
|
||
DEVDEPS=$(docker exec lsky-b sh -c 'for d in vendor/phpunit vendor/barryvdh vendor/spatie/ignition vendor/fakerphp; do [ -e "/var/www/html/$d" ] && echo "present:$d"; done; echo done')
|
||
echo "$DEVDEPS"
|
||
echo "$DEVDEPS" | grep -q "present:" && { echo "::error::镜像里仍有开发包(--no-dev 没生效)"; exit 1; }
|
||
|
||
echo "== 过期的包发现缓存有没有被作废 =="
|
||
# 注意:php -r 不会自动加载 classmap,必须先 require autoload.php,
|
||
# 否则 class_exists 对"本来存在"的类也会返回 false(假阳性)
|
||
docker exec lsky-b php -r 'require "/var/www/html/vendor/autoload.php";
|
||
$m = @include "/var/www/html/bootstrap/cache/packages.php";
|
||
if (!is_array($m)) { echo "PACKAGE_MANIFEST=absent", PHP_EOL; exit(0); }
|
||
$bad = 0; $seen = 0;
|
||
foreach ($m as $pkg => $info) { foreach (($info["providers"] ?? []) as $c) { $seen++; if (!class_exists($c)) { echo "MISSING_CLASS=$c", PHP_EOL; $bad++; } } }
|
||
echo "PACKAGE_MANIFEST_ENTRIES=$seen", PHP_EOL;
|
||
echo $bad ? "PACKAGE_MANIFEST=stale" : "PACKAGE_MANIFEST=clean", PHP_EOL;
|
||
exit($bad ? 1 : 0);'
|
||
MANIFEST=$(docker exec lsky-b php -r '$m = @include "/var/www/html/bootstrap/cache/packages.php"; echo is_array($m) ? "present" : "absent";')
|
||
echo "清单文件状态:$MANIFEST"
|
||
[ "$MANIFEST" = "absent" ] || { echo "::error::过期包发现缓存没被作废(--no-dev 后 Class not found 会导致整站 500)"; exit 1; }
|
||
|
||
echo "== C) 已有卷 + 同版本标记:必须跳过同步(手动修补保留)=="
|
||
docker rm -f lsky-b >/dev/null
|
||
docker run --rm -v "$VOL":/var/www/html --entrypoint sh "$IMAGE_REF" -c 'echo "<?php // 手动修补过" > /var/www/html/app/Services/ImageService.php'
|
||
docker run -d --name lsky-c -v "$VOL":/var/www/html -e WEB_PORT=8089 "$IMAGE_REF"
|
||
# 等待信号必须是「跳过同步」这行**本身**(技能第 16 条:别等第一条 [lsky] ——
|
||
# 入口脚本越早打的其它行,比如自签证书那行,会把这里的时序带偏)。
|
||
# 同样「先落文件再 grep」,避开 pipefail + SIGPIPE 的假红。
|
||
CLOG=/tmp/lsky-c.log
|
||
for i in $(seq 1 30); do
|
||
docker logs lsky-c > "$CLOG" 2>&1 || true
|
||
grep -q '跳过同步' "$CLOG" && break
|
||
sleep 1
|
||
done
|
||
grep '\[lsky\]' "$CLOG" || true
|
||
grep -q '跳过同步' "$CLOG" || { echo "::error::同版本标记时应跳过同步"; echo '--- lsky-c 日志 ---'; cat "$CLOG"; exit 1; }
|
||
C=$(docker exec lsky-c sh -c '
|
||
echo "HANDBOOK_KEPT=$(grep -c 手动修补过 /var/www/html/app/Services/ImageService.php || true)"
|
||
')
|
||
echo "$C"
|
||
echo "$C" | grep -q "HANDBOOK_KEPT=1" || { echo "::error::同版本时手动修补被覆盖了(不该同步)"; exit 1; }
|
||
# 端到端:验 Apache 实际吐给浏览器的那份(不是磁盘文件)。
|
||
# 注意 `[lsky]` 日志行是在 exec apache 之前打的,所以这里必须等就绪再 curl ——
|
||
# 直接 curl 会拿到空响应(空串 md5 = d41d8cd9...),实测踩过。
|
||
SERVED=d41d8cd98f00b204e9800998ecf8427e
|
||
for i in $(seq 1 30); do
|
||
SERVED=$(docker exec lsky-c sh -c 'curl -s http://127.0.0.1:8089/js/context-js/context-js.js | md5sum | cut -d" " -f1' 2>/dev/null || true)
|
||
if [ "$SERVED" = "$EXP_CTX" ]; then break; fi
|
||
sleep 2
|
||
done
|
||
echo "对外服务的 JS md5(Apache 实际吐出)=$SERVED"
|
||
[ "$SERVED" = "$EXP_CTX" ] || { echo "::error::跳过后对外服务的 JS 不是补丁版(期望 $EXP_CTX)"; exit 1; }
|
||
echo "C) 通过:同版本跳过同步、手动修补保留、对外服务的 JS 仍是补丁版"
|
||
|
||
echo "== 同步开销(只在标记不一致时发生,纯信息不影响结论)=="
|
||
# 用 bash 跑:`time` 是 bash 内建,sh(dash) 里没有(踩过 exit 127)。
|
||
# 这段只是量体积/耗时,失败不该拦住发布,所以 || true。
|
||
docker run --rm --entrypoint bash "$IMAGE_REF" -c 'du -sh /var/www/lsky; cd /var/www/lsky && time tar cf - --exclude=./.env --exclude=./storage --exclude=./database --exclude=./bootstrap/cache --exclude=./public/i . > /dev/null' || echo "(测速失败,不影响结论)"
|
||
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 }}
|
||
# 基线默认由 tools/upgrade-test.sh 向 GHCR 匿名解析(最新的 sha-<commit> tag ≠ 本次 commit)。
|
||
# 这里只在需要人工钉死时才读仓库变量 vars.UPGRADE_FROM_TAG(未设 = 空 = 走动态解析);
|
||
# 变量在脚本里是「覆盖优先」,解析逻辑照旧不跑。
|
||
UPGRADE_FROM_TAG: ${{ vars.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:
|
||
# 免得"还没定案的版本"变成线上 `docker compose pull` 的目标。
|
||
# 只有 master 上的构建才许动 latest:分支构建(现在 push: test/* 也会触发)绝不能把线上
|
||
# 拉取目标改掉 —— 否则推一次测试版分支就把老师线上在用的 :latest 偷偷顶成测试版。
|
||
# 分支构建只产出不可变的 :sha-<commit>(由构建步骤推送),要测就显式按 sha tag 拉。
|
||
if: steps.pin.outputs.is_test_build != 'true' && github.ref == 'refs/heads/master'
|
||
run: |
|
||
set -euo pipefail
|
||
# pin 从 Dockerfile 解析(不要再写死:曾经漂移过一次,把新镜像贴上了旧 pin 的 tag)
|
||
PIN="${{ steps.pin.outputs.pin }}"
|
||
|
||
# ================= 版本号 tag(X.Y.Z)=================
|
||
# 目的(老师 2026-10-03 定):
|
||
# · `latest` 永远 = master 最新正式版;
|
||
# · 同时每个正式版都留一个**不可变**的版本号 tag,日后能靠它拉回旧正式版;
|
||
# · **无论如何都不能靠版本号拉到 test 分支的镜像** —— 这不是靠约定,而是结构保证:
|
||
# 本步骤的 if 里写死了 github.ref == 'refs/heads/master',分支构建根本执行不到这里。
|
||
# 下面再加两道守卫,挡住「手动 workflow_dispatch 在别的 ref 上跑」这类旁路。
|
||
#
|
||
# 版本号来源 = 应用自己的 config/app.php(单一事实来源,避免 workflow 与代码漂移;
|
||
# 与上面 PIN 从 Dockerfile 解析同一个原则)。
|
||
VER=$(grep -oE "'version'[[:space:]]*=>[[:space:]]*'[^']+'" src/config/app.php \
|
||
| head -1 | sed -E "s/.*'([^']+)'.*/\1/")
|
||
[ -n "$VER" ] || { echo "::error::读不到 src/config/app.php 里的 version"; exit 1; }
|
||
echo "本次版本号:$VER"
|
||
# 守卫①:格式必须是 <数字>.<数字>.<数字>,免得手滑写出怪 tag
|
||
[[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|
||
|| { echo "::error::版本号格式不对(期望 X.Y.Z,实际 '$VER')"; exit 1; }
|
||
|
||
# 守卫②:这个 commit 必须真的在 master 上。
|
||
# 只信 github.ref 不够 —— workflow_dispatch 可以在任意 ref 上手动触发。
|
||
# 用 Compare API 判断(checkout 是浅克隆,本地 git 历史不足以做 merge-base)。
|
||
# 以本 commit 为 base、master 为 head:identical(就是 master 顶端)或 ahead(master 更新)
|
||
# 都说明本 commit 在 master 的历史里。
|
||
CMP=$(curl -fsS \
|
||
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||
-H "Accept: application/vnd.github+json" \
|
||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/compare/${GITHUB_SHA}...master" \
|
||
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("status",""))')
|
||
case "$CMP" in
|
||
identical|ahead) echo "守卫②通过:${GITHUB_SHA:0:12} 在 master 上(compare=$CMP)" ;;
|
||
*) echo "::error::${GITHUB_SHA:0:12} 不在 master 上(compare=$CMP)—— 拒绝打版本号 tag"; exit 1 ;;
|
||
esac
|
||
|
||
# 守卫③:版本号 tag **不可变** —— 已存在就保留原样、绝不覆盖。
|
||
# 这正是「靠版本号拉旧正式版」能成立的前提:老 tag 永远指向老镜像。
|
||
TAGS=(-t "${IMAGE}:latest" -t "${IMAGE}:${PIN}")
|
||
if docker buildx imagetools inspect "${IMAGE}:${VER}" >/dev/null 2>&1; then
|
||
echo "::notice::${IMAGE}:${VER} 已存在 —— 保留原样,不覆盖(版本号 tag 不可变)"
|
||
else
|
||
TAGS+=(-t "${IMAGE}:${VER}")
|
||
echo "将新建版本号 tag:${VER}"
|
||
fi
|
||
|
||
docker buildx imagetools create "${TAGS[@]}" "${IMAGE}:sha-${GITHUB_SHA}"
|
||
echo "已把 sha-${GITHUB_SHA} 提升为 latest + ${PIN} + 版本号 ${VER}"
|
||
docker buildx imagetools inspect "${IMAGE}:latest" > /tmp/promoted.txt
|
||
head -20 /tmp/promoted.txt
|