Files
mole404 f4c799477a chore(release): 版本号一律不带 v —— app 显示与镜像 tag 统一为 X.Y.Z
老师 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 形式。
2026-10-04 03:26:45 +08:00

1044 lines
66 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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