合并 master(48eb3d500):CI 缓存与运行时测试重组并入运行页入口收口
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m26s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m59s
Project CI / Backend tests (pull_request) Successful in 6m44s
Project CI / Native shell tests (pull_request) Successful in 7m55s
Project CI / Frontend tests (pull_request) Successful in 3m32s
Project CI / Repository checks (pull_request) Successful in 3m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m33s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m4s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m26s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m59s
Project CI / Backend tests (pull_request) Successful in 6m44s
Project CI / Native shell tests (pull_request) Successful in 7m55s
Project CI / Frontend tests (pull_request) Successful in 3m32s
Project CI / Repository checks (pull_request) Successful in 3m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m33s
- 只冲突 docs/project-memory/shared-memory/pitfalls.md:保留双方新增的排障记录(本分支的 Codex 原生包版本 + master 的 Rust 对象快照) - 其余 master 改动(CI 缓存脚本、process_session 测试拆分等)自动合并
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# 由能管理 CI 镜像的维护者运行;不得在 PR job 内提供 Docker API/发布权限。
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
base_ref="${1:?usage: build-gitea-rust-cache.sh <verified-base-image> <candidate-tag>}"
|
||||
candidate_tag="${2:?candidate image tag is required}"
|
||||
# 与实际 Gitea checkout 路径一致;Rust 对象 key 包含编译 cwd,不能随意换临时根。
|
||||
workspace=/workspace/GenarrativeAI/Genarrative
|
||||
[[ "${CI:-}" != true ]] || { echo 'Run on the trusted image builder, outside CI jobs.' >&2; exit 1; }
|
||||
base_id="$(docker image inspect --format '{{.Id}}' "${base_ref}")"
|
||||
[[ "${base_id}" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
# 删除容器内旧对象不能释放镜像底层;每次必须从不含对象快照的基础镜像重建。
|
||||
docker run --rm --network none --read-only --cap-drop=ALL \
|
||||
--security-opt=no-new-privileges --entrypoint /bin/bash "${base_id}" -c '
|
||||
if [[ -e /opt/genarrative-ci/rust-cache ]]; then
|
||||
echo "基础镜像已包含 Rust 对象缓存;请使用不含对象快照的原始 CI 镜像,禁止叠层。" >&2
|
||||
exit 1
|
||||
fi
|
||||
'
|
||||
bash "${repo_root}/scripts/gitea-ci-job-image.sh" verify "${base_id}"
|
||||
|
||||
# 只归档远端 master 的确定提交;不复制当前工作区或本地凭据。
|
||||
git -C "${repo_root}" fetch --no-tags origin refs/heads/master
|
||||
source_commit="$(git -C "${repo_root}" rev-parse FETCH_HEAD^{commit})"
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/gitea-rust-cache.XXXXXX")"
|
||||
container_id=''
|
||||
cleanup() {
|
||||
if [[ -n "${container_id}" ]]; then docker rm -f "${container_id}" >/dev/null; fi
|
||||
rm -rf -- "${work_dir}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
archive=sccache-v0.18.0-x86_64-unknown-linux-musl.tar.gz
|
||||
curl --fail --location --retry 3 --connect-timeout 15 --max-time 180 \
|
||||
"https://github.com/mozilla/sccache/releases/download/v0.18.0/${archive}" \
|
||||
--output "${work_dir}/${archive}"
|
||||
printf '45f1447fbe231e3037bde351ef70677dd212216c8d62ae7ca409fecc4d6acc89 %s\n' "${work_dir}/${archive}" | sha256sum --check
|
||||
tar -xzf "${work_dir}/${archive}" --directory "${work_dir}"
|
||||
mkdir "${work_dir}/snapshot"
|
||||
cp "${work_dir}/sccache-v0.18.0-x86_64-unknown-linux-musl/sccache" "${work_dir}/snapshot/sccache"
|
||||
printf '%s\n' "${source_commit}" > "${work_dir}/snapshot/source-commit.txt"
|
||||
printf '%s\n' "${base_id}" > "${work_dir}/snapshot/base-image.txt"
|
||||
printf '%s\n' "${workspace}" > "${work_dir}/snapshot/workspace.txt"
|
||||
|
||||
# 临时容器不挂载宿主目录/socket,不携带 Git/OSS/Jenkins 凭据,限制资源占用。
|
||||
container_id="$(docker run --detach --cpus=4 --memory=12g --pids-limit=1024 \
|
||||
--cap-drop=ALL --security-opt=no-new-privileges \
|
||||
--entrypoint /bin/bash "${base_id}" -c 'sleep infinity')"
|
||||
docker exec "${container_id}" mkdir -p "${workspace}" /opt/genarrative-ci/rust-cache/objects
|
||||
git -C "${repo_root}" archive "${source_commit}" | docker cp - "${container_id}:${workspace}"
|
||||
docker cp "${work_dir}/snapshot/." "${container_id}:/opt/genarrative-ci/rust-cache/"
|
||||
docker cp "${repo_root}/scripts/ci-rust-cache.sh" "${container_id}:/tmp/ci-rust-cache.sh"
|
||||
docker exec --interactive --workdir "${workspace}" "${container_id}" bash -s <<'WARM'
|
||||
set -euo pipefail
|
||||
rustc -vV > /opt/genarrative-ci/rust-cache/rustc.txt
|
||||
export GITHUB_ENV=/tmp/rust-cache.env CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=4 CI=true
|
||||
# sccache 对 CARGO_*(除 jobserver/jobs 等特例)参与 hash,必须与 workflow 对齐。
|
||||
export CARGO_HTTP_MULTIPLEXING=false CARGO_NET_RETRY=10 CARGO_TERM_COLOR=always
|
||||
bash /tmp/ci-rust-cache.sh prepare
|
||||
set -a
|
||||
source "${GITHUB_ENV}"
|
||||
set +a
|
||||
test -n "${RUSTC_WRAPPER}"
|
||||
trap 'bash /tmp/ci-rust-cache.sh report' EXIT
|
||||
cd apps/ai-game-creator-shell/src-tauri
|
||||
cargo test --locked --manifest-path Cargo.toml \
|
||||
--bin genarrative-ai-game-creator-shell --no-run
|
||||
WARM
|
||||
docker cp "${container_id}:/opt/genarrative-ci/rust-cache/." "${work_dir}/snapshot/"
|
||||
docker rm -f "${container_id}" >/dev/null
|
||||
container_id=''
|
||||
|
||||
# 从原基础镜像重新组装,只 COPY 对象快照;不 commit 含源码/target 的预热容器。
|
||||
cat > "${work_dir}/Dockerfile" <<EOF
|
||||
FROM ${base_id}
|
||||
COPY snapshot/ /opt/genarrative-ci/rust-cache/
|
||||
LABEL world.genarrative.ci.rust-cache-source="${source_commit}"
|
||||
LABEL world.genarrative.ci.rust-cache-base="${base_id}"
|
||||
EOF
|
||||
printf '**\n!Dockerfile\n!snapshot/\n!snapshot/**\n' > "${work_dir}/.dockerignore"
|
||||
docker build --pull=false --tag "${candidate_tag}" "${work_dir}"
|
||||
bash "${repo_root}/scripts/gitea-ci-job-image.sh" verify "${candidate_tag}"
|
||||
printf 'snapshot_source=%s\ncandidate_image=%s\n' "${source_commit}" "$(docker image inspect --format '{{.Id}}' "${candidate_tag}")"
|
||||
echo 'Candidate only: the runner configuration and running jobs have not been changed.'
|
||||
@@ -28,6 +28,19 @@ bash -n /usr/local/bin/genarrative-gitea-checkout
|
||||
test -d /root/.npm/_cacache
|
||||
test -d /usr/local/cargo/registry/cache
|
||||
|
||||
# 对象快照是可选镜像层;构建/装载前验证,运行时故障由 prepare 回退直接 rustc。
|
||||
rust_cache_root=/opt/genarrative-ci/rust-cache
|
||||
if [[ -d "${rust_cache_root}" && "${GENARRATIVE_GITEA_CI_CHECK_RUNTIME:-0}" != 1 ]]; then
|
||||
test "$("${rust_cache_root}/sccache" --version)" = 'sccache 0.18.0'
|
||||
rustc -vV | cmp -s - "${rust_cache_root}/rustc.txt"
|
||||
rg -q '^[a-f0-9]{40}$' "${rust_cache_root}/source-commit.txt"
|
||||
rg -q '^sha256:[a-f0-9]{64}$' "${rust_cache_root}/base-image.txt"
|
||||
test "$(cat "${rust_cache_root}/workspace.txt")" = '/workspace/GenarrativeAI/Genarrative'
|
||||
test -n "$(find "${rust_cache_root}/objects" -type f -print -quit)"
|
||||
test ! -e /workspace/GenarrativeAI/Genarrative
|
||||
printf 'rust_object_snapshot=verified\n'
|
||||
fi
|
||||
|
||||
verify_cache_lock() {
|
||||
local cache_name="$1"
|
||||
local expected_sha256="$2"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# 仅消费镜像内的可信快照;所有写入留在当前容器的可写层。
|
||||
set -euo pipefail
|
||||
|
||||
cache_root="${GENARRATIVE_CI_RUST_CACHE_ROOT:-/opt/genarrative-ci/rust-cache}"
|
||||
cache_binary="${cache_root}/sccache"
|
||||
state="${GENARRATIVE_CI_RUST_CACHE_STATE:-}"
|
||||
|
||||
configure_local_cache() {
|
||||
# 不继承开发机/其它流水线的 OSS、S3、GHA 或 daemon 配置。
|
||||
local variable
|
||||
for variable in ${!SCCACHE_@}; do unset "${variable}"; done
|
||||
export SCCACHE_CONF="${state}/config"
|
||||
export SCCACHE_DIR="${cache_root}/objects"
|
||||
export SCCACHE_CACHE_SIZE=2G
|
||||
export SCCACHE_SERVER_UDS="${state}/server.sock"
|
||||
# 单个不可缓存的链接/测试阶段可能超过一分钟;保持统计直到 report 显式停止。
|
||||
export SCCACHE_IDLE_TIMEOUT=0
|
||||
export SCCACHE_IGNORE_SERVER_IO_ERROR=1
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
prepare)
|
||||
: "${GITHUB_ENV:?GITHUB_ENV is required}"
|
||||
printf 'RUSTC_WRAPPER=\nCARGO_BUILD_RUSTC_WRAPPER=\nGENARRATIVE_CI_RUST_CACHE_STATE=\n' >> "${GITHUB_ENV}"
|
||||
fallback() { printf '[rust-cache] mode=direct reason=%s\n' "$1"; exit 0; }
|
||||
[[ -x "${cache_binary}" && -d "${cache_root}/objects" && -f "${cache_root}/rustc.txt" && -f "${cache_root}/source-commit.txt" && -f "${cache_root}/workspace.txt" ]] \
|
||||
|| fallback snapshot-unavailable
|
||||
if ! rustc -vV | cmp -s - "${cache_root}/rustc.txt"; then
|
||||
fallback toolchain-mismatch
|
||||
fi
|
||||
if [[ "$(pwd -P)" != "$(cat "${cache_root}/workspace.txt")" ]]; then
|
||||
fallback workspace-mismatch
|
||||
fi
|
||||
# 使用短 Unix socket 路径,避免共享固定端口或超出 sockaddr_un 长度。
|
||||
state="$(mktemp -d /tmp/ci-rust-cache.XXXXXX)"
|
||||
printf 'server_startup_timeout_ms = 5000\n' > "${state}/config"
|
||||
configure_local_cache
|
||||
# 探测必须暴露 daemon 故障;只有正式编译允许 sccache 的 IO 回退。
|
||||
unset SCCACHE_IGNORE_SERVER_IO_ERROR
|
||||
if ! timeout --kill-after=2 15 "${cache_binary}" "$(command -v rustc)" -vV > "${state}/probe.log" 2>&1; then
|
||||
timeout --kill-after=2 5 "${cache_binary}" --stop-server >/dev/null 2>&1 || true
|
||||
rm -rf -- "${state}"
|
||||
fallback wrapper-probe-failed
|
||||
fi
|
||||
script_path="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/$(basename "${BASH_SOURCE[0]}")"
|
||||
# wrapper 路径也参与 Rust cache key;固定容器内路径,隔离由 job 容器保证。
|
||||
wrapper_path="${cache_root}/rustc-wrapper"
|
||||
printf '#!/usr/bin/env bash\nexec bash %q "$@"\n' "${script_path}" > "${wrapper_path}"
|
||||
chmod 700 "${wrapper_path}"
|
||||
{
|
||||
printf 'GENARRATIVE_CI_RUST_CACHE_ROOT=%s\n' "${cache_root}"
|
||||
printf 'GENARRATIVE_CI_RUST_CACHE_STATE=%s\n' "${state}"
|
||||
printf 'RUSTC_WRAPPER=%s\nCARGO_BUILD_RUSTC_WRAPPER=%s\n' "${wrapper_path}" "${wrapper_path}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
printf '[rust-cache] mode=sccache snapshot=%s\n' "$(cat "${cache_root}/source-commit.txt")"
|
||||
;;
|
||||
report)
|
||||
if [[ -n "${state}" && -d "${state}" ]]; then
|
||||
configure_local_cache
|
||||
timeout --kill-after=2 5 "${cache_binary}" --show-stats || true
|
||||
timeout --kill-after=2 5 "${cache_binary}" --stop-server >/dev/null 2>&1 || true
|
||||
rm -rf -- "${state}"
|
||||
else
|
||||
printf '[rust-cache] mode=direct\n'
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Cargo wrapper 协议:第一个参数是真实 rustc。失去缓存状态时仍执行原编译。
|
||||
if [[ -z "${state}" || ! -f "${state}/config" || -f "${state}/disabled" || ! -x "${cache_binary}" ]]; then
|
||||
exec "$@"
|
||||
fi
|
||||
configure_local_cache
|
||||
status=0
|
||||
"${cache_binary}" "$@" || status=$?
|
||||
if [[ "${status}" == 2 ]]; then
|
||||
# 固定 sccache 版本的自身错误码为 2;实际 rustc 失败通常为 1/101。
|
||||
# 只重试这次编译,不重跑 Cargo 或测试,真实编译错误仍按 rustc 状态返回。
|
||||
echo '[rust-cache] cache infrastructure failed; compiling directly' >&2
|
||||
# 后续 crate 直接编译,避免坏 daemon 让每个 crate 都支付一次启动超时。
|
||||
: > "${state}/disabled"
|
||||
exec "$@"
|
||||
fi
|
||||
exit "${status}"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,217 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
const script = resolve('scripts/ci-rust-cache.sh');
|
||||
const linuxTest = process.platform === 'linux' ? test : test.skip;
|
||||
|
||||
function fixture(t) {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'ci-rust-cache-test-'));
|
||||
const root = join(directory, 'snapshot');
|
||||
const bin = join(directory, 'bin');
|
||||
mkdirSync(join(root, 'objects'), { recursive: true });
|
||||
mkdirSync(bin);
|
||||
const envFile = join(directory, 'github-env');
|
||||
writeFileSync(envFile, '');
|
||||
writeFileSync(join(root, 'rustc.txt'), 'fixture rustc identity\n');
|
||||
writeFileSync(join(root, 'source-commit.txt'), 'trusted-master-commit\n');
|
||||
writeFileSync(join(root, 'workspace.txt'), `${process.cwd()}\n`);
|
||||
writeFileSync(
|
||||
join(bin, 'rustc'),
|
||||
'#!/bin/bash\necho "fixture rustc identity"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, 'sccache'),
|
||||
`#!/bin/bash
|
||||
set -eu
|
||||
case "$1" in
|
||||
--stop-server|--show-stats) exit 0 ;;
|
||||
esac
|
||||
if [[ "$*" == *-vV ]]; then
|
||||
[[ "\${PROBE_FAILURE:-}" != 1 ]] || exit 1
|
||||
exec "$@"
|
||||
fi
|
||||
printf '%s\\n' "\${SCCACHE_OSS_BUCKET-unset}" "\${SCCACHE_CONF}" "\${SCCACHE_DIR}" "\${SCCACHE_SERVER_UDS}" "\${SCCACHE_IGNORE_SERVER_IO_ERROR}" "\${SCCACHE_IDLE_TIMEOUT}" >> "\${TRACE}"
|
||||
[[ "\${CACHE_FAILURE:-}" != 2 ]] || exit 2
|
||||
exec "$@"
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
GITHUB_ENV: envFile,
|
||||
GENARRATIVE_CI_RUST_CACHE_ROOT: root,
|
||||
GENARRATIVE_CI_RUST_CACHE_STATE: '',
|
||||
RUSTC_WRAPPER: 'bad-inherited-wrapper',
|
||||
CARGO_BUILD_RUSTC_WRAPPER: 'bad-inherited-wrapper',
|
||||
SCCACHE_OSS_BUCKET: 'must-not-use-publishing-cache',
|
||||
TRACE: join(directory, 'trace'),
|
||||
};
|
||||
function run(args, extraEnv = {}) {
|
||||
return spawnSync('bash', [script, ...args], {
|
||||
env: { ...env, ...extraEnv },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
function preparedEnv() {
|
||||
return Object.fromEntries(
|
||||
readFileSync(envFile, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const separator = line.indexOf('=');
|
||||
return [line.slice(0, separator), line.slice(separator + 1)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
t.after(() => {
|
||||
run(['report'], preparedEnv());
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
return { directory, root, env, run, preparedEnv };
|
||||
}
|
||||
|
||||
linuxTest(
|
||||
'missing snapshot and toolchain mismatch retain direct rustc',
|
||||
(t) => {
|
||||
const f = fixture(t);
|
||||
const missing = f.run(['prepare'], {
|
||||
GENARRATIVE_CI_RUST_CACHE_ROOT: join(f.directory, 'absent'),
|
||||
});
|
||||
assert.equal(missing.status, 0, missing.stderr);
|
||||
assert.match(missing.stdout, /reason=snapshot-unavailable/);
|
||||
assert.equal(f.preparedEnv().RUSTC_WRAPPER, '');
|
||||
writeFileSync(join(f.root, 'rustc.txt'), 'different compiler\n');
|
||||
const mismatch = f.run(['prepare']);
|
||||
assert.equal(mismatch.status, 0, mismatch.stderr);
|
||||
assert.match(mismatch.stdout, /reason=toolchain-mismatch/);
|
||||
assert.equal(f.preparedEnv().CARGO_BUILD_RUSTC_WRAPPER, '');
|
||||
writeFileSync(join(f.root, 'rustc.txt'), 'fixture rustc identity\n');
|
||||
writeFileSync(join(f.root, 'workspace.txt'), '/different-checkout\n');
|
||||
const moved = f.run(['prepare']);
|
||||
assert.equal(moved.status, 0, moved.stderr);
|
||||
assert.match(moved.stdout, /reason=workspace-mismatch/);
|
||||
assert.equal(f.preparedEnv().RUSTC_WRAPPER, '');
|
||||
},
|
||||
);
|
||||
|
||||
linuxTest(
|
||||
'preparations keep the compiler wrapper path stable with separate daemons',
|
||||
(t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run(['prepare']).status, 0);
|
||||
const first = f.preparedEnv();
|
||||
f.run(['report'], first);
|
||||
assert.equal(f.run(['prepare']).status, 0);
|
||||
const second = f.preparedEnv();
|
||||
assert.equal(
|
||||
first.CARGO_BUILD_RUSTC_WRAPPER,
|
||||
second.CARGO_BUILD_RUSTC_WRAPPER,
|
||||
);
|
||||
assert.equal(first.RUSTC_WRAPPER, second.RUSTC_WRAPPER);
|
||||
assert.notEqual(
|
||||
first.GENARRATIVE_CI_RUST_CACHE_STATE,
|
||||
second.GENARRATIVE_CI_RUST_CACHE_STATE,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
linuxTest('failed wrapper probe falls back without enabling the cache', (t) => {
|
||||
const f = fixture(t);
|
||||
const result = f.run(['prepare'], { PROBE_FAILURE: '1' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /reason=wrapper-probe-failed/);
|
||||
assert.equal(f.preparedEnv().RUSTC_WRAPPER, '');
|
||||
assert.equal(f.preparedEnv().GENARRATIVE_CI_RUST_CACHE_STATE, '');
|
||||
});
|
||||
|
||||
linuxTest(
|
||||
'wrapper isolates remote settings and preserves compiler failures without retry',
|
||||
(t) => {
|
||||
const f = fixture(t);
|
||||
const prepared = f.run(['prepare']);
|
||||
assert.equal(prepared.status, 0, prepared.stderr);
|
||||
const cachedEnv = f.preparedEnv();
|
||||
const result = spawnSync(
|
||||
cachedEnv.RUSTC_WRAPPER,
|
||||
['/bin/bash', '-c', 'exit 42'],
|
||||
{
|
||||
env: { ...f.env, ...cachedEnv },
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 42, result.stderr);
|
||||
const trace = readFileSync(f.env.TRACE, 'utf8').trim().split('\n');
|
||||
assert.deepEqual(trace, [
|
||||
'unset',
|
||||
`${cachedEnv.GENARRATIVE_CI_RUST_CACHE_STATE}/config`,
|
||||
`${f.root}/objects`,
|
||||
`${cachedEnv.GENARRATIVE_CI_RUST_CACHE_STATE}/server.sock`,
|
||||
'1',
|
||||
'0',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
linuxTest(
|
||||
'job copies use different cache directories and daemon sockets',
|
||||
(t) => {
|
||||
const first = fixture(t);
|
||||
const second = fixture(t);
|
||||
for (const f of [first, second]) {
|
||||
const prepared = f.run(['prepare']);
|
||||
assert.equal(prepared.status, 0, prepared.stderr);
|
||||
const result = spawnSync(f.preparedEnv().RUSTC_WRAPPER, ['/bin/true'], {
|
||||
env: { ...f.env, ...f.preparedEnv() },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
}
|
||||
const a = readFileSync(first.env.TRACE, 'utf8').split('\n');
|
||||
const b = readFileSync(second.env.TRACE, 'utf8').split('\n');
|
||||
assert.notEqual(a[2], b[2]);
|
||||
assert.notEqual(a[3], b[3]);
|
||||
},
|
||||
);
|
||||
|
||||
linuxTest(
|
||||
'sccache infrastructure failure invokes rustc and preserves its result',
|
||||
(t) => {
|
||||
const f = fixture(t);
|
||||
const prepared = f.run(['prepare']);
|
||||
assert.equal(prepared.status, 0, prepared.stderr);
|
||||
for (const [compiler, status] of [
|
||||
['/bin/false', 1],
|
||||
['/bin/true', 0],
|
||||
]) {
|
||||
const result = spawnSync(f.preparedEnv().RUSTC_WRAPPER, [compiler], {
|
||||
env: { ...f.env, ...f.preparedEnv(), CACHE_FAILURE: '2' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, status, result.stderr);
|
||||
if (status === 1)
|
||||
assert.match(result.stderr, /cache infrastructure failed/);
|
||||
else assert.equal(result.stderr, '');
|
||||
}
|
||||
assert.equal(
|
||||
readFileSync(f.env.TRACE, 'utf8').trim().split('\n').length,
|
||||
6,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
linuxTest('lost local cache state still invokes the real compiler', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run(['/bin/bash', '-c', 'exit 43']).status, 43);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createVitest } from 'vitest/node';
|
||||
|
||||
const workflow = readFileSync(
|
||||
resolve(process.cwd(), '.gitea/workflows/project-ci.yml'),
|
||||
@@ -15,6 +16,10 @@ const imageCheckScript = readFileSync(
|
||||
resolve(process.cwd(), 'scripts/check-gitea-ci-job-image.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const rustCacheBuildScript = readFileSync(
|
||||
resolve(process.cwd(), 'scripts/build-gitea-rust-cache.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const npmCiRetryScript = readFileSync(
|
||||
resolve(process.cwd(), 'scripts/ci-npm-ci-with-retry.sh'),
|
||||
'utf8',
|
||||
@@ -137,6 +142,34 @@ function backendStepIndex(stepName: string) {
|
||||
}
|
||||
|
||||
describe('project CI workflow', () => {
|
||||
it('trials isolated compilation caching only in AGC Rust lane 1', () => {
|
||||
const lane = jobSection('ai-game-creator-shell-rust-lane-1');
|
||||
expect(lane).toContain('node --test scripts/ci-rust-cache.test.mjs');
|
||||
expect(lane.indexOf('bash scripts/ci-rust-cache.sh prepare')).toBeLessThan(
|
||||
lane.indexOf('npm run check:native-shells:agc-rust-shard-1'),
|
||||
);
|
||||
expect(
|
||||
stepSection(
|
||||
'ai-game-creator-shell-rust-lane-1',
|
||||
'Report isolated Rust compilation cache',
|
||||
),
|
||||
).toContain('if: always()');
|
||||
for (const job of jobNames.filter(
|
||||
(name) => name !== 'ai-game-creator-shell-rust-lane-1',
|
||||
)) {
|
||||
expect(jobSection(job)).not.toContain('ci-rust-cache.sh');
|
||||
}
|
||||
expect(workflow).toContain("CARGO_INCREMENTAL: '0'");
|
||||
expect(workflow).toContain("RUSTC_WRAPPER: ''");
|
||||
for (const [, name, value] of workflow
|
||||
.slice(0, workflow.indexOf('\njobs:'))
|
||||
.matchAll(/^ {2}(CARGO_[A-Z0-9_]+): '?([^'\n]*)'?$/gm)) {
|
||||
// wrapper 在 prepare 中选择,其余 Cargo 环境必须和预热一致。
|
||||
if (name === 'CARGO_BUILD_RUSTC_WRAPPER') continue;
|
||||
expect(rustCacheBuildScript).toContain(`${name}=${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('runs for master pushes, pull requests, and manual dispatch only', () => {
|
||||
expect(workflow).toMatch(
|
||||
/on:\n {2}push:\n {4}branches:\n {6}- master\n {2}pull_request:\n {2}workflow_dispatch:/u,
|
||||
@@ -397,7 +430,10 @@ describe('project CI workflow', () => {
|
||||
|
||||
it('keeps frontend, operations fixture, and native shell gates in dedicated jobs', () => {
|
||||
const frontendJob = jobSection('frontend-tests');
|
||||
expect(frontendJob).toContain('run: npm run test');
|
||||
expect(frontendJob).toMatch(/^ {8}run: npm run test:ci:frontend$/mu);
|
||||
expect(rootPackageJson.scripts?.['test:ci:frontend']).toBe(
|
||||
'vitest run --config vitest.frontend-ci.config.ts',
|
||||
);
|
||||
expect(frontendJob).toContain('run: npm run bgfilter-worker:smoke-test');
|
||||
expect(frontendJob).toContain(
|
||||
'run: npm run check:production-health-patrol',
|
||||
@@ -419,6 +455,39 @@ describe('project CI workflow', () => {
|
||||
expect(nativeJob).toContain('cargo fetch --locked');
|
||||
});
|
||||
|
||||
it('partitions frontend and AGC test files without gaps or duplicates', async () => {
|
||||
expect(rootPackageJson.scripts?.test).toBe('vitest run');
|
||||
const full = await createVitest('test', {
|
||||
config: resolve('vitest.config.ts'),
|
||||
watch: false,
|
||||
});
|
||||
try {
|
||||
const allFiles = (await full.globTestFiles()).map(([, file]) => file);
|
||||
const agcFiles = (
|
||||
await full.globTestFiles(['apps/ai-game-creator-shell/tests'])
|
||||
).map(([, file]) => file);
|
||||
const frontend = await createVitest('test', {
|
||||
config: resolve('vitest.frontend-ci.config.ts'),
|
||||
watch: false,
|
||||
});
|
||||
try {
|
||||
const frontendFiles = (await frontend.globTestFiles()).map(
|
||||
([, file]) => file,
|
||||
);
|
||||
expect(agcFiles.length).toBeGreaterThan(0);
|
||||
expect(frontendFiles.length).toBeGreaterThan(0);
|
||||
expect(frontendFiles.filter((file) => agcFiles.includes(file))).toEqual(
|
||||
[],
|
||||
);
|
||||
expect([...frontendFiles, ...agcFiles].sort()).toEqual(allFiles.sort());
|
||||
} finally {
|
||||
await frontend.close();
|
||||
}
|
||||
} finally {
|
||||
await full.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('runs every native shell gate group exactly once across the split jobs', () => {
|
||||
for (const [group, script] of Object.entries(nativeShellGateGroupScripts)) {
|
||||
expect(rootPackageJson.scripts?.[`check:native-shells:${group}`]).toBe(
|
||||
|
||||
Reference in New Issue
Block a user