refactor(release): 改为 Agent 双阶段人工发布

删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
This commit is contained in:
2026-07-22 15:13:40 +08:00
parent cbebfd7baa
commit dae5d16a58
32 changed files with 1820 additions and 1990 deletions
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
script=$root/scripts/ci-build-images.sh
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/bin"
cat >"$tmp/bin/docker" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
printf 'docker %s\n' "$*" >>"$MOCK_LOG"
case "$*" in
'buildx inspect '*|'buildx create '*) exit 0 ;;
'buildx build '*)
if [[ ${MOCK_BUILD_FAIL:-0} == 1 && $* == *'--target web'* ]]; then
exit 21
fi
;;
'buildx prune '*)
[[ ${MOCK_PRUNE_FAIL:-0} == 0 ]] || exit 22
;;
'image rm '*)
[[ ${MOCK_IMAGE_RM_FAIL:-0} == 0 ]] || exit 23
;;
*)
printf 'unexpected docker invocation: %s\n' "$*" >&2
exit 99
;;
esac
MOCK
cat >"$tmp/bin/trivy" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
printf 'trivy %s\n' "$*" >>"$MOCK_LOG"
MOCK
chmod +x "$tmp/bin/docker" "$tmp/bin/trivy"
sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
registry=registry.example.com/easyai
api_image=$registry/ai-gateway:$sha
web_image=$registry/ai-gateway-web:$sha
run_build() {
env \
PATH="$tmp/bin:$PATH" \
MOCK_LOG="$tmp/calls.log" \
IMAGE_TAG="$sha" \
AI_GATEWAY_IMAGE_REGISTRY="$registry" \
AI_GATEWAY_BUILDX_BUILDER=gateway-test \
AI_GATEWAY_BUILDX_CACHE_LIMIT=2gb \
"$script" "$@"
}
: >"$tmp/calls.log"
run_build
grep -Fq "docker buildx prune --builder gateway-test --force --max-used-space 2gb" "$tmp/calls.log"
grep -Fq "docker image rm $api_image $web_image" "$tmp/calls.log"
grep -Fq 'trivy image --db-repository ghcr.m.daocloud.io/aquasecurity/trivy-db:2' \
"$tmp/calls.log"
: >"$tmp/calls.log"
run_build --keep-images
grep -Fq "docker buildx prune --builder gateway-test --force --max-used-space 2gb" "$tmp/calls.log"
if grep -Fq 'docker image rm ' "$tmp/calls.log"; then
echo '--keep-images must preserve fully built images' >&2
exit 1
fi
: >"$tmp/calls.log"
if MOCK_PRUNE_FAIL=1 run_build; then
echo 'a prune failure must fail an otherwise successful build' >&2
exit 1
fi
: >"$tmp/calls.log"
if MOCK_IMAGE_RM_FAIL=1 run_build; then
echo 'an image cleanup failure must fail an otherwise successful build' >&2
exit 1
fi
: >"$tmp/calls.log"
if MOCK_BUILD_FAIL=1 run_build; then
echo 'a failed image build must fail the script' >&2
exit 1
fi
grep -Fq "docker image rm $api_image $web_image" "$tmp/calls.log"
for invalid_limit in 0gb 2 2GB -1gb '2gb --all'; do
: >"$tmp/calls.log"
if env \
PATH="$tmp/bin:$PATH" \
MOCK_LOG="$tmp/calls.log" \
IMAGE_TAG="$sha" \
AI_GATEWAY_IMAGE_REGISTRY="$registry" \
AI_GATEWAY_BUILDX_CACHE_LIMIT="$invalid_limit" \
"$script" >/dev/null 2>&1; then
printf 'invalid cache limit was accepted: %s\n' "$invalid_limit" >&2
exit 1
fi
if [[ -s $tmp/calls.log ]]; then
printf 'invalid cache limit reached Docker: %s\n' "$invalid_limit" >&2
exit 1
fi
done
echo 'ci_build_images_tests=PASS'
+35 -228
View File
@@ -12,15 +12,12 @@ new_repo() {
mkdir -p "$repo/apps/api/migrations"
git -C "$repo" init -q
git -C "$repo" config user.name 'CI Migration Test'
git -C "$repo" config user.email 'ci-migration-test@example.invalid'
git -C "$repo" config user.name 'Release Migration Test'
git -C "$repo" config user.email 'release-migration-test@example.invalid'
printf 'CREATE TABLE accounts (id bigint PRIMARY KEY);\n' > \
"$repo/apps/api/migrations/0001_init.sql"
git -C "$repo" add apps/api/migrations/0001_init.sql
git -C "$repo" commit -qm baseline
mkdir -p "$repo/deploy/ci"
git -C "$repo" rev-parse HEAD > \
"$repo/deploy/ci/production-migration-base"
printf '%s\n' "$repo"
}
@@ -34,35 +31,19 @@ commit_all() {
expect_pass() {
local repo=$1
local base=$2
local immutable_base=${3:-}
(
cd "$repo"
if [[ -n $immutable_base ]]; then
node "$validator" "$base" "$immutable_base"
else
node "$validator" "$base"
fi
)
(cd "$repo" && node "$validator" "$base")
}
expect_fail() {
local repo=$1
local base=$2
local expected=$3
local immutable_base=${4:-}
local output status
if [[ -n $immutable_base ]]; then
set +e
output=$(cd "$repo" && node "$validator" "$base" "$immutable_base" 2>&1)
status=$?
set -e
else
set +e
output=$(cd "$repo" && node "$validator" "$base" 2>&1)
status=$?
set -e
fi
set +e
output=$(cd "$repo" && node "$validator" "$base" 2>&1)
status=$?
set -e
if [[ $status -eq 0 ]]; then
printf 'migration validator unexpectedly passed: %s\n' "$expected" >&2
exit 1
@@ -76,7 +57,7 @@ expect_fail() {
repo=$(new_repo safe)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_expand_accounts.sql" <<'SQL'
-- A comment mentioning DROP TABLE must not trigger the gate.
-- DROP in a comment and string is not executable.
ALTER TABLE accounts ADD COLUMN display_name text;
CREATE TABLE audit_events (id bigint PRIMARY KEY, note text);
INSERT INTO audit_events (id, note) VALUES (1, 'DROP TABLE is documentation');
@@ -85,171 +66,37 @@ SQL
commit_all "$repo" safe
expect_pass "$repo" "$base"
repo=$(new_repo drop)
base=$(git -C "$repo" rev-parse HEAD)
printf 'DROP\nTABLE accounts;\n' >"$repo/apps/api/migrations/0002_drop.sql"
commit_all "$repo" drop
expect_fail "$repo" "$base" 'destructive DROP operation'
repo=$(new_repo delete)
base=$(git -C "$repo" rev-parse HEAD)
printf 'DELETE FROM accounts;\n' >"$repo/apps/api/migrations/0002_delete.sql"
commit_all "$repo" delete
expect_fail "$repo" "$base" 'DELETE FROM operation'
repo=$(new_repo incompatible_alter)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_alter.sql" <<'SQL'
ALTER TABLE accounts ADD COLUMN external_id text NOT NULL;
ALTER TABLE accounts ALTER COLUMN id TYPE numeric;
SQL
commit_all "$repo" alter
expect_fail "$repo" "$base" 'non-null column addition'
expect_fail "$repo" "$base" 'column type change'
repo=$(new_repo dynamic_sql)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_dynamic.sql" <<'SQL'
DO $$
BEGIN
EXECUTE 'DROP ' || 'TABLE accounts';
END
$$;
SQL
commit_all "$repo" dynamic
expect_fail "$repo" "$base" 'dynamic SQL execution'
repo=$(new_repo escape_string)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_escape.sql" <<'SQL'
SELECT E'foo\'bar'; DROP TABLE accounts; --';
SQL
commit_all "$repo" escape
expect_fail "$repo" "$base" 'PostgreSQL escape/unicode strings are not allowed'
repo=$(new_repo string_semantics_override)
base=$(git -C "$repo" rev-parse HEAD)
printf 'SET standard_conforming_strings = off;\n' > \
"$repo/apps/api/migrations/0002_string_mode.sql"
commit_all "$repo" string_mode
expect_fail "$repo" "$base" 'SQL string semantics override'
repo=$(new_repo transaction_control)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_commit.sql" <<'SQL'
ALTER TABLE accounts ADD COLUMN display_name text;
COMMIT;
UPDATE accounts SET display_name = 'partially committed';
SQL
commit_all "$repo" transaction_control
expect_fail "$repo" "$base" 'transaction control operation'
repo=$(new_repo bare_end_transaction)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_end.sql" <<'SQL'
ALTER TABLE accounts ADD COLUMN display_name text;
END;
UPDATE accounts SET display_name = 'partially committed';
SQL
commit_all "$repo" bare_end_transaction
expect_fail "$repo" "$base" 'transaction control operation'
repo=$(new_repo plpgsql_block_end)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_function.sql" <<'SQL'
CREATE FUNCTION account_count() RETURNS bigint
LANGUAGE plpgsql AS $$
BEGIN
RETURN (SELECT count(*) FROM accounts);
END;
$$;
SQL
commit_all "$repo" plpgsql_block_end
expect_fail "$repo" "$base" 'procedural SQL body'
repo=$(new_repo ordinary_string_procedural_body)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_do_string.sql" <<'SQL'
DO LANGUAGE plpgsql 'BEGIN DROP TABLE accounts; END';
SQL
commit_all "$repo" ordinary_string_procedural_body
expect_fail "$repo" "$base" 'procedural SQL body'
repo=$(new_repo ordinary_string_boundary)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_string_boundary.sql" <<'SQL'
SELECT '\'; DROP TABLE accounts; --';
SQL
commit_all "$repo" ordinary_string
expect_fail "$repo" "$base" 'destructive DROP operation'
repo=$(new_repo unicode_string)
base=$(git -C "$repo" rev-parse HEAD)
printf "SELECT U&'d\\0061ta';\n" >"$repo/apps/api/migrations/0002_unicode.sql"
commit_all "$repo" unicode
expect_fail "$repo" "$base" 'PostgreSQL escape/unicode strings are not allowed'
repo=$(new_repo merge_delete)
base=$(git -C "$repo" rev-parse HEAD)
cat >"$repo/apps/api/migrations/0002_merge.sql" <<'SQL'
MERGE INTO accounts USING stale_accounts ON accounts.id = stale_accounts.id
WHEN MATCHED THEN DELETE;
SQL
commit_all "$repo" merge
expect_fail "$repo" "$base" 'MERGE operation'
repo=$(new_repo replace_view)
base=$(git -C "$repo" rev-parse HEAD)
printf 'CREATE OR REPLACE VIEW active_accounts AS SELECT id FROM accounts;\n' > \
"$repo/apps/api/migrations/0002_replace.sql"
commit_all "$repo" replace
expect_fail "$repo" "$base" 'CREATE OR REPLACE operation'
repo=$(new_repo rename_function)
base=$(git -C "$repo" rev-parse HEAD)
printf 'ALTER FUNCTION calculate_total() RENAME TO calculate_total_legacy;\n' > \
"$repo/apps/api/migrations/0002_rename.sql"
commit_all "$repo" rename
expect_fail "$repo" "$base" 'object or column rename'
repo=$(new_repo set_schema)
base=$(git -C "$repo" rev-parse HEAD)
printf 'ALTER TABLE accounts SET SCHEMA archive;\n' > \
"$repo/apps/api/migrations/0002_schema.sql"
commit_all "$repo" schema
expect_fail "$repo" "$base" 'SET SCHEMA operation'
repo=$(new_repo drop_domain)
base=$(git -C "$repo" rev-parse HEAD)
printf 'DROP DOMAIN account_code CASCADE;\n' > \
"$repo/apps/api/migrations/0002_drop_domain.sql"
commit_all "$repo" drop_domain
expect_fail "$repo" "$base" 'destructive DROP operation'
repo=$(new_repo immutable)
base=$(git -C "$repo" rev-parse HEAD)
printf '\n-- retroactive edit\n' >>"$repo/apps/api/migrations/0001_init.sql"
commit_all "$repo" edit
commit_all "$repo" immutable
expect_fail "$repo" "$base" 'applied migrations are immutable (status M)'
repo=$(new_repo immutable_after_baseline)
repo=$(new_repo removed)
base=$(git -C "$repo" rev-parse HEAD)
printf 'ALTER TABLE accounts ADD COLUMN nickname text;\n' > \
"$repo/apps/api/migrations/0002_nickname.sql"
commit_all "$repo" add
immutable_base=$(git -C "$repo" rev-parse HEAD)
printf '\nUPDATE accounts SET nickname = NULL;\n' >> \
"$repo/apps/api/migrations/0002_nickname.sql"
commit_all "$repo" modify
output=$(
cd "$repo"
node "$validator" "$base" "$immutable_base" 2>&1 || true
)
grep -Fq 'migration present on main is immutable (status M)' <<<"$output" || {
printf 'stale production baseline did not preserve main migration immutability:\n%s\n' \
"$output" >&2
exit 1
}
git -C "$repo" rm -q apps/api/migrations/0001_init.sql
git -C "$repo" commit -qm removed
expect_fail "$repo" "$base" 'applied migrations are immutable (status D)'
repo=$(new_repo destructive)
base=$(git -C "$repo" rev-parse HEAD)
printf 'DROP TABLE accounts;\n' >"$repo/apps/api/migrations/0002_drop.sql"
commit_all "$repo" destructive
expect_fail "$repo" "$base" 'destructive DROP operation'
repo=$(new_repo incompatible)
base=$(git -C "$repo" rev-parse HEAD)
printf 'ALTER TABLE accounts ADD COLUMN external_id text NOT NULL;\n' > \
"$repo/apps/api/migrations/0002_incompatible.sql"
commit_all "$repo" incompatible
expect_fail "$repo" "$base" 'non-null column addition'
repo=$(new_repo transaction)
base=$(git -C "$repo" rev-parse HEAD)
printf 'ALTER TABLE accounts ADD COLUMN nickname text; COMMIT;\n' > \
"$repo/apps/api/migrations/0002_transaction.sql"
commit_all "$repo" transaction
expect_fail "$repo" "$base" 'transaction control operation'
repo=$(new_repo filename)
base=$(git -C "$repo" rev-parse HEAD)
@@ -263,41 +110,10 @@ printf 'SELECT 1;\n' >"$repo/apps/api/migrations/0000_late.sql"
commit_all "$repo" ordering
expect_fail "$repo" "$base" 'new migration name must sort after 0001_init.sql'
repo=$(new_repo stale_baseline)
base=$(git -C "$repo" rev-parse HEAD)
printf 'ALTER TABLE accounts ADD COLUMN nickname text;\n' > \
"$repo/apps/api/migrations/0002_nickname.sql"
commit_all "$repo" main_migration
immutable_base=$(git -C "$repo" rev-parse HEAD)
printf 'ordinary code change\n' >"$repo/README.md"
commit_all "$repo" code_only
expect_pass "$repo" "$base" "$immutable_base"
repo=$(new_repo baseline_advance)
production_base=$(git -C "$repo" rev-parse HEAD)
mkdir -p "$repo/deploy/ci"
printf '%s\n' "$production_base" >"$repo/deploy/ci/production-migration-base"
commit_all "$repo" baseline_file
immutable_base=$(git -C "$repo" rev-parse HEAD)
git -C "$repo" switch -q -c release-baseline-update "$immutable_base"
printf '%s\n' "$immutable_base" >"$repo/deploy/ci/production-migration-base"
commit_all "$repo" valid_baseline_advance
expect_pass "$repo" "$immutable_base" "$immutable_base"
git -C "$repo" switch -q -c malicious-baseline "$immutable_base"
printf 'DROP TABLE accounts;\n' >"$repo/apps/api/migrations/0002_drop.sql"
commit_all "$repo" hidden_destructive_migration
hidden_sha=$(git -C "$repo" rev-parse HEAD)
printf '%s\n' "$hidden_sha" >"$repo/deploy/ci/production-migration-base"
commit_all "$repo" hide_migration_in_baseline
expect_fail "$repo" "$hidden_sha" \
'production migration baseline must belong to the immutable event base history' \
"$immutable_base"
repo=$(new_repo nested)
base=$(git -C "$repo" rev-parse HEAD)
mkdir -p "$repo/apps/api/migrations/archive"
printf 'SELECT 1;\n' >"$repo/apps/api/migrations/archive/9999_hidden.sql"
printf 'SELECT 1;\n' >"$repo/apps/api/migrations/archive/0002_hidden.sql"
commit_all "$repo" nested
expect_fail "$repo" "$base" 'new migration must use NNNN_lowercase_name.sql'
@@ -308,22 +124,13 @@ ln -s ../../../outside.sql "$repo/apps/api/migrations/0002_symlink.sql"
commit_all "$repo" symlink
expect_fail "$repo" "$base" 'new migration must be a mode 100644 regular file'
repo=$(new_repo baseline_symlink)
base=$(git -C "$repo" rev-parse HEAD)
printf '%s\n' "$base" >"$repo/baseline-target"
rm "$repo/deploy/ci/production-migration-base"
ln -s ../../baseline-target "$repo/deploy/ci/production-migration-base"
commit_all "$repo" baseline_symlink
expect_fail "$repo" "$base" \
'production migration baseline must be a mode 100644 regular file'
repo=$(new_repo ancestry)
base=$(git -C "$repo" rev-parse HEAD)
git -C "$repo" switch -q --orphan unrelated
printf 'unrelated\n' >"$repo/unrelated"
git -C "$repo" add unrelated
git -C "$repo" commit -qm unrelated
expect_fail "$repo" "$base" 'production migration baseline is not an ancestor of HEAD'
expect_fail "$repo" "$base" 'release base is not an ancestor of HEAD'
repo=$(new_repo invalid_sha)
expect_fail "$repo" deadbeef 'must be a full lowercase commit SHA'
-365
View File
@@ -1,365 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016 # Assertions intentionally match literal workflow variables.
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
workflow=$root/.gitea/workflows/ci.yml
release_workflow=$root/.gitea/workflows/release-ci.yml
runner_service=$root/deploy/ci/easyai-gateway-ci-v2-runner.service
runner_config=$root/deploy/ci/act-runner-v2-config.yaml
provision=$root/scripts/provision-ci-runner.sh
build_images=$root/scripts/ci-build-images.sh
validate_semver=$root/scripts/ci-validate-semver.sh
validate_migrations=$root/scripts/ci-validate-migrations.mjs
build_images_test=$root/tests/ci/ci-build-images-test.sh
migrations_test=$root/tests/ci/migrations-test.sh
migration_base=$root/deploy/ci/production-migration-base
adr=$root/docs/decisions/001-production-cicd.md
runbook=$root/docs/runbooks/production-ci-cd.md
migrator=$root/apps/api/cmd/migrate/main.go
assert_bounded_go_verification() {
local quality_workflow=$1
awk '
/^[[:space:]]+GOFLAGS:/ { all_goflags++ }
/^[[:space:]]+GOMAXPROCS:/ { all_gomaxprocs++ }
/^ - name: Verify Go code$/ {
target_steps++
in_target=1
in_env=0
next
}
/^ - / {
in_target=0
in_env=0
}
in_target && /^ env:$/ {
env_blocks++
in_env=1
next
}
in_target && in_env && /^ GOFLAGS: "-p=1"$/ { target_goflags++ }
in_target && in_env && /^ GOMAXPROCS: "1"$/ { target_gomaxprocs++ }
in_target && in_env && /^ [^ ]/ { in_env=0 }
END {
exit !(target_steps == 1 && env_blocks == 1 &&
target_goflags == 1 && target_gomaxprocs == 1 &&
all_goflags == 1 && all_gomaxprocs == 1)
}
' "$quality_workflow" || {
printf '%s must limit Go parallelism exactly once in the Verify Go code step\n' \
"$quality_workflow" >&2
exit 1
}
}
for file in "$workflow" "$release_workflow" "$runner_service" "$runner_config" "$provision" "$build_images" "$validate_semver" "$validate_migrations" "$build_images_test" "$migrations_test" "$migration_base" "$adr" "$runbook" "$migrator"; do
[[ -f $file ]] || {
printf 'missing CI/CD file: %s\n' "$file" >&2
exit 1
}
done
if [[ $(wc -l <"$migration_base") -ne 1 ]]; then
echo 'production migration baseline must contain exactly one line' >&2
exit 1
fi
grep -Fq 'SET standard_conforming_strings = on' "$migrator" || {
echo 'database migrator does not pin standard SQL string semantics' >&2
exit 1
}
if ! grep -Eq '^[0-9a-f]{40}$' "$migration_base"; then
echo 'production migration baseline must be a full lowercase SHA' >&2
exit 1
fi
grep -q '^name: ci$' "$workflow"
grep -q '^ push:$' "$workflow"
grep -q '^ branches: \[main\]$' "$workflow"
grep -q '^ pull_request:$' "$workflow"
grep -q '^ verify:$' "$workflow"
grep -Fq 'git checkout --detach "$CI_SHA"' "$workflow"
grep -Fq 'test "$(git rev-parse HEAD)" = "$CI_SHA"' "$workflow"
if grep -Fq 'git checkout --detach FETCH_HEAD' "$workflow"; then
echo 'main/PR CI must not checkout mutable FETCH_HEAD after comparison fetches' >&2
exit 1
fi
if grep -Eq "^[[:space:]]+tags:|Verify release tag ancestry" "$workflow" || \
grep -Fq './scripts/ci-validate-semver.sh "$tag_name"' "$workflow"; then
echo 'main/PR CI must not share the release-tag context' >&2
exit 1
fi
grep -q '^name: release-ci$' "$release_workflow"
grep -q '^ push:$' "$release_workflow"
grep -q "^ tags: \['v\*'\]$" "$release_workflow"
grep -q '^ verify-tag:$' "$release_workflow"
grep -q '^ - name: Verify release tag ancestry$' "$release_workflow"
grep -Fq './scripts/ci-validate-semver.sh "$tag_name"' "$release_workflow"
grep -Fq 'git merge-base --is-ancestor "$CI_SHA" refs/remotes/origin/main' "$release_workflow"
if grep -Eq 'branches:|pull_request:' "$release_workflow"; then
echo 'release CI must be a tag-only workflow/context' >&2
exit 1
fi
for quality_workflow in "$workflow" "$release_workflow"; do
grep -q '^ runs-on: easyai-gateway-ci-unprivileged-v2$' "$quality_workflow"
grep -q '^ - name: Checkout without external Actions$' "$quality_workflow"
if grep -q -- '--depth=' "$quality_workflow"; then
echo 'quality verification must not use a shallow checkout' >&2
exit 1
fi
grep -Fq 'test ! -f .git/shallow' "$quality_workflow"
grep -Fq './tests/ci/ci-build-images-test.sh' "$quality_workflow"
grep -Fq './tests/ci/migrations-test.sh' "$quality_workflow"
grep -Fq './tests/ci/pipeline-test.sh' "$quality_workflow"
grep -Fq './tests/ci/semver-test.sh' "$quality_workflow"
grep -Fq 'docker-compose version' "$quality_workflow"
grep -Fq 'docker-compose -f docker-compose.yml config --quiet' "$quality_workflow"
grep -Fq 'services:' "$quality_workflow"
grep -Fq 'image: docker.io/library/postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777' "$quality_workflow"
grep -Fq 'POSTGRES_HOST_AUTH_METHOD: trust' "$quality_workflow"
grep -Fq 'AI_GATEWAY_TEST_DATABASE_URL: postgresql://easyai_test@postgres:5432/easyai_gateway_test?sslmode=disable' "$quality_workflow"
grep -Fq 'run: go run ./cmd/migrate' "$quality_workflow"
if grep -Fq 'docker compose' "$quality_workflow"; then
echo 'job container must use the read-only standalone Compose binary' >&2
exit 1
fi
for forbidden in \
'sudo' \
'/usr/local/sbin/easyai-ai-gateway-release' \
'docker version' \
'docker buildx' \
'./scripts/ci-build-images.sh --keep-images' \
'Deploy verified release to Production'; do
if grep -Fq "$forbidden" "$quality_workflow"; then
printf 'unprivileged workflow contains forbidden capability: %s\n' "$forbidden" >&2
exit 1
fi
done
if grep -Eq '^[[:space:]]+\./scripts/ci-build-images\.sh([[:space:]]|$)' "$quality_workflow"; then
echo 'unprivileged workflow must not build container images' >&2
exit 1
fi
for gate in \
'scripts/ci-validate-migrations.mjs' \
'gofmt -l' \
'go vet ./...' \
'go test ./...' \
'pnpm install --frozen-lockfile' \
'pnpm lint' \
'pnpm test' \
'pnpm build' \
'pnpm audit --audit-level high' \
'docker-compose -f docker-compose.yml config --quiet' \
'govulncheck ./...' \
'trivy fs'; do
grep -Fq "$gate" "$quality_workflow" || {
printf '%s is missing quality gate: %s\n' "$quality_workflow" "$gate" >&2
exit 1
}
done
grep -Fq 'TRIVY_DB_REPOSITORY: ghcr.m.daocloud.io/aquasecurity/trivy-db:2' \
"$quality_workflow" || {
printf '%s does not pin the reachable Trivy vulnerability DB mirror\n' \
"$quality_workflow" >&2
exit 1
}
assert_bounded_go_verification "$quality_workflow"
grep -Fq 'production_base=$(cat deploy/ci/production-migration-base)' \
"$quality_workflow" || {
printf '%s does not read the versioned production migration baseline\n' \
"$quality_workflow" >&2
exit 1
}
done
if [[ $(sed -n '/^ - name: Verify Go formatting$/,$p' "$workflow") != \
"$(sed -n '/^ - name: Verify Go formatting$/,$p' "$release_workflow")" ]]; then
echo 'main/PR and release-tag workflows must have identical quality gates' >&2
exit 1
fi
grep -Fq 'CI_EVENT_BEFORE: ${{ github.event.before }}' "$workflow"
grep -Fq 'CI_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}' "$workflow"
grep -Fq 'git merge-base --is-ancestor "$immutable_base" HEAD' "$workflow"
grep -Fq '"$production_base" "$immutable_base"' "$workflow"
grep -Fq 'node ./scripts/ci-validate-migrations.mjs "$production_base"' \
"$release_workflow"
runner_image='docker.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a'
job_image='docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7'
grep -q '^User=root$' "$runner_service"
grep -q '^Group=root$' "$runner_service"
grep -q '^NoNewPrivileges=true$' "$runner_service"
grep -q '^ProtectSystem=strict$' "$runner_service"
grep -q '^ProtectHome=true$' "$runner_service"
grep -q '^CapabilityBoundingSet=$' "$runner_service"
grep -Fq 'Requires=docker.service' "$runner_service"
grep -Fq -- "--pull=never" "$runner_service"
grep -Fq -- "--privileged" "$runner_service"
grep -Fq -- '--pids-limit 1024' "$runner_service"
grep -Fq -- '--cpus 2' "$runner_service"
grep -Fq -- '--memory 2g' "$runner_service"
grep -Fq -- '--memory-swap 3g' "$runner_service"
grep -Fq -- '--env DOCKERD_ROOTLESS_ROOTLESSKIT_FLAGS=--pidns' "$runner_service"
grep -Fq 'EnvironmentFile=-/etc/easyai-gateway-ci-v2/runner.env' "$runner_service"
grep -Fq '$RUNNER_SECURITY_OPTIONS' "$runner_service"
grep -Fq -- "--volume easyai-gateway-ci-v2-data:/data" "$runner_service"
grep -Fq -- "--volume /etc/easyai-gateway-ci-v2/config.yaml:/config.yaml:ro" "$runner_service"
grep -Fq -- "--volume /etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro" "$runner_service"
grep -Fq -- "--volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro" "$runner_service"
grep -Fq 'Environment=RUNNER_IMAGE_REF=' "$runner_service"
grep -Fq '$RUNNER_IMAGE_REF' "$runner_service"
if grep -Eq 'GITEA_RUNNER_REGISTRATION_TOKEN|/var/run/docker\.sock|/run/docker\.sock' "$runner_service"; then
echo 'persistent runner service exposes a registration token or host Docker socket' >&2
exit 1
fi
if grep -Fq 'apparmor=rootlesskit' "$runner_service"; then
echo 'runner service must not hard-code an AppArmor profile that may be absent' >&2
exit 1
fi
grep -Fq "easyai-gateway-ci-unprivileged-v2:docker://$job_image" "$runner_config"
if grep -Fq ':host' "$runner_config"; then
echo 'CI runner must never use the host executor' >&2
exit 1
fi
grep -q '^ capacity: 1$' "$runner_config"
grep -q '^ file: /data/.runner$' "$runner_config"
grep -q '^ docker_host: "-"$' "$runner_config"
grep -q '^ privileged: false$' "$runner_config"
grep -q '^ require_docker: true$' "$runner_config"
grep -q '^ bind_workdir: false$' "$runner_config"
grep -Fq ' options: "--volume /opt/easyai-gateway-ci:/opt/easyai-gateway-ci:ro"' "$runner_config"
grep -q '^ valid_volumes:$' "$runner_config"
grep -q '^ - "/opt/easyai-gateway-ci"$' "$runner_config"
if awk '
/^ valid_volumes:$/ { in_volumes=1; next }
in_volumes && /^ - / { count++; next }
in_volumes && /^ [A-Za-z_]+:/ { in_volumes=0 }
END { exit count == 1 ? 0 : 1 }
' "$runner_config"; then
:
else
echo 'runner must allow exactly one workflow volume source' >&2
exit 1
fi
if grep -Eq '/var/run/docker\.sock|/run/docker\.sock' "$runner_config"; then
echo 'job configuration must not expose a Docker socket' >&2
exit 1
fi
if grep -Eq 'apparmor=unconfined|sysctl .*(apparmor_restrict_unprivileged_userns)' "$runner_service" "$runner_config" "$provision"; then
echo 'runner must not disable AppArmor isolation or its user-namespace restriction' >&2
exit 1
fi
grep -q '^GITEA_RUNNER_VERSION=2.0.0$' "$provision"
grep -q '^GO_VERSION=1.26.5$' "$provision"
grep -q '^NODE_VERSION=24.16.0$' "$provision"
grep -q '^TRIVY_VERSION=0.70.0$' "$provision"
grep -q '^PREFIX=/opt/easyai-gateway-ci$' "$provision"
grep -q '^RUNNER_VOLUME=easyai-gateway-ci-v2-data$' "$provision"
grep -q '^SHELLCHECK_VERSION=0.11.0$' "$provision"
grep -q '^SHELLCHECK_SHA256=8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198$' "$provision"
grep -q '^COMPOSE_VERSION=5.3.1$' "$provision"
grep -q '^COMPOSE_SHA256=f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959$' "$provision"
grep -Fq 'RUNNER_NAME=${RUNNER_NAME:-easyai-gateway-ci-unprivileged-v2}' "$provision"
grep -Fq "RUNNER_IMAGE='$runner_image'" "$provision"
grep -Fq 'RUNNER_IMAGE_SOURCE=${CI_RUNNER_IMAGE_SOURCE:-$RUNNER_IMAGE}' "$provision"
grep -Fq 'runner_manifest_digest=${RUNNER_IMAGE##*@}' "$provision"
grep -Fq 'CI_RUNNER_IMAGE_SOURCE must use the pinned runner manifest digest' "$provision"
grep -Fq 'runner_runtime_image=$(docker image inspect' "$provision"
grep -Fq 'RUNNER_IMAGE_REF="%s"' "$provision"
grep -Fq "JOB_IMAGE='$job_image'" "$provision"
grep -Fq 'RUNNER_LABEL="easyai-gateway-ci-unprivileged-v2:docker://$JOB_IMAGE"' "$provision"
grep -Fq 'docker volume create "$RUNNER_VOLUME"' "$provision"
grep -Fq '"data-root":"/data/docker"' "$provision"
grep -Fq -- '--security-opt apparmor=rootlesskit' "$provision"
grep -Fq 'RUNNER_SECURITY_OPTIONS="--security-opt=apparmor=rootlesskit"' "$provision"
grep -Fq 'RUNNER_SECURITY_OPTIONS=' "$provision"
grep -Fq '/proc/sys/kernel/apparmor_restrict_unprivileged_userns' "$provision"
grep -Fq -- '--entrypoint dockerd-entrypoint.sh "$runner_runtime_image"' "$provision"
grep -Fq '/etc/easyai-gateway-ci-v2/daemon.json:/home/rootless/.config/docker/daemon.json:ro' "$provision"
grep -Fq 'CI_NESTED_DOCKER_REGISTRY_MIRROR must be an HTTPS registry origin' "$provision"
grep -Fq 'PROBE_CONTAINER="easyai-gateway-ci-v2-probe-$$"' "$provision"
grep -Fq -- '--pids-limit "$RUNNER_PIDS_LIMIT"' "$provision"
grep -Fq -- '--cpus "$RUNNER_CPUS"' "$provision"
grep -Fq -- '--memory "$RUNNER_MEMORY"' "$provision"
grep -Fq -- '--memory-swap "$RUNNER_MEMORY_SWAP"' "$provision"
grep -Fq "'2147483648:3221225472:2000000000:1024'" "$provision"
grep -Fq -- '--entrypoint /usr/local/bin/gitea-runner' "$provision"
grep -Fq 'shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz' "$provision"
grep -Fq 'docker-compose-linux-x86_64' "$provision"
grep -Fq 'install -m 0755 "$PREFIX/downloads/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "$PREFIX/bin/shellcheck"' "$provision"
grep -Fq 'install -m 0755 "$PREFIX/downloads/docker-compose-${COMPOSE_VERSION}-linux-x86_64" "$PREFIX/bin/docker-compose"' "$provision"
grep -Fq 'PATH="$PREFIX/toolchains/node/bin:$PATH"' "$provision"
grep -Fq 'gpasswd -d easyai-gateway-ci-v2 docker' "$provision"
grep -Fq 'gpasswd -d easyai-gateway-ci-v2 sudo' "$provision"
if grep -Fq 'usermod -aG docker' "$provision"; then
echo 'unprivileged runner must never join the Docker group' >&2
exit 1
fi
grep -Fq 'systemctl disable --now easyai-gateway-act-runner.service' "$provision"
grep -Fq 'systemctl disable --now easyai-gateway-ci-v2-runner.service' "$provision"
grep -Fq 'RUNNER_REGISTRATION_TOKEN is required for first registration' "$provision"
grep -Fq 'printf '\''%s\n%s\n%s\n'\'' "$GITEA_INSTANCE_URL" "$RUNNER_REGISTRATION_TOKEN" "$RUNNER_NAME"' "$provision"
if grep -Fq -- '--token "$RUNNER_REGISTRATION_TOKEN"' "$provision"; then
echo 'registration token must not be exposed in a host-visible process argv' >&2
exit 1
fi
grep -Fq "grep -Fq 'name=rootless'" "$provision"
grep -Fq "grep -Fq 'declare successfully'" "$provision"
grep -Fq 'docker buildx prune --builder "$LEGACY_BUILDER" --force --max-used-space 512mb' "$provision"
grep -Fq 'require_free_space "$MIN_FREE_GIB" "runner image pull"' "$provision"
grep -Fq 'require_free_space "$MIN_POST_INSTALL_FREE_GIB" "completed CI runner installation"' "$provision"
grep -Fq 'CI_RUNNER_MIN_FREE_GIB cannot be lower than 8' "$provision"
grep -Fq 'CI_RUNNER_MIN_POST_INSTALL_FREE_GIB cannot be lower than 4' "$provision"
grep -Fq 'docker exec "$PROBE_CONTAINER" docker pull "$JOB_IMAGE"' "$provision"
grep -Fq 'docker exec "$PROBE_CONTAINER" docker run --rm --pull=never' "$provision"
grep -Fq -- '--volume "$RUNNER_VOLUME:/data"' "$provision"
grep -Fq -- '--volume "$PREFIX:$PREFIX:ro"' "$provision"
probe_pull_line=$(grep -nF 'docker exec "$PROBE_CONTAINER" docker pull "$JOB_IMAGE"' "$provision" | cut -d: -f1)
registration_line=$(grep -nF 'if ! runner_is_registered; then' "$provision" | cut -d: -f1)
[[ -n $probe_pull_line && -n $registration_line && $probe_pull_line -lt $registration_line ]] || {
echo 'pinned job image and isolation probes must complete before runner registration' >&2
exit 1
}
grep -Fq 'nested --pid=host can see the outer runner PID namespace' "$provision"
grep -Fq 'chown -R root:root "$PREFIX"' "$provision"
grep -Fq 'chmod -R go-w "$PREFIX"' "$provision"
if grep -Eq 'SHELLCHECK_BIN|COMPOSE_PLUGIN|command -v shellcheck|docker compose version' "$provision"; then
echo 'runner provisioning must use checksum-pinned static CI tools, not host copies' >&2
exit 1
fi
if grep -Eq 'docker (system|builder|image|volume) prune' "$provision"; then
echo 'runner provisioning must not globally prune shared Docker state' >&2
exit 1
fi
grep -Fq 'root-owned dispatcher' "$adr"
grep -Fq 'release-ci / verify-tag (push)' "$adr"
grep -Fq '来自 Fork 的 PR 必须先由维护者检查工作流变更并在 Gitea 中批准' "$adr"
grep -Fq 'rootless DinD 不是 VM 沙箱' "$runbook"
grep -Fq '同时等待 `ci / verify (push)` 与本次新产生的 `release-ci / verify-tag (push)`' "$runbook"
grep -Fq '不得执行 Tag checkout 中的' "$runbook"
grep -Fq 'docker buildx build --builder "$builder"' "$build_images"
grep -q -- '--target api' "$build_images"
grep -q -- '--target web' "$build_images"
grep -Fq 'docker buildx prune --builder "$builder" --force --max-used-space "$cache_limit"' "$build_images"
grep -Fq 'trivy image' "$build_images"
grep -Fq 'docker image rm "$api_image" "$web_image"' "$build_images"
grep -Fq 'AI_GATEWAY_BUILDX_CACHE_LIMIT has an invalid format' "$build_images"
if grep -En '(password|secret|token):[[:space:]]+[^$<{]' "$workflow" "$release_workflow"; then
echo 'workflow contains a literal credential' >&2
exit 1
fi
echo 'gateway_ci_pipeline_tests=PASS'
-52
View File
@@ -1,52 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
validator=$root/scripts/ci-validate-semver.sh
valid_tags=(
v0.0.0
v1.2.3
v1.2.3-alpha
v1.2.3-alpha.1
v1.2.3-0.3.7
v1.2.3-01a
v1.2.3-alpha-beta
v1.2.3--
v1.2.3-x.7.z.92+build.5
v1.2.3+build.11.e0f985a
v1.2.3+001
)
invalid_tags=(
1.2.3
v01.2.3
v1.02.3
v1.2.03
v1.2.3.foo
v1.2.3.
v1.2.3-.
v1.2.3-alpha.
v1.2.3-alpha..1
v1.2.3-01
v1.2.3-00.1
v1.2.3+
v1.2.3+build.
v1.2.3+build..1
v1.2.3+build+1
'v1.2.3 '
v1.2.3-alpha_1
v1.2
)
for tag in "${valid_tags[@]}"; do
"$validator" "$tag"
done
for tag in "${invalid_tags[@]}"; do
if "$validator" "$tag" >/dev/null 2>&1; then
printf 'invalid SemVer tag was accepted: %s\n' "$tag" >&2
exit 1
fi
done
echo 'semver_tests=PASS'
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
new_repo() {
local name=$1
local repo=$tmp/$name
mkdir -p "$repo/apps/api/migrations" "$repo/apps/web/src" "$repo/packages/contracts/src" "$repo/docs"
git -C "$repo" init -q
git -C "$repo" config user.name 'Manual Release Test'
git -C "$repo" config user.email 'manual-release-test@example.invalid'
printf 'package api\n' >"$repo/apps/api/main.go"
printf 'SELECT 1;\n' >"$repo/apps/api/migrations/0001_init.sql"
printf 'web\n' >"$repo/apps/web/src/app.ts"
printf 'contracts\n' >"$repo/packages/contracts/src/index.ts"
# shellcheck disable=SC2016
printf 'ARG API_RUNTIME_IMAGE=scratch\nARG WEB_RUNTIME_IMAGE=scratch\nFROM ${API_RUNTIME_IMAGE} AS api\nCOPY apps/api /app\nFROM ${WEB_RUNTIME_IMAGE} AS web\nCOPY apps/web /web\n' >"$repo/Dockerfile"
printf 'docs\n' >"$repo/docs/readme.md"
git -C "$repo" add -A
git -C "$repo" commit -qm baseline
printf '%s\n' "$repo"
}
classify() {
local repo=$1
local base=$2
(cd "$repo" && node "$root/scripts/release-components.mjs" "$base")
}
repo=$(new_repo api)
base=$(git -C "$repo" rev-parse HEAD)
printf 'package api\n// changed\n' >"$repo/apps/api/main.go"
git -C "$repo" add -A && git -C "$repo" commit -qm api
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="api"||value.migrationsChanged)process.exit(1)' "$result"
repo=$(new_repo web)
base=$(git -C "$repo" rev-parse HEAD)
printf 'web changed\n' >"$repo/apps/web/src/app.ts"
git -C "$repo" add -A && git -C "$repo" commit -qm web
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="web")process.exit(1)' "$result"
repo=$(new_repo all)
base=$(git -C "$repo" rev-parse HEAD)
printf 'node_modules\n' >"$repo/.dockerignore"
git -C "$repo" add -A && git -C "$repo" commit -qm all
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="all")process.exit(1)' "$result"
repo=$(new_repo api-docker-stage)
base=$(git -C "$repo" rev-parse HEAD)
# shellcheck disable=SC2016
printf 'ARG API_RUNTIME_IMAGE=scratch\nARG WEB_RUNTIME_IMAGE=scratch\nFROM ${API_RUNTIME_IMAGE} AS api\nCOPY apps/api /app\nRUN echo api\nFROM ${WEB_RUNTIME_IMAGE} AS web\nCOPY apps/web /web\n' >"$repo/Dockerfile"
git -C "$repo" add -A && git -C "$repo" commit -qm api-docker-stage
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="api")process.exit(1)' "$result"
repo=$(new_repo web-docker-stage)
base=$(git -C "$repo" rev-parse HEAD)
printf '\nRUN echo web\n' >>"$repo/Dockerfile"
git -C "$repo" add -A && git -C "$repo" commit -qm web-docker-stage
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="web")process.exit(1)' "$result"
repo=$(new_repo migration)
base=$(git -C "$repo" rev-parse HEAD)
printf 'ALTER TABLE demo ADD COLUMN note text;\n' >"$repo/apps/api/migrations/0002_note.sql"
git -C "$repo" add -A && git -C "$repo" commit -qm migration
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="api"||!value.migrationsChanged)process.exit(1)' "$result"
repo=$(new_repo docs)
base=$(git -C "$repo" rev-parse HEAD)
printf 'docs changed\n' >"$repo/docs/readme.md"
git -C "$repo" add -A && git -C "$repo" commit -qm docs
result=$(classify "$repo" "$base")
node -e 'const value=JSON.parse(process.argv[1]); if(value.components!=="none")process.exit(1)' "$result"
bare=$tmp/origin.git
git init -q --bare "$bare"
repo=$(new_repo preflight)
git -C "$repo" branch -M main
git -C "$repo" remote add origin "$bare"
git -C "$repo" push -q -u origin main
(cd "$repo" && node "$root/scripts/release-preflight.mjs" >/dev/null)
printf 'dirty\n' >>"$repo/docs/readme.md"
if (cd "$repo" && node "$root/scripts/release-preflight.mjs" >/dev/null 2>&1); then
echo 'dirty worktree passed release preflight' >&2
exit 1
fi
git -C "$repo" restore docs/readme.md
printf 'untracked\n' >"$repo/untracked"
if (cd "$repo" && node "$root/scripts/release-preflight.mjs" >/dev/null 2>&1); then
echo 'untracked file passed release preflight' >&2
exit 1
fi
rm "$repo/untracked"
git -C "$repo" switch -q -c feature
printf 'feature\n' >>"$repo/docs/readme.md"
git -C "$repo" add -A && git -C "$repo" commit -qm feature
if (cd "$repo" && node "$root/scripts/release-preflight.mjs" >/dev/null 2>&1); then
echo 'non-main commit passed release preflight' >&2
exit 1
fi
manifest=$tmp/release.json
recorded=$tmp/recorded.json
source_sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
base_sha=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
api_image=registry.example.com/easyai/ai-gateway@sha256:$(printf '1%.0s' {1..64})
web_image=registry.example.com/easyai/ai-gateway-web@sha256:$(printf '2%.0s' {1..64})
RELEASE_SOURCE_SHA=$source_sha \
RELEASE_BASE_SHA=$base_sha \
RELEASE_COMPONENTS=api \
RELEASE_MIGRATIONS_CHANGED=false \
RELEASE_API_IMAGE=$api_image \
RELEASE_WEB_IMAGE=$web_image \
RELEASE_BUILD_COMPLETED_AT=2026-07-22T00:00:00Z \
RELEASE_SMOKE_COMPLETED_AT=2026-07-22T00:00:00Z \
node "$root/scripts/release-manifest.mjs" create "$manifest" >/dev/null
node "$root/scripts/release-manifest.mjs" validate "$manifest" >/dev/null
[[ $(node "$root/scripts/release-manifest.mjs" get "$manifest" images.api) == "$api_image" ]]
RELEASE_DEPLOYED_AT=2026-07-22T00:01:00Z \
RELEASE_DEPLOY_DURATION_SECONDS=17 \
RELEASE_DEPLOY_ACTION=deploy \
node "$root/scripts/release-manifest.mjs" record-deployment "$manifest" "$recorded" >/dev/null
node "$root/scripts/release-manifest.mjs" validate "$recorded" >/dev/null
sensitive=$tmp/sensitive.json
node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1])); value.accessToken="forbidden"; fs.writeFileSync(process.argv[2], JSON.stringify(value))' "$manifest" "$sensitive"
if node "$root/scripts/release-manifest.mjs" validate "$sensitive" >/dev/null 2>&1; then
echo 'sensitive manifest field passed validation' >&2
exit 1
fi
tampered=$tmp/tampered.json
node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1])); value.images.api=value.images.api.replace(/^registry[.]/, "mirror."); fs.writeFileSync(process.argv[2], JSON.stringify(value))' "$manifest" "$tampered"
if node "$root/scripts/release-manifest.mjs" validate "$tampered" >/dev/null 2>&1; then
echo 'tampered manifest content passed validation' >&2
exit 1
fi
incomplete_digest=$tmp/incomplete-digest.json
node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync(process.argv[1])); value.images.web="registry.example.com/easyai/ai-gateway-web:tag"; fs.writeFileSync(process.argv[2], JSON.stringify(value))' "$manifest" "$incomplete_digest"
if node "$root/scripts/release-manifest.mjs" validate "$incomplete_digest" >/dev/null 2>&1; then
echo 'tag-based image passed manifest validation' >&2
exit 1
fi
if grep -Eqi 'password|secret|token|credential|private.?key' "$manifest"; then
echo 'generated manifest contains a sensitive field name' >&2
exit 1
fi
publisher_repo=$tmp/publisher
mkdir -p "$publisher_repo/scripts" "$publisher_repo/apps/api" "$publisher_repo/fake-bin"
cp "$root/scripts/publish-release-images.sh" \
"$root/scripts/release-preflight.mjs" \
"$root/scripts/release-manifest.mjs" \
"$root/scripts/release-components.mjs" \
"$root/scripts/ci-validate-migrations.mjs" \
"$publisher_repo/scripts/"
printf 'FROM scratch\n' >"$publisher_repo/Dockerfile"
printf 'module example.invalid/release-test\n\ngo 1.26\n' >"$publisher_repo/apps/api/go.mod"
cat >"$publisher_repo/fake-bin/docker" <<'EOF'
#!/usr/bin/env bash
case "$*" in
info|'buildx version'|'compose version') exit 0 ;;
'buildx imagetools inspect '*)
if [[ ${FAKE_DOCKER_MODE:-} == registry ]]; then
echo 'unauthorized: authentication required' >&2
else
echo 'manifest unknown: not found' >&2
fi
exit 1
;;
'run --rm --platform linux/amd64 '*) exit 1 ;;
esac
echo "unexpected fake docker call: $*" >&2
exit 1
EOF
cat >"$publisher_repo/fake-bin/ssh" <<'EOF'
#!/usr/bin/env bash
exit 1
EOF
chmod +x "$publisher_repo/scripts/"* "$publisher_repo/fake-bin/"*
git -C "$publisher_repo" init -q
git -C "$publisher_repo" config user.name 'Manual Release Test'
git -C "$publisher_repo" config user.email 'manual-release-test@example.invalid'
git -C "$publisher_repo" add -A
git -C "$publisher_repo" commit -qm baseline
git -C "$publisher_repo" branch -M main
publisher_bare=$tmp/publisher-origin.git
git init -q --bare "$publisher_bare"
git -C "$publisher_repo" remote add origin "$publisher_bare"
git -C "$publisher_repo" push -q -u origin main
if (cd "$publisher_repo" && PATH="$publisher_repo/fake-bin:$PATH" \
./scripts/publish-release-images.sh --components auto >/dev/null 2>&1); then
echo 'first release guessed components in auto mode' >&2
exit 1
fi
if (cd "$publisher_repo" && PATH="$publisher_repo/fake-bin:$PATH" FAKE_DOCKER_MODE=registry \
./scripts/publish-release-images.sh --components all >/dev/null 2>&1); then
echo 'publisher passed without registry access' >&2
exit 1
fi
if (cd "$publisher_repo" && PATH="$publisher_repo/fake-bin:$PATH" FAKE_DOCKER_MODE=platform \
./scripts/publish-release-images.sh --components all >/dev/null 2>&1); then
echo 'publisher passed without linux/amd64 execution support' >&2
exit 1
fi
current_manifest=$tmp/current.json
RELEASE_SOURCE_SHA=cccccccccccccccccccccccccccccccccccccccc \
RELEASE_BASE_SHA=dddddddddddddddddddddddddddddddddddddddd \
RELEASE_COMPONENTS=web \
RELEASE_MIGRATIONS_CHANGED=false \
RELEASE_API_IMAGE=$api_image \
RELEASE_WEB_IMAGE=$web_image \
RELEASE_BUILD_COMPLETED_AT=2026-07-22T00:02:00Z \
RELEASE_SMOKE_COMPLETED_AT=2026-07-22T00:02:00Z \
node "$root/scripts/release-manifest.mjs" create "$current_manifest" >/dev/null
stale_bin=$tmp/stale-bin
mkdir -p "$stale_bin"
cat >"$stale_bin/ssh" <<'EOF'
#!/usr/bin/env bash
if [[ $* == *' status' ]]; then
cat "$STALE_STATUS_FILE"
fi
exit 0
EOF
cat >"$stale_bin/scp" <<'EOF'
#!/usr/bin/env bash
: >"$STALE_SCP_MARKER"
exit 0
EOF
chmod +x "$stale_bin/"*
stale_marker=$tmp/stale-scp-called
if PATH="$stale_bin:$PATH" STALE_STATUS_FILE=$current_manifest STALE_SCP_MARKER=$stale_marker \
"$root/scripts/deploy-production-release.sh" "$manifest" >/dev/null 2>&1; then
echo 'stale production base passed deploy client validation' >&2
exit 1
fi
[[ ! -e $stale_marker ]] || {
echo 'stale deploy uploaded a manifest' >&2
exit 1
}
if find "$root/.gitea/workflows" -type f -print -quit 2>/dev/null | grep -q .; then
echo 'automatic Gitea workflow still exists' >&2
exit 1
fi
grep -Fq 'stale release manifest' "$root/deploy/manual/easyai-ai-gateway-release"
grep -Fq 'production_changed=false' "$root/scripts/publish-release-images.sh"
echo 'manual_release_tests=PASS'