Files
kaidi/scripts/package-release.sh
T
2026-08-17 17:44:13 +08:00

223 lines
10 KiB
Bash
Executable File

#!/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"
# 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"
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"
PUBLIC_KEY_SHA256=$(sha256_file "$OUTPUT_DIR/release-public.pem")
INSTALLER_SHA256=$(sha256_file "$OUTPUT_DIR/install.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" <<EOF
KAIDI_RELEASE_PUBLIC_KEY_SHA256=$PUBLIC_KEY_SHA256
KAIDI_INSTALLER_SHA256=$INSTALLER_SHA256
EOF
BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt")
RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"}
VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" RELEASE_NOTES="$RELEASE_NOTES_VALUE" \
BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \
INSTALLER_SHA256="$INSTALLER_SHA256" PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
BOOTSTRAP_SHA256="$BOOTSTRAP_SHA256" SOURCE_REVISION="$SOURCE_REVISION" SOURCE_REF="$SOURCE_REF" \
SOURCE_DIRTY="$SOURCE_DIRTY" \
node <<'NODE' > "$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,
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 \
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 \
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"