#!/usr/bin/env bash set -Eeuo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) VERSION=${1:-${KAIDI_RELEASE_VERSION:-}} OUTPUT_DIR=${KAIDI_RELEASE_OUTPUT_DIR:-$ROOT/dist/release} SIGNING_KEY=${KAIDI_RELEASE_SIGNING_KEY:-} TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256:-} WORK=$(mktemp -d) FRONTEND_VERSION_OVERRIDDEN=false cleanup() { if [ "$FRONTEND_VERSION_OVERRIDDEN" = true ]; then cp "$WORK/package.json" "$ROOT/frontend/package.json" cp "$WORK/package-lock.json" "$ROOT/frontend/package-lock.json" fi rm -rf "$WORK" } trap cleanup EXIT sha256_file() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else shasum -a 256 "$1" | awk '{print $1}' fi } "$ROOT/scripts/check-semver.sh" "$VERSION" \ || { printf 'Usage: %s VERSION\n' "$0" >&2; exit 2; } SOURCE_REVISION=${KAIDI_SOURCE_REVISION:-} if [ -z "$SOURCE_REVISION" ]; then SOURCE_REVISION=$(git -C "$ROOT" rev-parse --verify HEAD 2>/dev/null || true) fi [[ "$SOURCE_REVISION" =~ ^[0-9A-Fa-f]{40}$ ]] \ || { echo 'KAIDI_SOURCE_REVISION must be a 40-character Git commit SHA' >&2; exit 1; } SOURCE_REVISION=$(printf '%s' "$SOURCE_REVISION" | tr '[:upper:]' '[:lower:]') SOURCE_REF=${KAIDI_SOURCE_REF:-} if [ -z "$SOURCE_REF" ]; then SOURCE_REF=$(git -C "$ROOT" tag --points-at "$SOURCE_REVISION" 2>/dev/null \ | awk -v expected="v$VERSION" '$0 == expected { print; exit }') fi [ -n "$SOURCE_REF" ] || SOURCE_REF=local [ "${#SOURCE_REF}" -le 128 ] && [[ "$SOURCE_REF" != *$'\n'* ]] && [[ "$SOURCE_REF" != *$'\r'* ]] \ || { echo 'KAIDI_SOURCE_REF must be a single line of at most 128 characters' >&2; exit 1; } SOURCE_DIRTY=${KAIDI_SOURCE_DIRTY:-} if [ -z "$SOURCE_DIRTY" ]; then if [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all 2>/dev/null)" ]; then SOURCE_DIRTY=true else SOURCE_DIRTY=false fi fi case "$SOURCE_DIRTY" in true|false) ;; *) echo 'KAIDI_SOURCE_DIRTY must be true or false' >&2; exit 1 ;; esac if [ "${KAIDI_SKIP_BUILD:-false}" != "true" ]; then cp "$ROOT/frontend/package.json" "$WORK/package.json" cp "$ROOT/frontend/package-lock.json" "$WORK/package-lock.json" FRONTEND_VERSION_OVERRIDDEN=true (cd "$ROOT/frontend" \ && npm version "$VERSION" --no-git-tag-version --allow-same-version --ignore-scripts >/dev/null) (cd "$ROOT/backend" && ./mvnw -q -Drevision="$VERSION" -DskipTests package) (cd "$ROOT/frontend" && HUSKY=0 npm ci && npm run build) fi JAR="$ROOT/backend/target/finance-system-$VERSION.jar" [ -s "$JAR" ] || { echo 'Backend release jar is missing' >&2; exit 1; } [ -s "$ROOT/backend/target/backend-sbom.json" ] \ || { echo 'Backend CycloneDX SBOM is missing; run the Maven package phase first' >&2; exit 1; } [ -s "$ROOT/frontend/dist/index.html" ] || { echo 'Frontend release build is missing' >&2; exit 1; } JAR_METADATA_DIR="$WORK/jar-metadata" mkdir -p "$JAR_METADATA_DIR" (cd "$JAR_METADATA_DIR" && jar -xf "$JAR" \ META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties) JAR_POM_VERSION=$(sed -n 's/^version=//p' \ "$JAR_METADATA_DIR/META-INF/maven/com.kaidi/finance-system/pom.properties") JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \ "$JAR_METADATA_DIR/META-INF/MANIFEST.MF" | tr -d '\r') [ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \ || { printf 'JAR metadata versions do not match release %s\n' "$VERSION" >&2; exit 1; } rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" STAGE="$WORK/stage" mkdir -p "$STAGE/public" "$STAGE/ops" cp "$JAR" "$STAGE/app.jar" cp -R "$ROOT/frontend/dist/." "$STAGE/public/" printf '%s\n' "$VERSION" > "$STAGE/VERSION" cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh" cp "$ROOT/deploy/baota-start.sh" "$STAGE/ops/baota-start.sh" cp "$ROOT/deploy/baota.env.example" "$STAGE/ops/baota.env.example" # Kept in the archive so Preview.9's updater can complete the one-time transition. cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf" cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service" cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service" cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path" chmod 0755 "$STAGE/ops/update.sh" "$STAGE/ops/baota-start.sh" ARTIFACT="kaidi-finance-$VERSION.tar.gz" COPYFILE_DISABLE=1 tar --format=ustar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" . SHA256=$(sha256_file "$OUTPUT_DIR/$ARTIFACT") cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json" (cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \ --sbom-format cyclonedx --sbom-type application) > "$OUTPUT_DIR/frontend-sbom.cdx.json" BACKEND_BUILD_VERSION=$(jq -er '.metadata.component.version | strings | select(length > 0)' \ "$OUTPUT_DIR/backend-sbom.cdx.json") FRONTEND_BUILD_VERSION=$(jq -er '.metadata.component.version | strings | select(length > 0)' \ "$OUTPUT_DIR/frontend-sbom.cdx.json") [ "$BACKEND_BUILD_VERSION" = "$VERSION" ] \ || { printf 'Backend build version %s does not match release %s\n' "$BACKEND_BUILD_VERSION" "$VERSION" >&2; exit 1; } [ "$FRONTEND_BUILD_VERSION" = "$VERSION" ] \ || { printf 'Frontend build version %s does not match release %s\n' "$FRONTEND_BUILD_VERSION" "$VERSION" >&2; exit 1; } for sbom in "$OUTPUT_DIR/backend-sbom.cdx.json" "$OUTPUT_DIR/frontend-sbom.cdx.json"; do jq -e --arg version "$VERSION" \ '. as $document | .metadata.component.version == $version and (.metadata.component["bom-ref"] | strings | length > 0) and (.metadata.component.purl | strings | length > 0) and any(.dependencies[]; .ref == $document.metadata.component["bom-ref"])' \ "$sbom" >/dev/null \ || { printf 'SBOM build-version references are inconsistent in %s\n' "$sbom" >&2; exit 1; } done BACKEND_SBOM_SHA256=$(sha256_file "$OUTPUT_DIR/backend-sbom.cdx.json") FRONTEND_SBOM_SHA256=$(sha256_file "$OUTPUT_DIR/frontend-sbom.cdx.json") if [ -n "$SIGNING_KEY" ]; then [ -s "$SIGNING_KEY" ] || { echo 'KAIDI_RELEASE_SIGNING_KEY does not exist' >&2; exit 1; } openssl rsa -in "$SIGNING_KEY" -check -noout >/dev/null 2>&1 \ || { echo 'KAIDI_RELEASE_SIGNING_KEY must be an RSA private key' >&2; exit 1; } SIGNING_KEY_BITS=$(openssl rsa -in "$SIGNING_KEY" -text -noout 2>/dev/null \ | sed -n 's/^Private-Key: (\([0-9][0-9]*\) bit.*$/\1/p' | head -n 1) [ -n "$SIGNING_KEY_BITS" ] && [ "$SIGNING_KEY_BITS" -ge 3072 ] \ || { echo 'KAIDI_RELEASE_SIGNING_KEY must use at least 3072 bits' >&2; exit 1; } openssl pkey -in "$SIGNING_KEY" -pubout -out "$OUTPUT_DIR/release-public.pem" elif [ "${KAIDI_ALLOW_UNSIGNED_RELEASE:-false}" = "true" ]; then : > "$OUTPUT_DIR/release-public.pem" else echo 'KAIDI_RELEASE_SIGNING_KEY is required for an online-update release' >&2 exit 1 fi install -m 0755 "$ROOT/deploy/install.sh" "$OUTPUT_DIR/install.sh" install -m 0755 "$ROOT/deploy/purge.sh" "$OUTPUT_DIR/purge.sh" PUBLIC_KEY_SHA256=$(sha256_file "$OUTPUT_DIR/release-public.pem") INSTALLER_SHA256=$(sha256_file "$OUTPUT_DIR/install.sh") PURGER_SHA256=$(sha256_file "$OUTPUT_DIR/purge.sh") if [ -n "$TRUSTED_PUBLIC_KEY_SHA256" ]; then NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]') [[ "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]] \ || { echo 'KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256 must be 64 hexadecimal characters' >&2; exit 1; } [ "$PUBLIC_KEY_SHA256" = "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" ] \ || { echo 'Release signing key does not match the trusted public-key SHA-256' >&2; exit 1; } elif [ "${GITHUB_ACTIONS:-false}" = true ] || [ "${GITEA_ACTIONS:-false}" = true ]; then echo 'KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256 is required in release CI' >&2 exit 1 fi cat > "$OUTPUT_DIR/bootstrap-checksums.txt" < "$OUTPUT_DIR/release-manifest.json" const manifest = { version: process.env.VERSION, artifact: process.env.ARTIFACT, sha256: process.env.SHA256, publishedAt: new Date().toISOString(), minimumJava: 17, source: { revision: process.env.SOURCE_REVISION, ref: process.env.SOURCE_REF, dirty: process.env.SOURCE_DIRTY === 'true', }, buildVersions: { backend: process.env.BACKEND_BUILD_VERSION, frontend: process.env.FRONTEND_BUILD_VERSION, }, releaseNotes: process.env.RELEASE_NOTES, sboms: [ { ecosystem: 'maven', artifact: 'backend-sbom.cdx.json', sha256: process.env.BACKEND_SBOM_SHA256 }, { ecosystem: 'npm', artifact: 'frontend-sbom.cdx.json', sha256: process.env.FRONTEND_SBOM_SHA256 }, ], bootstrap: { installer: 'install.sh', installerSha256: process.env.INSTALLER_SHA256, purger: 'purge.sh', purgerSha256: process.env.PURGER_SHA256, publicKey: 'release-public.pem', publicKeySha256: process.env.PUBLIC_KEY_SHA256, checksums: 'bootstrap-checksums.txt', checksumsSha256: process.env.BOOTSTRAP_SHA256, }, }; process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`); NODE if [ -n "$SIGNING_KEY" ]; then openssl dgst -sha256 -sign "$SIGNING_KEY" \ -out "$OUTPUT_DIR/release-manifest.sig" "$OUTPUT_DIR/release-manifest.json" else : > "$OUTPUT_DIR/release-manifest.sig" fi ( cd "$OUTPUT_DIR" if command -v sha256sum >/dev/null 2>&1; then sha256sum "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \ release-manifest.json release-manifest.sig release-public.pem install.sh purge.sh \ bootstrap-checksums.txt > SHA256SUMS else shasum -a 256 "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \ release-manifest.json release-manifest.sig release-public.pem install.sh purge.sh \ bootstrap-checksums.txt > SHA256SUMS fi ) printf 'Release assets created in %s\n' "$OUTPUT_DIR" printf 'Trusted release public-key SHA-256: %s\n' "$PUBLIC_KEY_SHA256" printf 'Installer SHA-256: %s\n' "$INSTALLER_SHA256" printf 'Purger SHA-256: %s\n' "$PURGER_SHA256"