ci: 补齐NapCat运行时镜像构建下游Job

This commit is contained in:
sunlei 2026-06-26 10:03:18 +08:00
parent 1e39a431b7
commit 114eef3195
5 changed files with 289 additions and 2 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules/
dist/
.turbo/
.kt-workspace/
*.log

View File

@ -59,9 +59,10 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
- `ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile` 是 NapCatQQ 上游审计入口,默认只调用 ktWorkflow 的 `napcat-upstream-audit` dry-run保留人工审查边界。
- Codex 上游审计必须同时设置 `RUN_CODEX_AUDIT=true` 并填写 `CONFIRM_CODEX_WORKSPACE_WRITE=RUN_CODEX_WORKSPACE_WRITE_AUDIT`;这是因为 Codex runner 使用 workspace-write sandbox不能作为定时任务默认行为。
- `ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile` 是运行时发布入口,先调用 `napcat-runtime-release-readiness`,再通过显式参数触发运行时镜像构建和 API promotion下游 job 使用白名单 choice避免任意 Jenkins job 名被传入。
- `ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile` 是运行时发布入口,先调用 `napcat-runtime-release-readiness`,再通过显式参数触发运行时镜像构建和 API promotion下游 job 使用白名单 choice避免任意 Jenkins job 名被传入,并把 upstream release commit / NapCat base image digest 透传到镜像构建证据。
- `ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile` 是运行时镜像构建下游,只从 NAS 本地裸仓库 clone 经过审查的 NapCatQQ fork ref构建 `NapCat.Shell`,调用 API 仓库 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 staged context执行 `docker build` 和容器内 `/ci/napcat-desktop-cn/verify.sh`;它不触发 API promotion、不执行 `kubectl`、不合并或推送 Git。
- 生产 API promotion 除了 `PROMOTE_API_RUNTIME=true` 和明确 image/profile 参数外,还必须填写 `CONFIRM_API_PROMOTION_TARGET=PROMOTE_KT_TEMPLATE_API_MAIN`
- 个模板默认使用 NAS 工作区 `/vol1/docker/kt-codex/workspace/KT`,运行证据落在 `/vol1/docker/kt-codex/artifacts` 下。
- 个模板默认使用 NAS 工作区 `/vol1/docker/kt-codex/workspace/KT`,运行证据落在 `/vol1/docker/kt-codex/artifacts` 下。
- 模板只保存入口和参数形状,不记录 Jenkins credential、Token、SSH key 或其他真实凭据。
## MCP 客户端配置

View File

