276 lines
11 KiB
Bash
Executable File
276 lines
11 KiB
Bash
Executable File
#!/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" ]]
|
|
bootstrap_manifest=$tmp/bootstrap.json
|
|
RELEASE_SOURCE_SHA=$source_sha \
|
|
RELEASE_BASE_SHA= \
|
|
RELEASE_COMPONENTS=api \
|
|
RELEASE_MIGRATIONS_CHANGED=true \
|
|
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 "$bootstrap_manifest" >/dev/null
|
|
[[ -z $(node "$root/scripts/release-manifest.mjs" get "$bootstrap_manifest" baseReleaseSha) ]] || {
|
|
echo 'bootstrap manifest returned a non-empty production base' >&2
|
|
exit 1
|
|
}
|
|
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'
|