#!/usr/bin/env sh set -eu if [ "$#" -ne 2 ]; then echo "Usage: $0 <40-character-commit-sha> " >&2 exit 64 fi RELEASE_SHA="$1" ARTIFACT_DIR="${2%/}" case "${RELEASE_SHA}" in *[!0-9a-f]*|'') echo "Release SHA must contain exactly 40 lowercase hexadecimal characters." >&2; exit 64 ;; esac if [ "${#RELEASE_SHA}" -ne 40 ]; then echo "Release SHA must contain exactly 40 lowercase hexadecimal characters." >&2 exit 64 fi API_ARCHIVE="${ARTIFACT_DIR}/projectbrain-api-${RELEASE_SHA}.tar" WEB_ARCHIVE="${ARTIFACT_DIR}/projectbrain-web-${RELEASE_SHA}.tar" DIGEST_FILE="${ARTIFACT_DIR}/archive-sha256.txt" for path in "${API_ARCHIVE}" "${WEB_ARCHIVE}" "${DIGEST_FILE}"; do if [ ! -f "${path}" ]; then echo "Missing release artifact: ${path}" >&2; exit 66; fi done for command in sha256sum skopeo docker curl; do if ! command -v "${command}" >/dev/null 2>&1; then echo "Required command is unavailable: ${command}" >&2; exit 69; fi done verify_digest() { archive="$1" filename="$(basename "${archive}")" expected="$(awk -v file="${filename}" '$2 == file || $2 ~ ("/" file "$") { print $1; exit }' "${DIGEST_FILE}")" actual="$(sha256sum "${archive}" | awk '{ print $1 }')" if [ -z "${expected}" ] || [ "${actual}" != "${expected}" ]; then echo "Digest mismatch for ${filename}." >&2 exit 65 fi printf '%s' "${actual}" } API_DIGEST="$(verify_digest "${API_ARCHIVE}")" WEB_DIGEST="$(verify_digest "${WEB_ARCHIVE}")" skopeo copy "oci-archive:${API_ARCHIVE}" "docker-daemon:projectbrain-api:${RELEASE_SHA}" skopeo copy "oci-archive:${WEB_ARCHIVE}" "docker-daemon:projectbrain-web:${RELEASE_SHA}" API_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "projectbrain-api:${RELEASE_SHA}")" WEB_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "projectbrain-web:${RELEASE_SHA}")" : "${POSTGRES_PASSWORD:?set a non-default POSTGRES_PASSWORD in the environment or .env}" : "${PROJECTBRAIN_OWNER_PASSWORD:?set PROJECTBRAIN_OWNER_PASSWORD in the environment or .env}" : "${PROJECTBRAIN_AUTH_SECRET:?set PROJECTBRAIN_AUTH_SECRET in the environment or .env}" if [ "${POSTGRES_PASSWORD}" = "projectbrain" ]; then echo "Default PostgreSQL password is forbidden." >&2; exit 78; fi if [ "${#PROJECTBRAIN_OWNER_PASSWORD}" -lt 12 ]; then echo "Owner password must contain at least 12 characters." >&2; exit 78; fi if [ "${#PROJECTBRAIN_AUTH_SECRET}" -lt 32 ]; then echo "Auth secret must contain at least 32 characters." >&2; exit 78; fi export PROJECTBRAIN_RELEASE_SHA="${RELEASE_SHA}" export PROJECTBRAIN_DEPLOY_SLOT="${PROJECTBRAIN_DEPLOY_SLOT:-stage}" export PROJECTBRAIN_WEB_PORT="${PROJECTBRAIN_STAGE_WEB_PORT:-1210}" COMPOSE_PROJECT="projectbrain-${PROJECTBRAIN_DEPLOY_SLOT}-${RELEASE_SHA%????????????????????????????????}" docker compose \ --project-name "${COMPOSE_PROJECT}" \ --file docker-compose.yml \ --file docker-compose.release.yml \ config --quiet docker compose \ --project-name "${COMPOSE_PROJECT}" \ --file docker-compose.yml \ --file docker-compose.release.yml \ up --detach --no-build --pull never READINESS_URL="http://127.0.0.1:${PROJECTBRAIN_WEB_PORT}/api/system/readiness" attempt=0 until READINESS="$(curl --fail --silent --show-error "${READINESS_URL}")"; do attempt=$((attempt + 1)) if [ "${attempt}" -ge 30 ]; then echo "Staged release did not become ready: ${READINESS_URL}" >&2; exit 70; fi sleep 2 done EVIDENCE_FILE="${ARTIFACT_DIR}/staged-deployment-${RELEASE_SHA}.txt" { echo "release_sha=${RELEASE_SHA}" echo "compose_project=${COMPOSE_PROJECT}" echo "web_port=${PROJECTBRAIN_WEB_PORT}" echo "api_archive_sha256=${API_DIGEST}" echo "web_archive_sha256=${WEB_DIGEST}" echo "api_image_id=${API_IMAGE_ID}" echo "web_image_id=${WEB_IMAGE_ID}" echo "readiness_url=${READINESS_URL}" echo "readiness=${READINESS}" echo "verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" } > "${EVIDENCE_FILE}" echo "Staged immutable release ${RELEASE_SHA} as ${COMPOSE_PROJECT}." echo "Evidence: ${EVIDENCE_FILE}"