@ -0,0 +1,190 @@
pipeline {
agent { label 'kt-node-agent' }
options {
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '30'))
disableConcurrentBuilds()
skipDefaultCheckout(true)
timestamps()
timeout(time: 90, unit: 'MINUTES')
}
parameters {
string(name: 'NAPCAT_FORK_REF', defaultValue: 'main', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: 'unknown', description: 'Reviewed upstream release tag for marker metadata.')
string(name: 'UPSTREAM_RELEASE_COMMIT', defaultValue: '5c18a62530d87dbadf53d267002894faa6ca7e90', description: 'Reviewed upstream release commit for marker metadata.')
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build, for example kt-napcat-desktop-cn:desktop-cn-v9.')
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit desktop profile version paired with the runtime image tag.')
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', defaultValue: 'mlikiowa/napcat-docker@sha256:9254ec12af101576c5eeb4910847abd1d219297bc6d9a35c52511e12500f0f45', description: 'Immutable NapCat base image digest used for Docker build.')
string(name: 'KT_ARTIFACT_ROOT', defaultValue: '/vol1/docker/kt-codex/artifacts/napcat-runtime-release', description: 'NAS artifact root for runtime release evidence.')
}
environment {
KT_WORKSPACE_ROOT = '/vol1/docker/kt-codex/workspace/KT'
API_ROOT = '/vol1/docker/kt-codex/workspace/KT/Node/kt-template-online-api'
NAPCAT_GIT_REPOSITORY = '/vol1/docker/kt-codex/git/NapCatQQ.git'
KT_WORKFLOW_RUNNER_IMAGE = 'kt-jenkins-node-agent:current'
}
stages {
stage('Validate parameters') {
steps {
script {
requireRuntimeImageParameters(
params.NAPCAT_FORK_REF,
params.UPSTREAM_RELEASE_COMMIT,
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE,
params.NAPCAT_BASE_IMAGE_DIGEST
)
requireImageProfileMatch(
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE
)
}
}
}
stage('Build and verify runtime image') {
steps {
sh '''
set -e
docker run --rm \
-e API_ROOT="$API_ROOT" \
-e BUILD_TAG="$BUILD_TAG" \
-e BUILD_URL="$BUILD_URL" \
-e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
-e KT_WORKSPACE_ROOT="$KT_WORKSPACE_ROOT" \
-e NAPCAT_BASE_IMAGE_DIGEST="$NAPCAT_BASE_IMAGE_DIGEST" \
-e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \
-e NAPCAT_GIT_REPOSITORY="$NAPCAT_GIT_REPOSITORY" \
-e QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \
-e QQBOT_NAPCAT_IMAGE_OVERRIDE="$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
-e UPSTREAM_RELEASE_COMMIT="$UPSTREAM_RELEASE_COMMIT" \
-e UPSTREAM_RELEASE_TAG="$UPSTREAM_RELEASE_TAG" \
-v /vol1/docker/kt-codex:/vol1/docker/kt-codex \
-v /var/run/docker.sock:/var/run/docker.sock \
--entrypoint bash "$KT_WORKFLOW_RUNNER_IMAGE" -lc '
set -euo pipefail
source /vol1/docker/kt-codex/runtime/nvm/nvm.sh
corepack prepare pnpm@10.28.2 --activate
ARTIFACTS_BASE_REAL=$(realpath -m /vol1/docker/kt-codex/artifacts)
ARTIFACT_ROOT_REAL=$(realpath -m "$KT_ARTIFACT_ROOT")
case "$ARTIFACT_ROOT_REAL" in
"$ARTIFACTS_BASE_REAL"/*) ;;
*)
echo "KT_ARTIFACT_ROOT must stay under /vol1/docker/kt-codex/artifacts" >&2
exit 2
;;
esac
BUILD_ID_SAFE=$(printf "%s" "${BUILD_TAG:-manual}" | tr "/ " "__")
ARTIFACT_DIR="$ARTIFACT_ROOT_REAL/runtime-image-build"
BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"
NAPCAT_ROOT="$BUILD_WORKDIR/NapCatQQ"
STAGE_OUT=".kt-workspace/napcat-desktop-cn-build-runtime-image"
VERIFY_NAME="kt-napcat-runtime-image-verify-$BUILD_ID_SAFE"
cleanup() {
docker rm -f "$VERIFY_NAME" >/dev/null 2>&1 || true
rm -rf "$BUILD_WORKDIR"
}
trap cleanup EXIT
rm -rf "$ARTIFACT_DIR" "$BUILD_WORKDIR"
mkdir -p "$ARTIFACT_DIR" "$BUILD_WORKDIR"
git clone "$NAPCAT_GIT_REPOSITORY" "$NAPCAT_ROOT"
cd "$NAPCAT_ROOT"
git checkout --detach "$NAPCAT_FORK_REF"
git rev-parse HEAD > "$ARTIFACT_DIR/fork-commit.txt"
git status --short > "$ARTIFACT_DIR/fork-status.txt"
pnpm install --frozen-lockfile
pnpm --filter napcat-webui-frontend run build
pnpm run build:shell
pnpm run build:framework
cd "$API_ROOT"
node scripts/napcat-desktop-cn-stage-build.mjs \
--napcat-root "$NAPCAT_ROOT" \
--out "$STAGE_OUT" \
--upstream-release-tag "$UPSTREAM_RELEASE_TAG" \
--upstream-release-commit "$UPSTREAM_RELEASE_COMMIT" \
--napcat-base-image-digest "$NAPCAT_BASE_IMAGE_DIGEST" \
--jenkins-build-url "$BUILD_URL" \
| tee "$ARTIFACT_DIR/stage-build.json"
cp "$STAGE_OUT/ci/napcat-desktop-cn/fork-artifact.json" "$ARTIFACT_DIR/fork-artifact.json"
docker build \
--build-arg NAPCAT_BASE_IMAGE="$NAPCAT_BASE_IMAGE_DIGEST" \
-t "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
-f "$STAGE_OUT/ci/napcat-desktop-cn/Dockerfile" \
"$STAGE_OUT"
docker run -d --name "$VERIFY_NAME" "$QQBOT_NAPCAT_IMAGE_OVERRIDE" >/dev/null
sleep 3
docker exec "$VERIFY_NAME" sh /ci/napcat-desktop-cn/verify.sh
docker image inspect "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
--format "tag={{index .RepoTags 0}} id={{.Id}} size={{.Size}} created={{.Created}} repoDigests={{json .RepoDigests}}" \
| tee "$ARTIFACT_DIR/image.txt"
'
'''
}
}
}
post {
always {
sh '''
set +e
docker run --rm \
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
-e WORKSPACE="$WORKSPACE" \
-v /vol1/docker/kt-codex:/vol1/docker/kt-codex \
-v kt-node-agent-workdir:/home/jenkins/agent \
--entrypoint bash "$KT_WORKFLOW_RUNNER_IMAGE" -lc '
set +e
rm -rf "$WORKSPACE/artifacts"
mkdir -p "$WORKSPACE/artifacts/napcat-runtime-image-build"
cp -a "$KT_ARTIFACT_ROOT/runtime-image-build/." "$WORKSPACE/artifacts/napcat-runtime-image-build/" 2>/dev/null || true
'
'''
archiveArtifacts artifacts: 'artifacts/napcat-runtime-image-build/**', allowEmptyArchive: true, fingerprint: false
}
}
}
/**
* Stops runtime image builds unless all immutable release parameters are present.
* @param forkRef Reviewed fork branch, tag, or commit that is cloned into the disposable build workspace.
* @param upstreamReleaseCommit Reviewed upstream release commit stored in the image marker.
* @param imageTag Reviewed image tag that will be created on NAS Docker.
* @param profile Reviewed desktop profile version paired with the image tag.
* @param baseImageDigest Immutable upstream/base Docker image digest in `repo@sha256` form.
* @return Nothing; aborts this pipeline through `error` when required values are missing.
*/
void requireRuntimeImageParameters(String forkRef, String upstreamReleaseCommit, String imageTag, String profile, String baseImageDigest) {
if (!forkRef?.trim()) {
error('NAPCAT_FORK_REF is required before runtime image build.')
}
if (!upstreamReleaseCommit?.trim()) {
error('UPSTREAM_RELEASE_COMMIT is required before runtime image build.')
}
if (!imageTag?.trim()) {
error('QQBOT_NAPCAT_IMAGE_OVERRIDE is required before runtime image build.')
}
if (!profile?.trim()) {
error('QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE is required before runtime image build.')
}
if (!baseImageDigest?.trim()?.contains('@sha256:')) {
error('NAPCAT_BASE_IMAGE_DIGEST must be an immutable repo@sha256 digest.')
}
}
/**
* Ensures the promoted image tag and desktop profile cannot drift silently.
* @param imageTag Runtime image tag, expected to end with the profile tag suffix.
* @param profile Runtime desktop profile value passed to API promotion.
* @return Nothing; aborts this pipeline through `error` when values drift.
*/
void requireImageProfileMatch(String imageTag, String profile) {
if (!imageTag?.trim()?.endsWith(":${profile?.trim()}")) {
error('QQBOT_NAPCAT_IMAGE_OVERRIDE tag suffix must match QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE.')
}
}

