#!/usr/bin/env bash # Black-box smoke test for a Memos release image. # # By default, the script builds the current worktree as a local Docker image. # Pass --candidate-image to test an image that has already been built. set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" candidate_image="${MEMOS_SMOKE_CANDIDATE_IMAGE:-}" previous_image="${MEMOS_SMOKE_PREVIOUS_IMAGE:-}" keep_resources="${MEMOS_SMOKE_KEEP_RESOURCES:-0}" usage() { cat <<'EOF' Usage: ./scripts/release_smoke_test.sh [options] Runs fresh-install and previous-stable upgrade smoke tests against a Memos Docker image. With no options, the current worktree is built and tested. Options: --candidate-image IMAGE Test an existing image instead of building locally. --previous-image IMAGE Image used to seed the upgrade test. By default, the latest stable Git tag before HEAD is used. --keep-resources Keep containers and volumes after the test for debugging. -h, --help Show this help text. Environment equivalents: MEMOS_SMOKE_CANDIDATE_IMAGE MEMOS_SMOKE_PREVIOUS_IMAGE MEMOS_SMOKE_KEEP_RESOURCES=1 Examples: ./scripts/release_smoke_test.sh ./scripts/release_smoke_test.sh \ --candidate-image memos-smoke:local \ --previous-image neosmemo/memos:0.29.1 EOF } log() { printf '\n==> %s\n' "$*" } die() { printf 'error: %s\n' "$*" >&2 exit 1 } while (($# > 0)); do case "$1" in --candidate-image) (($# >= 2)) || die "--candidate-image requires a value" candidate_image="$2" shift 2 ;; --previous-image) (($# >= 2)) || die "--previous-image requires a value" previous_image="$2" shift 2 ;; --keep-resources) keep_resources=1 shift ;; -h|--help) usage exit 0 ;; *) usage >&2 die "unknown option: $1" ;; esac done for command_name in curl docker git jq; do command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required" done docker info >/dev/null 2>&1 || die "Docker is not running" run_id="${GITHUB_RUN_ID:-local}-$(date +%s)-$$" fresh_container="memos-smoke-fresh-$run_id" upgrade_old_container="memos-smoke-upgrade-old-$run_id" upgrade_new_container="memos-smoke-upgrade-new-$run_id" fresh_volume="memos-smoke-fresh-$run_id" upgrade_volume="memos-smoke-upgrade-$run_id" temp_dir="$(mktemp -d)" frontend_index="$REPO_ROOT/server/router/frontend/dist/index.html" frontend_index_backup="$temp_dir/frontend-index.html" frontend_index_existed=0 frontend_index_backed_up=0 restore_frontend_index() { if [[ "$frontend_index_backed_up" != "1" ]]; then return fi if [[ "$frontend_index_existed" == "1" ]]; then cp "$frontend_index_backup" "$frontend_index" else rm -f "$frontend_index" fi frontend_index_backed_up=0 } container_exists() { docker container inspect "$1" >/dev/null 2>&1 } cleanup() { local status=$? trap - EXIT restore_frontend_index if ((status != 0)); then for container_name in "$fresh_container" "$upgrade_old_container" "$upgrade_new_container"; do if container_exists "$container_name"; then printf '\n--- docker logs: %s ---\n' "$container_name" >&2 docker logs "$container_name" >&2 || true fi done fi if [[ "$keep_resources" == "1" ]]; then printf '\nKept smoke-test resources for debugging:\n' printf ' containers: %s %s %s\n' "$fresh_container" "$upgrade_old_container" "$upgrade_new_container" printf ' volumes: %s %s\n' "$fresh_volume" "$upgrade_volume" else docker rm -f "$fresh_container" "$upgrade_old_container" "$upgrade_new_container" >/dev/null 2>&1 || true docker volume rm "$fresh_volume" "$upgrade_volume" >/dev/null 2>&1 || true fi rm -rf "$temp_dir" exit "$status" } trap cleanup EXIT detect_previous_image() { local head_sha tag tag_sha head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" while IFS= read -r tag; do case "$tag" in *-rc.*) continue ;; esac tag_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$tag")" if [[ "$tag_sha" == "$head_sha" ]]; then continue fi if git -C "$REPO_ROOT" merge-base --is-ancestor "$tag" HEAD; then printf 'neosmemo/memos:%s\n' "${tag#v}" return fi done < <(git -C "$REPO_ROOT" tag --list 'v[0-9]*' --sort=-version:refname) die "could not detect a previous stable release; pass --previous-image" } build_local_candidate() { local commit_sha image_tag command -v pnpm >/dev/null 2>&1 || die "pnpm is required to build the local candidate" commit_sha="$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD)" image_tag="memos-smoke:${commit_sha}-${run_id}" mkdir -p "$(dirname "$frontend_index")" if [[ -f "$frontend_index" ]]; then cp "$frontend_index" "$frontend_index_backup" frontend_index_existed=1 fi frontend_index_backed_up=1 log "Building frontend release assets" ( cd "$REPO_ROOT/web" pnpm release ) log "Building candidate image $image_tag" docker build \ --file "$REPO_ROOT/scripts/Dockerfile" \ --build-arg VERSION=smoke-local \ --build-arg COMMIT="$commit_sha" \ --tag "$image_tag" \ "$REPO_ROOT" restore_frontend_index candidate_image="$image_tag" } ensure_image() { local image="$1" if docker image inspect "$image" >/dev/null 2>&1; then return fi log "Pulling image $image" docker pull "$image" } wait_until_ready() { local container_name="$1" local base_url="$2" local attempt for attempt in $(seq 1 90); do if curl --fail --silent --show-error --max-time 2 "$base_url/healthz" >/dev/null 2>&1; then return fi if [[ "$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true)" != "true" ]]; then break fi sleep 2 done docker logs "$container_name" >&2 || true die "$container_name did not become ready" } current_base_url="" set_current_base_url() { local container_name="$1" local port_mapping port port_mapping="$(docker port "$container_name" 5230/tcp)" port="${port_mapping##*:}" [[ -n "$port" ]] || die "could not determine the host port for $container_name" current_base_url="http://127.0.0.1:$port" } start_container() { local container_name="$1" local image="$2" local volume="$3" docker run --detach \ --name "$container_name" \ --label "org.usememos.release-smoke=$run_id" \ --publish "127.0.0.1::5230" \ --env MEMOS_MODE=prod \ --env MEMOS_INSTANCE_URL=http://localhost \ --mount "type=volume,source=$volume,target=/var/opt/memos" \ "$image" >/dev/null set_current_base_url "$container_name" wait_until_ready "$container_name" "$current_base_url" } assert_frontend_assets() { local html asset_path asset_url html="$(curl --fail --silent --show-error "$current_base_url/")" grep -q 'id="root"' <<<"$html" || die "frontend root element was not served" asset_path="$(grep -o 'src="[^"]*\.js"' <<<"$html" | sed -n '1{s/^src="//;s/"$//;p;}' || true)" [[ -n "$asset_path" ]] || die "frontend JavaScript asset was not found" case "$asset_path" in http://*|https://*) asset_url="$asset_path" ;; /*) asset_url="$current_base_url$asset_path" ;; *) asset_url="$current_base_url/$asset_path" ;; esac curl --fail --silent --show-error "$asset_url" >/dev/null } create_admin() { local payload response payload="$(jq -nc '{username:"smoke-admin",password:"smoke-password",email:"smoke@example.test"}')" response="$(curl --fail --silent --show-error \ --header 'Content-Type: application/json' \ --data "$payload" \ "$current_base_url/api/v1/users")" jq -e '.username == "smoke-admin" and .role == "ADMIN"' <<<"$response" >/dev/null } sign_in() { local payload payload="$(jq -nc '{passwordCredentials:{username:"smoke-admin",password:"smoke-password"}}')" curl --fail --silent --show-error \ --header 'Content-Type: application/json' \ --data "$payload" \ "$current_base_url/api/v1/auth/signin" | jq -er '.accessToken' } create_memo() { local token="$1" local memo_id="$2" local content="$3" local payload response payload="$(jq -nc --arg content "$content" '{content:$content,visibility:"PRIVATE"}')" response="$(curl --fail --silent --show-error \ --header "Authorization: Bearer $token" \ --header 'Content-Type: application/json' \ --data "$payload" \ "$current_base_url/api/v1/memos?memoId=$memo_id")" jq -e --arg name "memos/$memo_id" --arg content "$content" '.name == $name and .content == $content' <<<"$response" >/dev/null } assert_memo() { local token="$1" local memo_id="$2" local content="$3" curl --fail --silent --show-error \ --header "Authorization: Bearer $token" \ "$current_base_url/api/v1/memos/$memo_id" | jq -e --arg content "$content" '.content == $content' >/dev/null } if [[ -z "$candidate_image" ]]; then build_local_candidate else ensure_image "$candidate_image" fi if [[ -z "$previous_image" ]]; then previous_image="$(detect_previous_image)" fi ensure_image "$previous_image" [[ "$candidate_image" != "$previous_image" ]] || die "candidate and previous images must be different" log "Candidate image: $candidate_image" log "Previous image: $previous_image" log "Running fresh-install smoke test" docker volume create "$fresh_volume" >/dev/null start_container "$fresh_container" "$candidate_image" "$fresh_volume" assert_frontend_assets create_admin fresh_token="$(sign_in)" create_memo "$fresh_token" "release-smoke" "fresh install smoke sentinel" docker restart "$fresh_container" >/dev/null set_current_base_url "$fresh_container" wait_until_ready "$fresh_container" "$current_base_url" fresh_token="$(sign_in)" assert_memo "$fresh_token" "release-smoke" "fresh install smoke sentinel" docker rm -f "$fresh_container" >/dev/null log "Running $previous_image to $candidate_image upgrade smoke test" docker volume create "$upgrade_volume" >/dev/null start_container "$upgrade_old_container" "$previous_image" "$upgrade_volume" create_admin upgrade_token="$(sign_in)" create_memo "$upgrade_token" "pre-upgrade-smoke" "created before release upgrade" docker rm -f "$upgrade_old_container" >/dev/null start_container "$upgrade_new_container" "$candidate_image" "$upgrade_volume" assert_frontend_assets upgrade_token="$(sign_in)" assert_memo "$upgrade_token" "pre-upgrade-smoke" "created before release upgrade" create_memo "$upgrade_token" "post-upgrade-smoke" "created after release upgrade" assert_memo "$upgrade_token" "post-upgrade-smoke" "created after release upgrade" log "Release smoke tests passed"