View File

@ -12,6 +12,8 @@ pipeline {
parameters {
string(name: 'NAPCAT_FORK_REF', defaultValue: 'main', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: '', description: 'Reviewed upstream release tag, when applicable.')
string(name: 'UPSTREAM_RELEASE_COMMIT', defaultValue: '5c18a62530d87dbadf53d267002894faa6ca7e90', description: 'Reviewed upstream release commit used for runtime marker metadata.')
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', defaultValue: 'mlikiowa/napcat-docker@sha256:9254ec12af101576c5eeb4910847abd1d219297bc6d9a35c52511e12500f0f45', description: 'Immutable NapCat base image digest used by the runtime image build.')
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build or promote, for example kt-napcat-desktop-cn:desktop-cn-v9.')
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit API desktop profile version override.')
booleanParam(name: 'BUILD_RUNTIME_IMAGE', defaultValue: false, description: 'When true, trigger the reviewed Jenkins runtime image build/publish job.')
@ -38,6 +40,8 @@ pipeline {
-e KT_ARTIFACT_ROOT="$KT_ARTIFACT_ROOT" \
-e NAPCAT_FORK_REF="$NAPCAT_FORK_REF" \
-e UPSTREAM_RELEASE_TAG="$UPSTREAM_RELEASE_TAG" \
-e UPSTREAM_RELEASE_COMMIT="$UPSTREAM_RELEASE_COMMIT" \
-e NAPCAT_BASE_IMAGE_DIGEST="$NAPCAT_BASE_IMAGE_DIGEST" \
-e QQBOT_NAPCAT_IMAGE_OVERRIDE="$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
-e QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE="$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE" \
-e COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack \
@ -72,6 +76,8 @@ pipeline {
parameters: [
string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF),
string(name: 'UPSTREAM_RELEASE_TAG', value: params.UPSTREAM_RELEASE_TAG),
string(name: 'UPSTREAM_RELEASE_COMMIT', value: params.UPSTREAM_RELEASE_COMMIT),
string(name: 'NAPCAT_BASE_IMAGE_DIGEST', value: params.NAPCAT_BASE_IMAGE_DIGEST),
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', value: params.QQBOT_NAPCAT_IMAGE_OVERRIDE),
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', value: params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE),
string(name: 'KT_ARTIFACT_ROOT', value: "${env.KT_ARTIFACT_ROOT}/napcat-runtime-release")

View File

@ -524,6 +524,12 @@ export async function runSelfTest(): Promise<void> {
),
"utf8",
).replace(/\r\n/g, "\n");
const runtimeImageBuildJenkinsTemplate = readFileSync(
resolveInsideRoot(
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile",
),
"utf8",
).replace(/\r\n/g, "\n");
for (const expected of [
"RUN_CODEX_AUDIT",
"CONFIRM_CODEX_WORKSPACE_WRITE",
@ -626,6 +632,10 @@ export async function runSelfTest(): Promise<void> {
runtimeReleaseJenkinsTemplate,
"NapCat runtime",
);
assertNapCatJenkinsUsesNasAgent(
runtimeImageBuildJenkinsTemplate,
"NapCat runtime image build",
);
/**
* Verifies that a NapCat Jenkins template runs ktWorkflow inside a helper container with NAS mounts.
* @param template - Full Jenkinsfile text that Jenkins will execute.
@ -658,6 +668,44 @@ export async function runSelfTest(): Promise<void> {
runtimeReleaseJenkinsTemplate,
"NapCat runtime",
);
assertNapCatJenkinsUsesWorkflowRunner(
runtimeImageBuildJenkinsTemplate,
"NapCat runtime image build",
);
/**
* Ensures destructive Jenkins artifact cleanup is bounded by canonical NAS paths.
* @param template - Full Jenkinsfile text whose shell guard will run before `rm -rf`.
* @param name - Human-readable template name for the self-test failure.
* @returns Nothing; throws when the guard accepts raw `..` path prefixes.
*/
const assertNapCatJenkinsCanonicalArtifactRoot = (
template: string,
name: string,
): void => {
for (const expected of [
'ARTIFACTS_BASE_REAL=$(realpath -m /vol1/docker/kt-codex/artifacts)',
'ARTIFACT_ROOT_REAL=$(realpath -m "$KT_ARTIFACT_ROOT")',
'case "$ARTIFACT_ROOT_REAL" in',
'"$ARTIFACTS_BASE_REAL"/*) ;;',
'ARTIFACT_DIR="$ARTIFACT_ROOT_REAL/runtime-image-build"',
'BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"',
]) {
if (!template.includes(expected)) {
throw new Error(
`${name} Jenkins template must canonicalize artifact roots before cleanup: ${expected}`,
);
}
}
if (template.includes('case "$KT_ARTIFACT_ROOT" in')) {
throw new Error(
`${name} Jenkins template must not validate destructive cleanup paths with a raw prefix check`,
);
}
};
assertNapCatJenkinsCanonicalArtifactRoot(
runtimeImageBuildJenkinsTemplate,
"NapCat runtime image build",
);
for (const forbidden of ["git merge", "git push", "docker build", "kubectl"]) {
if (upstreamSyncJenkinsTemplate.includes(forbidden)) {
throw new Error(
@ -666,6 +714,8 @@ export async function runSelfTest(): Promise<void> {
}
}
for (const expected of [
"UPSTREAM_RELEASE_COMMIT",
"NAPCAT_BASE_IMAGE_DIGEST",
"choice(name: 'RUNTIME_BUILD_JOB'",
"choice(name: 'API_PROMOTION_JOB'",
"CONFIRM_API_PROMOTION_TARGET",
@ -748,6 +798,45 @@ export async function runSelfTest(): Promise<void> {
"NapCat runtime Jenkins API promotion confirmation must run before API build job",
);
}
for (const expected of [
"NAPCAT_FORK_REF",
"NAPCAT_GIT_REPOSITORY = '/vol1/docker/kt-codex/git/NapCatQQ.git'",
"QQBOT_NAPCAT_IMAGE_OVERRIDE",
"QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE",
"NAPCAT_BASE_IMAGE_DIGEST",
"requireRuntimeImageParameters(",
"requireImageProfileMatch(",
"-v /var/run/docker.sock:/var/run/docker.sock",
"git clone \"$NAPCAT_GIT_REPOSITORY\" \"$NAPCAT_ROOT\"",
"BUILD_WORKDIR=\"$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE\"",
"KT_ARTIFACT_ROOT must stay under /vol1/docker/kt-codex/artifacts",
"scripts/napcat-desktop-cn-stage-build.mjs",
"--napcat-root \"$NAPCAT_ROOT\"",
"--napcat-base-image-digest \"$NAPCAT_BASE_IMAGE_DIGEST\"",
"docker build",
"docker exec \"$VERIFY_NAME\" sh /ci/napcat-desktop-cn/verify.sh",
"archiveArtifacts artifacts: 'artifacts/napcat-runtime-image-build/**'",
]) {
if (!runtimeImageBuildJenkinsTemplate.includes(expected)) {
throw new Error(
`NapCat runtime image build Jenkins template missing: ${expected}`,
);
}
}
for (const forbidden of [
"PROMOTE_API_RUNTIME",
"CONFIRM_API_PROMOTION_TARGET",
"kubectl",
"git push",
"git merge",
"/vol1/docker/kt-codex/workspace/KT/GitHub/NapCatQQ",
]) {
if (runtimeImageBuildJenkinsTemplate.includes(forbidden)) {
throw new Error(
`NapCat runtime image build Jenkins template forbidden text leaked: ${forbidden}`,
);
}
}
const codexExecutionArtifactRoot = resolveInsideRoot(
".kt-workspace/test-artifacts/napcat-codex-runner-self-test",
);