feat: deliver first finance preview with setup wizard
Release / release (push) Failing after 18s

This commit is contained in:
Qiufeng
2026-08-17 14:08:40 +08:00
parent 47403fd337
commit c8b0151769
558 changed files with 140774 additions and 3 deletions
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SPEC_FILE="${1:-$ROOT_DIR/openapi.yaml}"
# shellcheck disable=SC2016 # The single-quoted block is Ruby source, not shell text.
ruby -ryaml -e '
document = YAML.load_file(ARGV.fetch(0))
paths = document.fetch("paths")
finance_lists = {
"/api/v1/contracts" => "listFinanceContracts",
"/api/v1/costs" => "listFinanceCosts",
"/api/v1/payables" => "listFinancePayables"
}
finance_lists.each do |path, operation_id|
operation = paths.fetch(path).fetch("get")
abort "#{path} has unstable operationId" unless operation["operationId"] == operation_id
sort_parameter = operation.fetch("parameters").find { |parameter| parameter["name"] == "sort" }
abort "#{path} is missing repeatable sort schema" unless sort_parameter&.dig("schema", "type") == "array"
end
rescan_operations = {
["/api/v1/files/{publicId}/rescan", "post"] => "rescanOwnedFile",
["/api/v1/archive/files/{publicId}/rescan", "post"] => "rescanArchiveFile"
}
rescan_operations.each do |(path, method), operation_id|
actual = paths.fetch(path).fetch(method).fetch("operationId")
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
end
fund_ledger = paths.fetch("/api/v1/funds/ledger").fetch("get")
abort "fund ledger has unstable operationId" unless fund_ledger["operationId"] == "listFundLedger"
fund_parameter_names = fund_ledger.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
expected_fund_parameters = %w[currency dateFrom dateTo entryType page projectId size sortBy sortDirection]
abort "fund ledger parameter contract drifted: #{fund_parameter_names.inspect}" unless
fund_parameter_names == expected_fund_parameters
workflow_operations = {
["/api/v1/workflow/tasks", "get"] => "listWorkflowTasks",
["/api/v1/workflow/tasks/{publicId}", "get"] => "getWorkflowTask",
["/api/v1/workflow/tasks/{publicId}/approve", "post"] => "approveWorkflowTask",
["/api/v1/workflow/tasks/{publicId}/return", "post"] => "returnWorkflowTask",
["/api/v1/workflow/tasks/{publicId}/reject", "post"] => "rejectWorkflowTask",
["/api/v1/workflow/instances/{instancePublicId}/withdraw", "post"] => "withdrawWorkflowInstance",
["/api/v1/workflow/instances/{instancePublicId}/void", "post"] => "voidWorkflowInstance"
}
workflow_operations.each do |(path, method), operation_id|
actual = paths.fetch(path).fetch(method).fetch("operationId")
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
end
workflow_list = paths.fetch("/api/v1/workflow/tasks").fetch("get")
workflow_parameters = workflow_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
expected_workflow_parameters = %w[
arrivedDateFrom arrivedDateTo formType keyword overdue page projectId size sort status view
]
abort "workflow task parameter contract drifted: #{workflow_parameters.inspect}" unless
workflow_parameters == expected_workflow_parameters
workflow_sort = workflow_list.fetch("parameters").find { |parameter| parameter["name"] == "sort" }
expected_workflow_sorts = %w[arrivedAt,asc arrivedAt,desc dueAt,asc dueAt,desc updatedAt,asc updatedAt,desc]
abort "workflow task sort whitelist drifted" unless workflow_sort&.dig("schema", "enum") == expected_workflow_sorts
workflow_size = workflow_list.fetch("parameters").find { |parameter| parameter["name"] == "size" }
abort "workflow task size schema drifted" unless workflow_size&.dig("schema", "type") == "integer"
abort "workflow task size whitelist drifted" unless workflow_size&.dig("schema", "enum") == [20, 50, 100]
source_forms = paths.fetch("/api/v1/source/forms").fetch("get")
abort "source forms has unstable operationId" unless source_forms["operationId"] == "listSourceForms"
source_form_parameters = source_forms.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
expected_source_form_parameters = %w[
createdByMe createdDateFrom createdDateTo formType groupCode keyword page projectId size sort sourceSystem
status templateVersion
]
abort "source forms parameter contract drifted: #{source_form_parameters.inspect}" unless
source_form_parameters == expected_source_form_parameters
source_form_sort = source_forms.fetch("parameters").find { |parameter| parameter["name"] == "sort" }
expected_source_form_sorts = %w[
updatedAt,asc updatedAt,desc createdAt,asc createdAt,desc businessNo,asc businessNo,desc status,asc status,desc
]
abort "source forms sort whitelist drifted" unless
source_form_sort&.dig("schema", "enum") == expected_source_form_sorts
source_form_size = source_forms.fetch("parameters").find { |parameter| parameter["name"] == "size" }
abort "source forms size whitelist drifted" unless source_form_size&.dig("schema", "enum") == [20, 50, 100]
accounting_operations = {
["/api/v1/accounting/accounts", "get"] => "listAccountingAccounts",
["/api/v1/accounting/events", "get"] => "listAccountingEvents",
["/api/v1/accounting/events/{publicId}/generate-draft", "post"] => "generateVoucherDraft",
["/api/v1/accounting/vouchers", "get"] => "listVouchers",
["/api/v1/accounting/vouchers/{publicId}", "get"] => "getVoucher",
["/api/v1/accounting/vouchers/{publicId}", "patch"] => "updateVoucher",
["/api/v1/accounting/vouchers/{publicId}/submit", "post"] => "submitVoucher",
["/api/v1/accounting/vouchers/{publicId}/approve", "post"] => "approveVoucher",
["/api/v1/accounting/vouchers/{publicId}/return", "post"] => "returnVoucher",
["/api/v1/accounting/vouchers/{publicId}/export", "post"] => "exportVoucher",
["/api/v1/accounting/vouchers/{publicId}/export-file", "get"] => "downloadVoucherExport",
["/api/v1/accounting/vouchers/{voucherPublicId}/result-files/{filePublicId}/content", "get"] => "downloadVoucherResultEvidence",
["/api/v1/accounting/vouchers/{publicId}/record-result", "post"] => "recordVoucherResult",
["/api/v1/accounting/vouchers/{publicId}/verify-result", "post"] => "verifyVoucherResult",
["/api/v1/accounting/vouchers/{publicId}/reverse-result", "post"] => "reverseVoucherResult",
["/api/v1/accounting/vouchers/{publicId}/reopen-result", "post"] => "reopenVoucherResult",
["/api/v1/accounting/vouchers/{publicId}/void", "post"] => "voidVoucher"
}
accounting_operations.each do |(path, method), operation_id|
actual = paths.fetch(path).fetch(method).fetch("operationId")
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
end
audit_operations = {
["/api/v1/audit/logs", "get"] => "listAuditLogs",
["/api/v1/audit/logs/{publicId}", "get"] => "getAuditLog",
["/api/v1/audit/exports", "post"] => "exportAuditLogs"
}
audit_operations.each do |(path, method), operation_id|
actual = paths.fetch(path).fetch(method).fetch("operationId")
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
end
audit_list = paths.fetch("/api/v1/audit/logs").fetch("get")
audit_parameters = audit_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
expected_audit_parameters = %w[
action actorId identityCode keyword objectId objectType occurredFrom occurredTo page requestId result size sort
]
abort "audit list parameter contract drifted: #{audit_parameters.inspect}" unless
audit_parameters == expected_audit_parameters
audit_result = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "result" }
expected_audit_results = %w[SUCCESS FAILED DENIED BLOCKED]
abort "audit result whitelist drifted" unless audit_result&.dig("schema", "enum") == expected_audit_results
audit_size = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "size" }
abort "audit size whitelist drifted" unless audit_size&.dig("schema", "enum") == [20, 50, 100]
audit_actor = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "actorId" }
abort "audit actorId length drifted" unless audit_actor&.dig("schema", "maxLength") == 26
audit_sort = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "sort" }
expected_audit_sorts = %w[occurredAt,asc occurredAt,desc eventSequence,asc eventSequence,desc]
abort "audit sort whitelist drifted" unless audit_sort&.dig("schema", "enum") == expected_audit_sorts
audit_response_ref = lambda { |operation|
operation.dig("responses", "200", "content", "application/json", "schema", "$ref")
}
abort "audit list response drifted" unless audit_response_ref.call(audit_list) ==
"#/components/schemas/ApiResponseListAuditLogView"
abort "audit detail response drifted" unless audit_response_ref.call(
paths.fetch("/api/v1/audit/logs/{publicId}").fetch("get")
) == "#/components/schemas/ApiResponseAuditLogView"
abort "audit export response drifted" unless audit_response_ref.call(
paths.fetch("/api/v1/audit/exports").fetch("post")
) == "#/components/schemas/ApiResponseAuditExportView"
problem_ref = "#/components/schemas/ApiProblem"
problem_response_ref = lambda { |operation, code|
operation.dig("responses", code, "content", "application/problem+json", "schema", "$ref")
}
{
audit_list => %w[400 401 403 422],
paths.fetch("/api/v1/audit/logs/{publicId}").fetch("get") => %w[401 403 404],
paths.fetch("/api/v1/audit/exports").fetch("post") => %w[400 401 403 422]
}.each do |operation, codes|
codes.each do |code|
abort "audit problem response #{code} drifted" unless
problem_response_ref.call(operation, code) == problem_ref
end
end
versions = paths.fetch("/api/v1/masterdata/{resource}/{publicId}/versions").fetch("get")
abort "master-data versions has unstable operationId" unless
versions["operationId"] == "listMasterDataVersions"
versions_size = versions.fetch("parameters").find { |parameter| parameter["name"] == "size" }
abort "master-data versions size whitelist drifted" unless versions_size&.dig("schema", "enum") == [20, 50, 100]
%w[400 401 403 404 422].each do |code|
abort "master-data versions problem response #{code} drifted" unless
problem_response_ref.call(versions, code) == problem_ref
end
archive_operations = {
["/api/v1/archive/packages", "get"] => "listArchivePackages",
["/api/v1/archive/packages/{publicId}", "get"] => "getArchivePackage",
["/api/v1/archive/packages", "post"] => "createArchivePackage",
["/api/v1/archive/packages/{publicId}/check", "post"] => "checkArchivePackage",
["/api/v1/archive/packages/{publicId}/submit", "post"] => "submitArchivePackage",
["/api/v1/archive/packages/{publicId}/return", "post"] => "returnArchivePackage",
["/api/v1/archive/packages/{publicId}/revise", "post"] => "reviseArchivePackage",
["/api/v1/archive/packages/{publicId}/archive", "post"] => "archivePackage",
["/api/v1/archive/packages/{publicId}/freeze", "post"] => "freezeArchivePackage",
["/api/v1/archive/packages/{publicId}/unfreeze", "post"] => "unfreezeArchivePackage",
["/api/v1/archive/packages/{packageId}/items/{itemId}/not-applicable", "post"] => "markArchiveItemNotApplicable",
["/api/v1/archive/packages/{packageId}/items/{itemId}/not-applicable/review", "post"] => "reviewArchiveItemNotApplicable",
["/api/v1/archive/files", "get"] => "listArchiveFiles",
["/api/v1/archive/files", "post"] => "uploadArchiveFile",
["/api/v1/archive/files/{publicId}", "get"] => "getArchiveFile",
["/api/v1/archive/files/{publicId}/rescan", "post"] => "rescanArchiveFile",
["/api/v1/archive/files/{publicId}/content", "get"] => "getArchiveFileContent",
["/api/v1/archive/borrows", "get"] => "listBorrows",
["/api/v1/archive/borrows", "post"] => "createBorrow",
["/api/v1/archive/borrows/{publicId}/approve", "post"] => "approveBorrow",
["/api/v1/archive/borrows/{publicId}/reject", "post"] => "rejectBorrow",
["/api/v1/archive/borrows/{publicId}/return", "post"] => "returnBorrow"
}
archive_operations.each do |(path, method), operation_id|
actual = paths.fetch(path).fetch(method).fetch("operationId")
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
end
project_operations = {
["/api/v1/projects/{publicId}/finance-complete", "post"] => "completeProjectFinance",
["/api/v1/projects/{projectPublicId}/forms", "get"] => "getProjectForms",
["/api/v1/projects/{projectPublicId}/contracts", "get"] => "getProjectContracts",
["/api/v1/projects/{projectPublicId}/receipts", "get"] => "getProjectReceipts",
["/api/v1/projects/{projectPublicId}/invoices", "get"] => "getProjectInvoices",
["/api/v1/projects/{projectPublicId}/payments", "get"] => "getProjectPayments",
["/api/v1/projects/{projectPublicId}/progress", "get"] => "getProjectProgress",
["/api/v1/projects/{projectPublicId}/people", "get"] => "getProjectPeople",
["/api/v1/projects/{projectPublicId}/accounting", "get"] => "getProjectAccounting",
["/api/v1/projects/{projectPublicId}/archives", "get"] => "getProjectArchives",
["/api/v1/projects/{projectPublicId}/timeline", "get"] => "getProjectTimeline",
["/api/v1/projects/{projectPublicId}/risk-flags", "get"] => "getProjectRiskFlags"
}
project_operations.each do |(path, method), operation_id|
actual = paths.fetch(path).fetch(method).fetch("operationId")
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
end
governance_operations = {
["/api/v1/admin/{resource}", "get"] => "listGovernanceResources",
["/api/v1/admin/{resource}/{publicId}", "get"] => "getGovernanceResource",
["/api/v1/admin/{resource}", "post"] => "createGovernanceResource",
["/api/v1/admin/{resource}/{publicId}", "patch"] => "updateGovernanceResource",
["/api/v1/admin/{resource}/{publicId}/{command}", "post"] => "commandGovernanceResource"
}
governance_operations.each do |(path, method), operation_id|
actual = paths.fetch(path).fetch(method).fetch("operationId")
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
end
update_operations = {
["/api/v1/admin/system-update", "get"] => "getSystemUpdateStatus",
["/api/v1/admin/system-update/check", "post"] => "checkSystemUpdate",
["/api/v1/admin/system-update/download", "post"] => "downloadSystemUpdate",
["/api/v1/admin/system-update/install", "post"] => "installSystemUpdate"
}
update_operations.each do |(path, method), operation_id|
actual = paths.fetch(path).fetch(method).fetch("operationId")
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
end
update_response = paths.fetch("/api/v1/admin/system-update").fetch("get")
.dig("responses", "200", "content", "application/json", "schema", "$ref")
abort "system update response drifted" unless update_response ==
"#/components/schemas/ApiResponseSystemUpdateView"
governance_list = paths.fetch("/api/v1/admin/{resource}").fetch("get")
parameter_names = governance_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
expected_parameter_names = %w[keyword page resource size]
abort "governance list parameter whitelist drifted: #{parameter_names.inspect}" unless parameter_names == expected_parameter_names
resource_parameter = governance_list.fetch("parameters").find { |parameter| parameter["name"] == "resource" }
resource_values = resource_parameter&.dig("schema", "enum")
expected_resources = %w[users roles scopes templates parameters]
abort "governance resource enum drifted: #{resource_values.inspect}" unless resource_values == expected_resources
schemas = document.fetch("components", {}).fetch("schemas", {})
abort "internal query map leaked into OpenAPI" if schemas.key?("MultiValueMapStringString")
task_properties = schemas.fetch("WorkflowTaskSummaryView").fetch("properties")
abort "workflow keyAmount must remain a decimal string" unless task_properties.dig("keyAmount", "type") == "string"
list_response_ref = governance_list.dig("responses", "200", "content", "application/json", "schema", "$ref")
expected_list_response_ref = "#/components/schemas/ApiResponseListResourceListView"
abort "governance list response drifted: #{list_response_ref.inspect}" unless list_response_ref == expected_list_response_ref
list_data_ref = schemas.dig("ApiResponseListResourceListView", "properties", "data", "items", "$ref")
abort "governance list data is not ResourceListView" unless list_data_ref == "#/components/schemas/ResourceListView"
resource_properties = schemas.fetch("ResourceListView").fetch("properties")
expected_resource_fields = %w[
resource publicId username displayName departmentName enabled mustChangePassword roleCodes
code name description sortOrder memberCount userPublicId roleCode permissionCode scopeType
companyPublicId projectPublicId amountLimit status formType templateVersion schemaVersion
parameterGroup versionNo version publishedAt effectiveAt createdAt updatedAt
]
missing_fields = expected_resource_fields - resource_properties.keys
extra_fields = resource_properties.keys - expected_resource_fields
abort "ResourceListView fields drifted; missing=#{missing_fields.inspect}, extra=#{extra_fields.inspect}" unless missing_fields.empty? && extra_fields.empty?
abort "amountLimit must remain a decimal string" unless resource_properties.dig("amountLimit", "type") == "string"
abort "roleCodes must remain a string array" unless resource_properties.dig("roleCodes", "type") == "array" &&
resource_properties.dig("roleCodes", "items", "type") == "string"
%w[publishedAt effectiveAt createdAt updatedAt].each do |field|
schema = resource_properties.fetch(field)
abort "#{field} must remain an ISO date-time string" unless schema["type"] == "string" && schema["format"] == "date-time"
end
puts "OpenAPI contract checks passed"
' "$SPEC_FILE"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -eu
VERSION=${1:-}
SEMVER_PATTERN='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
if [ "${#VERSION}" -gt 128 ] || [[ ! "$VERSION" =~ $SEMVER_PATTERN ]]; then
printf 'Invalid SemVer: %s\n' "$VERSION" >&2
exit 1
fi
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://127.0.0.1:18080}"
VERSION="${OPENAPI_VERSION:-1.0.0}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RAW_FILE="$(mktemp)"
NORMALIZED_FILE="$(mktemp)"
YAML_FILE="$(mktemp)"
OUTPUT_FILE="$(mktemp)"
trap 'rm -f "$RAW_FILE" "$NORMALIZED_FILE" "$YAML_FILE" "$OUTPUT_FILE"' EXIT
curl --fail --silent --show-error "${BASE_URL%/}/api-docs" -o "$RAW_FILE"
jq -S --arg version "$VERSION" \
'del(.servers)
| .info.title = "Kaidi Finance API"
| .info.version = $version
| .paths |= with_entries(
if (.key | test("^/api/v1/archives?(/|$)"))
then .value |= with_entries(
if (.value.operationId? // "" | test("_1$"))
then .value.operationId |= sub("_1$"; "")
else .
end
)
else .
end
)
# OpenAPI annotations expose allowableValues as strings even for an integer schema.
| (.paths["/api/v1/workflow/tasks"].get.parameters[]
| select(.name == "size").schema.enum) |= map(tonumber)
| (.paths["/api/v1/source/forms"].get.parameters[]
| select(.name == "size").schema.enum) |= map(tonumber)
| (.paths["/api/v1/audit/logs"].get.parameters[]
| select(.name == "size").schema.enum) |= map(tonumber)
| (.paths["/api/v1/masterdata/{resource}/{publicId}/versions"].get.parameters[]
| select(.name == "size").schema.enum) |= map(tonumber)' \
"$RAW_FILE" > "$NORMALIZED_FILE"
ruby -ryaml -rjson -e 'puts JSON.parse(File.read(ARGV[0])).to_yaml' \
"$NORMALIZED_FILE" > "$YAML_FILE"
{
printf '# Generated from Springdoc /api-docs by scripts/export-openapi.sh. Do not edit manually.\n'
cat "$YAML_FILE"
} > "$OUTPUT_FILE"
"$ROOT_DIR/scripts/check-openapi.sh" "$OUTPUT_FILE"
mv "$OUTPUT_FILE" "$ROOT_DIR/openapi.yaml"
printf 'Generated %s from %s/api-docs\n' "$ROOT_DIR/openapi.yaml" "$BASE_URL"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -Eeuo pipefail
OUTPUT=${1:-release-signing-private.pem}
[ ! -e "$OUTPUT" ] || { echo "$OUTPUT already exists" >&2; exit 1; }
umask 077
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out "$OUTPUT"
openssl pkey -in "$OUTPUT" -pubout -out "${OUTPUT%.pem}-public.pem"
if command -v sha256sum >/dev/null 2>&1; then
PUBLIC_KEY_SHA256=$(sha256sum "${OUTPUT%.pem}-public.pem" | awk '{print $1}')
else
PUBLIC_KEY_SHA256=$(shasum -a 256 "${OUTPUT%.pem}-public.pem" | awk '{print $1}')
fi
printf 'Private key: %s\nPublic key: %s\nTrusted public-key SHA-256: %s\n' \
"$OUTPUT" "${OUTPUT%.pem}-public.pem" "$PUBLIC_KEY_SHA256"
+221
View File
@@ -0,0 +1,221 @@
#!/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/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 -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"
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SERVER_URL=${GITEA_SERVER_URL:-}
REPOSITORY=${GITEA_REPOSITORY:-}
TAG=${GITEA_REF_NAME:-}
SOURCE_SHA=${GITEA_SHA:-}
TOKEN=${GITEA_TOKEN:-}
RELEASE_DIR=${KAIDI_RELEASE_DIR:-dist/release}
RELEASE_NAME=${KAIDI_RELEASE_NAME:-Kaidi Finance $TAG}
RELEASE_BODY=${KAIDI_RELEASE_BODY:-Kaidi Finance $TAG
Source: $SOURCE_SHA}
WORK=$(mktemp -d)
AUTH_HEADER=$WORK/gitea-auth-header
cleanup() {
rm -rf "$WORK"
}
trap cleanup EXIT
fail() {
printf 'Gitea release failed: %s\n' "$1" >&2
exit 1
}
case "$SERVER_URL" in
https://*) ;;
*) fail 'GITEA_SERVER_URL must use HTTPS' ;;
esac
[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|| fail 'GITEA_REPOSITORY must be OWNER/REPO'
[[ "$TAG" =~ ^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$ ]] || fail 'GITEA_REF_NAME is invalid'
[[ "$SOURCE_SHA" =~ ^[0-9A-Fa-f]{40}$ ]] || fail 'GITEA_SHA must be a 40-character commit SHA'
[ -n "$TOKEN" ] && [ "${#TOKEN}" -le 512 ] && [[ "$TOKEN" != *$'\n'* ]] && [[ "$TOKEN" != *$'\r'* ]] \
|| fail 'GITEA_TOKEN is invalid'
[ -d "$RELEASE_DIR" ] || fail 'release directory is missing'
printf 'Authorization: token %s\nAccept: application/json\n' "$TOKEN" > "$AUTH_HEADER"
chmod 0600 "$AUTH_HEADER"
API="${SERVER_URL%/}/api/v1/repos/$REPOSITORY"
ENCODED_TAG=$(jq -rn --arg value "$TAG" '$value | @uri')
request() {
local method=$1 url=$2 output=$3 input=${4:-} status
local args=(
--silent --show-error --proto '=https' --tlsv1.2
--request "$method" --header "@$AUTH_HEADER"
--output "$output" --write-out '%{http_code}'
)
if [ -n "$input" ]; then
args+=(--header 'Content-Type: application/json' --data-binary "@$input")
fi
status=$(curl "${args[@]}" "$url") || fail "$method $url could not be completed"
printf '%s\n' "$status"
}
existing_status=$(request GET "$API/releases/tags/$ENCODED_TAG" "$WORK/existing.json")
case "$existing_status" in
404) ;;
200) fail "release $TAG already exists; published releases are immutable" ;;
*) fail "release lookup returned HTTP $existing_status" ;;
esac
jq -n \
--arg tag "$TAG" \
--arg commit "$SOURCE_SHA" \
--arg name "$RELEASE_NAME" \
--arg body "$RELEASE_BODY" \
'{tag_name:$tag,target_commitish:$commit,name:$name,body:$body,draft:true,prerelease:false}' \
> "$WORK/create.json"
create_status=$(request POST "$API/releases" "$WORK/release.json" "$WORK/create.json")
[ "$create_status" = 201 ] || fail "draft release creation returned HTTP $create_status"
RELEASE_ID=$(jq -er '.id | numbers' "$WORK/release.json") || fail 'draft release id is missing'
VERSION=${TAG#v}
ARTIFACT="kaidi-finance-$VERSION.tar.gz"
ASSETS=(
"$ARTIFACT"
backend-sbom.cdx.json
frontend-sbom.cdx.json
release-manifest.json
release-manifest.sig
release-public.pem
install.sh
bootstrap-checksums.txt
SHA256SUMS
)
: > "$WORK/expected-assets.jsonl"
for name in "${ASSETS[@]}"; do
path="$RELEASE_DIR/$name"
[ -f "$path" ] && [ ! -L "$path" ] || fail "release asset $name is missing or unsafe"
size=$(wc -c < "$path" | tr -d '[:space:]')
jq -cn --arg name "$name" --argjson size "$size" '{name:$name,size:$size}' \
>> "$WORK/expected-assets.jsonl"
encoded_name=$(jq -rn --arg value "$name" '$value | @uri')
upload_status=$(curl --silent --show-error --proto '=https' --tlsv1.2 \
--header "@$AUTH_HEADER" \
--form "attachment=@$path;type=application/octet-stream" \
--output "$WORK/upload.json" --write-out '%{http_code}' \
"$API/releases/$RELEASE_ID/assets?name=$encoded_name") \
|| fail "asset $name upload could not be completed"
[ "$upload_status" = 201 ] || fail "asset $name upload returned HTTP $upload_status"
done
jq -s 'sort_by(.name)' "$WORK/expected-assets.jsonl" > "$WORK/expected-assets.json"
verify_status=$(request GET "$API/releases/$RELEASE_ID" "$WORK/verify.json")
[ "$verify_status" = 200 ] || fail "draft release verification returned HTTP $verify_status"
jq -e --slurpfile expected "$WORK/expected-assets.json" \
'([.assets[] | {name,size}] | sort_by(.name)) == $expected[0]' "$WORK/verify.json" >/dev/null \
|| fail 'uploaded release asset names or sizes do not match the signed local set'
jq -n \
--arg commit "$SOURCE_SHA" \
--arg name "$RELEASE_NAME" \
--arg body "$RELEASE_BODY" \
'{target_commitish:$commit,name:$name,body:$body,draft:false,prerelease:false}' \
> "$WORK/publish.json"
publish_status=$(request PATCH "$API/releases/$RELEASE_ID" "$WORK/published.json" "$WORK/publish.json")
[ "$publish_status" = 200 ] || fail "release publication returned HTTP $publish_status"
jq -e '.draft == false and .prerelease == false and (.assets | length) == 9' \
"$WORK/published.json" >/dev/null || fail 'published release state is invalid'
printf 'Published Gitea release %s with nine verified assets\n' "$TAG"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
FIXTURE=$(mktemp -d)
cleanup() {
rm -rf "$FIXTURE"
}
trap cleanup EXIT
mkdir -p "$FIXTURE/repo/deploy" "$FIXTURE/bin"
cp "$ROOT/deploy/install-from-git.sh" "$FIXTURE/repo/deploy/install-from-git.sh"
cat > "$FIXTURE/bin/sudo" <<'SH'
#!/usr/bin/env bash
exec "$@"
SH
cat > "$FIXTURE/repo/deploy/install.sh" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail
[ "$KAIDI_RELEASE_API_URL" = 'https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest' ]
[ "$KAIDI_RELEASE_PUBLIC_KEY_SHA256" = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ]
if [ "${KAIDI_SETUP_WIZARD:-false}" = true ]; then
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ]
else
[ "$KAIDI_DB_URL" = 'jdbc:mysql://DB_HOST:3306/kaidi_finance' ]
[ "$KAIDI_DB_USERNAME" = 'kaidi' ]
[ "$KAIDI_DB_PASSWORD" = 'fixture-password' ]
fi
[ "$(cat "$KAIDI_RELEASE_TOKEN_FILE")" = 'fixture-read-token' ]
printf 'fixture-installer: PASS\n'
SH
chmod 0755 "$FIXTURE/bin/sudo" "$FIXTURE/repo/deploy/install.sh" "$FIXTURE/repo/deploy/install-from-git.sh"
printf '%s' 'fixture-read-token' > "$FIXTURE/token"
chmod 0600 "$FIXTURE/token"
installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}')
PATH="$FIXTURE/bin:$PATH" \
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
KAIDI_INSTALLER_SHA256="$installer_sha256" \
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
KAIDI_RELEASE_PUBLIC_KEY_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
KAIDI_DB_URL=jdbc:mysql://DB_HOST:3306/kaidi_finance \
KAIDI_DB_USERNAME=kaidi \
KAIDI_DB_PASSWORD=fixture-password \
"$FIXTURE/repo/deploy/install-from-git.sh"
PATH="$FIXTURE/bin:$PATH" \
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
KAIDI_INSTALLER_SHA256="$installer_sha256" \
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
KAIDI_RELEASE_PUBLIC_KEY_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
KAIDI_SETUP_WIZARD=true \
"$FIXTURE/repo/deploy/install-from-git.sh"
if PATH="$FIXTURE/bin:$PATH" \
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
KAIDI_INSTALLER_SHA256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \
"$FIXTURE/repo/deploy/install-from-git.sh" > "$FIXTURE/hash-mismatch.log" 2>&1; then
printf 'Mismatched installer SHA-256 was accepted\n' >&2
exit 1
fi
grep -q 'does not match the trusted SHA-256' "$FIXTURE/hash-mismatch.log"
printf 'Git checkout installation wrapper fixture passed\n'
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
fail() {
printf 'Gitea publish fixture failed: %s\n' "$1" >&2
exit 1
}
VERSION=1.2.3-preview.1
TAG=v$VERSION
TOKEN=fixture-release-token
RELEASE_DIR=$WORK/release
MOCK_BIN=$WORK/bin
mkdir -p "$RELEASE_DIR" "$MOCK_BIN"
for name in \
"kaidi-finance-$VERSION.tar.gz" \
backend-sbom.cdx.json \
frontend-sbom.cdx.json \
release-manifest.json \
release-manifest.sig \
release-public.pem \
install.sh \
bootstrap-checksums.txt \
SHA256SUMS; do
printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name"
done
cat > "$MOCK_BIN/curl" <<'SH'
#!/usr/bin/env bash
set -Eeuo pipefail
method=GET
output=
form=
url=
header_file=
printf '%s\n' "$*" >> "$MOCK_CURL_LOG"
while [ "$#" -gt 0 ]; do
case "$1" in
--request) shift; method=$1 ;;
--output) shift; output=$1 ;;
--form) shift; form=$1; method=POST ;;
--header)
shift
case "$1" in @*) header_file=${1#@} ;; esac
;;
https://*) url=$1 ;;
esac
shift
done
[ -r "$header_file" ] || exit 90
grep -Fqx "Authorization: token $MOCK_GITEA_TOKEN" "$header_file" || exit 91
case "$method:$url" in
GET:*/releases/tags/*)
printf '{"message":"not found"}\n' > "$output"
printf '404'
;;
POST:*/releases)
printf '{"id":7,"draft":true,"prerelease":false}\n' > "$output"
printf '201'
;;
POST:*/releases/7/assets*)
path=${form#attachment=@}
path=${path%%;type=*}
name=$(basename "$path")
size=$(wc -c < "$path" | tr -d '[:space:]')
jq -cn --arg name "$name" --argjson size "$size" '{name:$name,size:$size}' >> "$MOCK_ASSET_LOG"
printf '{"name":"%s","size":%s}\n' "$name" "$size" > "$output"
printf '201'
;;
GET:*/releases/7)
jq -s '{id:7,draft:true,prerelease:false,assets:.}' "$MOCK_ASSET_LOG" > "$output"
printf '200'
;;
PATCH:*/releases/7)
jq -s '{id:7,draft:false,prerelease:false,assets:.}' "$MOCK_ASSET_LOG" > "$output"
printf '200'
;;
*)
printf 'unexpected mock request: %s %s\n' "$method" "$url" >&2
exit 92
;;
esac
SH
chmod 0755 "$MOCK_BIN/curl"
: > "$WORK/curl.log"
: > "$WORK/assets.jsonl"
PATH="$MOCK_BIN:$PATH" \
MOCK_CURL_LOG="$WORK/curl.log" \
MOCK_ASSET_LOG="$WORK/assets.jsonl" \
MOCK_GITEA_TOKEN="$TOKEN" \
GITEA_SERVER_URL=https://gitea.fixture.invalid \
GITEA_REPOSITORY=ERP-Team/kaidi \
GITEA_REF_NAME="$TAG" \
GITEA_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
GITEA_TOKEN="$TOKEN" \
KAIDI_RELEASE_DIR="$RELEASE_DIR" \
"$ROOT/scripts/publish-gitea-release.sh" > "$WORK/publish.log"
grep -Fqx "Published Gitea release $TAG with nine verified assets" "$WORK/publish.log" \
|| fail 'publish script did not report success'
[ "$(wc -l < "$WORK/assets.jsonl" | tr -d '[:space:]')" -eq 9 ] \
|| fail 'publish script did not upload exactly nine assets'
! grep -Fq "$TOKEN" "$WORK/curl.log" \
|| fail 'Gitea write token leaked into curl process arguments'
printf 'Gitea draft, nine-asset verification, and publication fixture passed\n'
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
fail() {
printf 'Install fixture failed: %s\n' "$1" >&2
exit 1
}
# Load only pure helper functions. The installer itself must never run in this fixture.
{
sed -n '/^decode_env_value()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^read_env_file()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^read_existing_env()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^read_setup_env()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^read_reinstall_env()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^write_env_file_preserving_unknown()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^azul_arch()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^download_release_url()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh"
sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh"
} > "$WORK/helpers.sh"
# shellcheck disable=SC1090,SC1091
source "$WORK/helpers.sh"
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
REINSTALL=true
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
WORK_DIR=$WORK
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
CONFIG_ROOT=$WORK
STATE_ROOT=$WORK/state
mkdir -p "$STATE_ROOT/setup"
env_file="$WORK/kaidi.env"
cat > "$env_file" <<'ENV'
# operator-owned values must survive a reinstall
FINANCE_SESSION_ABSOLUTE_TIMEOUT="45m"
FILE_SCANNER_HOST="scanner.internal"
FILE_SCANNER_PORT="3310"
DB_URL="jdbc:mysql://old/kaidi_finance"
APP_VERSION="old"
ENV
# shellcheck disable=SC2016 # These shell characters must remain literal data.
literal_password='pw$HOME-$(touch PLACEHOLDER)-`id`-back\slash-"quote"'
literal_password=${literal_password/PLACEHOLDER/$WORK\/executed}
write_env_file_preserving_unknown "$env_file" \
DB_URL 'jdbc:mysql://new/kaidi_finance' \
DB_PASSWORD "$literal_password" \
APP_VERSION '1.0.0-preview.1'
grep -qx 'FINANCE_SESSION_ABSOLUTE_TIMEOUT="45m"' "$env_file" \
|| fail 'reinstall removed the custom session timeout'
grep -qx 'FILE_SCANNER_HOST="scanner.internal"' "$env_file" \
|| fail 'reinstall removed the custom scanner host'
grep -qx 'DB_URL="jdbc:mysql://new/kaidi_finance"' "$env_file" \
|| fail 'reinstall did not replace the managed database URL'
[ "$(grep -c '^DB_URL=' "$env_file")" -eq 1 ] || fail 'reinstall duplicated a managed key'
[ "$(grep -c '^APP_VERSION=' "$env_file")" -eq 1 ] || fail 'reinstall duplicated the application version'
[ "$(read_existing_env DB_PASSWORD)" = "$literal_password" ] \
|| fail 'reinstall changed literal shell characters in the database password'
[ ! -e "$WORK/executed" ] || fail 'reinstall executed database password content'
cat > "$STATE_ROOT/setup/application.env" <<'ENV'
DB_URL="jdbc:mysql://runtime/kaidi_finance"
DB_USERNAME="runtime-user"
DB_PASSWORD="runtime-password"
ENV
grep -qx 'jdbc:mysql://runtime/kaidi_finance' <(read_reinstall_env DB_URL) \
|| fail 'reinstall did not prefer the completed setup runtime database URL'
grep -qx 'runtime-user' <(read_reinstall_env DB_USERNAME) \
|| fail 'reinstall did not prefer the completed setup runtime database user'
for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do
is_semver "$version" || fail "installer rejected valid SemVer $version"
"$ROOT/scripts/check-semver.sh" "$version" || fail "release workflow rejected valid SemVer $version"
done
for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
! is_semver "$version" || fail "installer accepted invalid SemVer $version"
! "$ROOT/scripts/check-semver.sh" "$version" >/dev/null 2>&1 \
|| fail "release workflow accepted invalid SemVer $version"
done
ARCH_FIXTURE=
uname() {
if [ "${1:-}" = -m ]; then
printf '%s\n' "$ARCH_FIXTURE"
else
command uname "$@"
fi
}
die() {
return 1
}
for arch in i386 i486 i586 i686; do
ARCH_FIXTURE=$arch
[ "$(azul_arch)" = i686 ] || fail "$arch did not map to the Azul i686 runtime"
done
mkdir -p "$WORK/fake-jre/bin"
cat > "$WORK/fake-jre/bin/java" <<'JAVA'
#!/usr/bin/env sh
printf 'openjdk version "17-fixture"\n' >&2
JAVA
chmod 0755 "$WORK/fake-jre/bin/java"
tar -czf "$WORK/java-fixture.tar.gz" -C "$WORK" fake-jre
java_fixture_sha=$(sha256sum "$WORK/java-fixture.tar.gz" | awk '{print $1}')
cat > "$WORK/java-list.json" <<'JSON'
[{"name":"zulu17-fixture-linux_i686.tar.gz","package_uuid":"fixture-package"}]
JSON
cat > "$WORK/java-detail.json" <<JSON
{"download_url":"https://cdn.azul.com/zulu/bin/zulu17-fixture-linux_i686.tar.gz",
"sha256_hash":"$java_fixture_sha"}
JSON
download() {
case "$1" in
*'/packages/?'*) cp "$WORK/java-list.json" "$2" ;;
*/packages/fixture-package) cp "$WORK/java-detail.json" "$2" ;;
https://cdn.azul.com/*) cp "$WORK/java-fixture.tar.gz" "$2" ;;
*) fail "unexpected Java fixture URL: $1" ;;
esac
}
ARCH_FIXTURE=i686
prepare_java >/dev/null 2>&1
[ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged'
export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest
RELEASE_TOKEN=fixture-read-only-token
RELEASE_AUTH_HEADER_FILE=$WORK/release-auth-header
printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE"
chmod 0600 "$RELEASE_AUTH_HEADER_FILE"
cat > "$WORK/release-api.json" <<'JSON'
{"assets":[
{"name":"release-manifest.json","browser_download_url":"https://gitea.fixture.invalid/assets/release-manifest.json"}
]}
JSON
printf 'signed manifest fixture\n' > "$WORK/release-manifest.fixture"
: > "$WORK/release-curl.log"
curl() {
local output='' url='' header_file='' argument=''
printf '%s\n' "$*" >> "$WORK/release-curl.log"
for argument in "$@"; do
case "$argument" in
@*) header_file=${argument#@} ;;
https://*) url=$argument ;;
esac
done
while [ "$#" -gt 0 ]; do
case "$1" in
-o) shift; output=$1 ;;
esac
shift
done
[ "$header_file" = "$RELEASE_AUTH_HEADER_FILE" ] || return 90
grep -Fqx "Authorization: token $RELEASE_TOKEN" "$header_file" || return 91
[ "$url" = https://gitea.fixture.invalid/assets/release-manifest.json ] || return 92
cp "$WORK/release-manifest.fixture" "$output"
}
[ "$(release_asset_url release-manifest.json)" = \
https://gitea.fixture.invalid/assets/release-manifest.json ] \
|| fail 'installer did not resolve the private Gitea release asset'
download_release_asset release-manifest.json "$WORK/downloaded-manifest.json"
cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \
|| fail 'installer did not download the private Gitea release asset'
! grep -Fq "$RELEASE_TOKEN" "$WORK/release-curl.log" \
|| fail 'installer leaked the private Gitea token into curl process arguments'
! grep -Fq -- '--location' "$WORK/release-curl.log" \
|| fail 'installer allowed authenticated Gitea redirects'
jq '.assets[0].browser_download_url = "https://assets.fixture.invalid/release-manifest.json"' \
"$WORK/release-api.json" > "$WORK/release-api.cross-origin.json"
mv "$WORK/release-api.cross-origin.json" "$WORK/release-api.json"
if release_asset_url release-manifest.json >/dev/null 2>&1; then
fail 'installer accepted a cross-origin Gitea release asset'
fi
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer rejects unsupported custom roots'
grep -Fq '8.4.*) ;;' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer enforces MySQL 8.4.x'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer uses the reinstall-safe environment writer'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'download "$api" "$java_metadata"' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer applies the restricted downloader to Java metadata'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer obtains the Java runtime SHA-256'
# shellcheck disable=SC2016 # Match literal installer source.
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer verifies the Java runtime SHA-256'
grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \
|| fail 'installer default release source is not the private Gitea latest API'
grep -Fq 'KAIDI_RELEASE_TOKEN_FILE' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer supports a protected initial Gitea token file'
grep -Fq 'KAIDI_SETUP_WIZARD' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer supports first-run setup mode'
grep -Fq 'FINANCE_SETUP_TOKEN_SHA256' "$ROOT/deploy/install.sh" \
|| fail 'installer no longer writes the one-time setup-code hash'
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
"$ROOT/deploy/systemd/kaidi-finance.service" \
|| fail 'application service no longer loads the setup-completion environment'
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
"$ROOT/deploy/systemd/kaidi-update.service" \
|| fail 'update service no longer loads setup database overrides'
# shellcheck disable=SC2016 # Match the literal installer source.
grep -Fq 'chown root:kaidi "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|| fail 'update state parent is not group-accessible to the application user'
grep -Fq 'KAIDI_SETUP_WIZARD=true' "$ROOT/README.md" \
|| fail 'README does not document the setup-wizard install path'
printf 'Install configuration, private Gitea, i686, setup wizard, and MySQL 8.4 fixtures passed\n'
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
REAL_OPENSSL=$(command -v openssl)
fail() {
printf 'Update fixture failed: %s\n' "$1" >&2
exit 1
}
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/update.sh" > "$WORK/update-semver.sh"
# shellcheck disable=SC1090,SC1091
source "$WORK/update-semver.sh"
for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do
is_semver "$version" || fail "updater rejected valid SemVer $version"
done
for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
! is_semver "$version" || fail "updater accepted invalid SemVer $version"
done
write_mock_commands() {
local mock_bin=$1
mkdir -p "$mock_bin"
cat > "$mock_bin/curl" <<'SH'
#!/bin/sh
output=
url=
header_file=
printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}"
while [ "$#" -gt 0 ]; do
case "$1" in
-o) shift; output=$1 ;;
--header|-H)
shift
case "${1:-}" in @*) header_file=${1#@} ;; esac
;;
-*) ;;
*) url=$1 ;;
esac
shift
done
case "$url" in
"${MOCK_RELEASE_ORIGIN:-https://release.fixture.invalid}"/*)
if [ -n "${MOCK_EXPECT_RELEASE_TOKEN:-}" ]; then
[ -r "$header_file" ] || exit 90
grep -Fqx "Authorization: token $MOCK_EXPECT_RELEASE_TOKEN" "$header_file" || exit 91
printf 'AUTH_FILE_OK\n' >> "${MOCK_CURL_LOG:-/dev/null}"
fi
;;
esac
if [ -n "$output" ]; then
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output"
elif [ "$url" = "${KAIDI_PUBLIC_INDEX_URL:-http://127.0.0.1/}" ]; then
cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
else
[ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22
cp "$FIXTURE_RELEASE_ROOT/${url##*/}" "$output"
fi
elif [ "${MOCK_HEALTH:-success}" = success ]; then
printf '{"status":"UP"}\n'
elif [ "${MOCK_HEALTH:-success}" = fail-new ] \
&& [ "$(cat "$MOCK_APP_ROOT/current/VERSION" 2>/dev/null)" = '1.0.0-preview.1' ]; then
printf '{"status":"UP"}\n'
else
exit 22
fi
SH
cat > "$mock_bin/systemctl" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
exit 0
SH
cat > "$mock_bin/nginx" <<'SH'
#!/bin/sh
printf '%s\n' "$*" >> "$MOCK_NGINX_LOG"
exit 0
SH
cat > "$mock_bin/flock" <<'SH'
#!/bin/sh
exit 0
SH
cat > "$mock_bin/systemd-analyze" <<'SH'
#!/bin/sh
exit 0
SH
cat > "$mock_bin/mysqldump" <<'SH'
#!/bin/sh
if [ "${MOCK_MYSQLDUMP:-success}" = fail ]; then
printf 'partial dump\n'
exit 9
fi
printf '%s\n' '-- valid fixture dump' 'CREATE TABLE fixture (id INT);'
SH
cat > "$mock_bin/chown" <<'SH'
#!/bin/sh
exit 0
SH
cat > "$mock_bin/mv" <<'SH'
#!/bin/sh
case "${1:-}" in
-Tf | -fT)
source=$2
destination=$3
/bin/rm -f "$destination"
/bin/mv "$source" "$destination"
;;
*) /bin/mv "$@" ;;
esac
SH
cat > "$mock_bin/sha256sum" <<'SH'
#!/bin/sh
hash=$($REAL_OPENSSL dgst -sha256 "$1" | awk '{print $NF}')
printf '%s %s\n' "$hash" "$1"
SH
chmod 0755 "$mock_bin"/*
}
build_release() {
local fixture=$1
local version=$2
local stage="$fixture/stage"
mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
printf 'new application\n' > "$stage/app.jar"
printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
printf '%s\n' "$version" > "$stage/VERSION"
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
chmod 0755 "$stage/ops/update.sh"
for name in kaidi-finance.service kaidi-update.service kaidi-update.path kaidi-finance.conf; do
printf 'new %s\n' "$name" > "$stage/ops/$name"
done
local artifact="kaidi-finance-$version.tar.gz"
COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" .
local sha
sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}')
jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" \
'{version:$version,artifact:$artifact,sha256:$sha,publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \
> "$fixture/release/release-manifest.json"
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out "$fixture/private.pem" >/dev/null 2>&1
"$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \
-out "$fixture/release-public.pem" >/dev/null 2>&1
"$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \
-out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json"
}
build_gitea_release_index() {
local fixture=$1
local origin=${2:-https://gitea.fixture.invalid}
local artifact
artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json")
jq -n --arg origin "$origin" --arg artifact "$artifact" \
'{assets:[
{name:"release-manifest.json",browser_download_url:($origin + "/assets/release-manifest.json")},
{name:"release-manifest.sig",browser_download_url:($origin + "/assets/release-manifest.sig")},
{name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)}
]}' > "$fixture/release/release-api.json"
}
prepare_installation() {
local fixture=$1
local version=$2
mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \
"$fixture/state/inbox" "$fixture/systemd" "$fixture/nginx" "$fixture/log"
printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar"
printf '<!doctype html><title>old</title>\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html"
printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION"
ln -s "$fixture/app/releases/1.0.0-preview.1" "$fixture/app/current"
printf 'old update.sh\n' > "$fixture/app/bin/update.sh"
chmod 0755 "$fixture/app/bin/update.sh"
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
printf 'old %s\n' "$name" > "$fixture/systemd/$name"
done
printf 'old kaidi-finance.conf\n' > "$fixture/nginx/kaidi-finance.conf"
write_request "$fixture" "$version" DOWNLOAD
}
write_request() {
local fixture=$1
local version=$2
local action=$3
mkdir -p "$fixture/state/inbox"
jq -n --arg action "$action" --arg version "$version" \
'{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json"
}
download_and_prepare_install() {
local fixture=$1
local version=$2
run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'download phase did not persist READY'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'download phase changed the active application'
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \
|| fail 'download phase did not persist the verified artifact cache'
write_request "$fixture" "$version" INSTALL
}
run_update() {
local fixture=$1
local health=$2
local skip_backup=${3:-true}
env \
PATH="$fixture/mock-bin:$PATH" \
REAL_OPENSSL="$REAL_OPENSSL" \
FIXTURE_RELEASE_ROOT="$fixture/release" \
MOCK_APP_ROOT="$fixture/app" \
MOCK_HEALTH="$health" \
MOCK_DOWNLOAD_FAILURE="${MOCK_DOWNLOAD_FAILURE:-}" \
MOCK_MYSQLDUMP="${MOCK_MYSQLDUMP:-success}" \
MOCK_CURL_LOG="${MOCK_CURL_LOG:-$fixture/curl.log}" \
MOCK_RELEASE_API_URL="${FIXTURE_RELEASE_API_URL-}" \
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
MOCK_NGINX_LOG="$fixture/nginx.log" \
KAIDI_APP_ROOT="$fixture/app" \
KAIDI_UPDATE_STATE_ROOT="$fixture/state" \
KAIDI_LOG_ROOT="$fixture/log" \
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
KAIDI_NGINX_CONFIG="$fixture/nginx/kaidi-finance.conf" \
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \
UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \
UPDATE_RELEASE_API_URL="${FIXTURE_RELEASE_API_URL-}" \
UPDATE_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \
UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \
UPDATE_STATUS_FILE="$fixture/state/status.json" \
KAIDI_HEALTH_URL=http://127.0.0.1:18080/actuator/health \
KAIDI_PUBLIC_HEALTH_URL=http://127.0.0.1/actuator/health \
KAIDI_PUBLIC_INDEX_URL=http://127.0.0.1/ \
"$ROOT/deploy/update.sh"
}
assert_private_gitea_release_case() {
local fixture="$WORK/private-gitea"
local version='1.0.0-preview.2'
local api_url='https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest'
local token='fixture-read-only-token'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
build_gitea_release_index "$fixture"
prepare_installation "$fixture" "$version"
FIXTURE_RELEASE_BASE_URL='' \
FIXTURE_RELEASE_API_URL="$api_url" \
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
FIXTURE_RELEASE_TOKEN="$token" \
MOCK_CURL_LOG="$fixture/curl.log" \
run_update "$fixture" success
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|| fail 'private Gitea download did not persist READY'
! grep -Fq "$token" "$fixture/curl.log" \
|| fail 'private Gitea token leaked into curl process arguments'
[ "$(grep -c '^AUTH_FILE_OK$' "$fixture/curl.log")" -eq 4 ] \
|| fail 'private Gitea requests did not use the protected header file'
! grep -Fq -- '--location' "$fixture/curl.log" \
|| fail 'authenticated private Gitea requests unexpectedly enabled redirects'
}
assert_cross_origin_gitea_asset_rejected() {
local fixture="$WORK/cross-origin-gitea"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
build_gitea_release_index "$fixture" https://assets.fixture.invalid
prepare_installation "$fixture" "$version"
if FIXTURE_RELEASE_BASE_URL='' \
FIXTURE_RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest \
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
FIXTURE_RELEASE_TOKEN=fixture-read-only-token \
MOCK_CURL_LOG="$fixture/curl.log" \
run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'cross-origin Gitea asset unexpectedly succeeded'
fi
grep -q 'Release manifest asset is missing' "$fixture/update.log" \
|| fail 'cross-origin Gitea asset rejection was not reported'
! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \
|| fail 'cross-origin Gitea asset was requested'
}
assert_success_case() {
local fixture="$WORK/success"
local version='1.0.0-preview.2+build.7'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
run_update "$fixture" success
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|| fail 'success case did not activate the new application'
cmp -s "$fixture/app/bin/update.sh" "$fixture/app/current/ops/update.sh" \
|| fail 'success case did not activate the signed updater'
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|| fail 'success case did not persist SUCCEEDED'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'success case left a claimed request behind'
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
grep -qx 'reload nginx' "$fixture/systemctl.log" || fail 'Nginx was not reloaded'
}
assert_rollback_case() {
local fixture="$WORK/rollback"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then
fail 'rollback case unexpectedly succeeded'
fi
grep -q 'previous release was restored and verified' "$fixture/update.log" \
|| fail 'rollback case did not report a complete restoration'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'rollback case did not restore the previous application'
grep -qx 'old update.sh' "$fixture/app/bin/update.sh" \
|| fail 'rollback case did not restore the previous updater'
grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \
|| fail 'rollback case did not restore the previous path unit'
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'rollback case did not persist FAILED'
[ ! -e "$fixture/app/releases/$version" ] \
|| fail 'rollback case left the failed release installed'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'rollback case did not archive the failed request'
}
assert_download_failure_case() {
local fixture="$WORK/download-failure"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
if MOCK_DOWNLOAD_FAILURE=release-manifest.json run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'download failure case unexpectedly succeeded'
fi
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'download failure did not persist FAILED'
[ ! -e "$fixture/state/processing/request.json" ] \
|| fail 'download failure left a busy processing request'
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|| fail 'download failure did not archive the request'
}
assert_database_failure_case() {
local fixture="$WORK/database-failure"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
download_and_prepare_install "$fixture" "$version"
if MOCK_MYSQLDUMP=fail run_update "$fixture" success false > "$fixture/update.log" 2>&1; then
fail 'database failure case unexpectedly succeeded'
fi
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'database failure changed the active application'
find "$fixture/state/backups" -type f -name 'mysql-*.sql.gz' -print -quit | grep -q . \
&& fail 'database failure produced a trusted backup artifact'
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|| fail 'database failure did not persist FAILED'
}
assert_symlink_request_rejected() {
local fixture="$WORK/symlink-request"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
printf '%s\n' 'operator-owned sentinel' > "$fixture/sentinel"
rm "$fixture/state/inbox/request.json"
ln -s "$fixture/sentinel" "$fixture/state/inbox/request.json"
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'symlink request case unexpectedly succeeded'
fi
grep -q 'Update request must be a regular file' "$fixture/update.log" \
|| fail 'symlink request case did not report the unsafe request'
[ "$(cat "$fixture/sentinel")" = 'operator-owned sentinel' ] \
|| fail 'symlink request case changed the link target'
[ ! -e "$fixture/state/inbox/request.json" ] && [ ! -L "$fixture/state/inbox/request.json" ] \
|| fail 'symlink request case left the unsafe request in the inbox'
local actual_state
actual_state=$(jq -r '.state // "MISSING"' "$fixture/state/status.json")
[ "$actual_state" = FAILED ] \
|| fail "symlink request case persisted state $actual_state instead of FAILED"
}
assert_install_without_verified_cache_rejected() {
local fixture="$WORK/install-without-cache"
local version='1.0.0-preview.2'
mkdir -p "$fixture"
write_mock_commands "$fixture/mock-bin"
build_release "$fixture" "$version"
prepare_installation "$fixture" "$version"
write_request "$fixture" "$version" INSTALL
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
fail 'install without cache unexpectedly succeeded'
fi
grep -q 'Verified release cache is missing' "$fixture/update.log" \
|| fail 'install without cache did not report the missing verified cache'
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|| fail 'install without cache changed the active application'
}
assert_success_case
assert_rollback_case
assert_download_failure_case
assert_database_failure_case
assert_symlink_request_rejected
assert_install_without_verified_cache_rejected
assert_private_gitea_release_case
assert_cross_origin_gitea_asset_rejected
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
RELEASE_DIR=${1:-$ROOT/dist/release}
TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256:-}
EXPECTED_SOURCE_REVISION=${KAIDI_EXPECTED_SOURCE_REVISION:-}
EXPECTED_SOURCE_REF=${KAIDI_EXPECTED_SOURCE_REF:-}
EXPECTED_SOURCE_DIRTY=${KAIDI_EXPECTED_SOURCE_DIRTY:-}
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' 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
}
cd "$RELEASE_DIR"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c SHA256SUMS
else
shasum -a 256 -c SHA256SUMS
fi
openssl dgst -sha256 -verify release-public.pem \
-signature release-manifest.sig release-manifest.json
VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json)
"$ROOT/scripts/check-semver.sh" "$VERSION" \
|| { printf 'Release version is not valid SemVer\n' >&2; exit 1; }
ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json)
[ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; }
[ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \
|| { printf 'Release artifact name does not match the version\n' >&2; exit 1; }
printf '%s\n' \
SHA256SUMS \
backend-sbom.cdx.json \
bootstrap-checksums.txt \
frontend-sbom.cdx.json \
install.sh \
"$ARTIFACT" \
release-manifest.json \
release-manifest.sig \
release-public.pem | LC_ALL=C sort > "$WORK/expected-assets.txt"
for path in ./* ./.[!.]* ./..?*; do
[ -e "$path" ] || [ -L "$path" ] || continue
basename "$path"
done | LC_ALL=C sort > "$WORK/actual-assets.txt"
diff -u "$WORK/expected-assets.txt" "$WORK/actual-assets.txt" \
|| { printf 'Release directory must contain exactly the nine published assets\n' >&2; exit 1; }
EXPECTED_ARTIFACT_SHA256=$(jq -er \
'.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \
release-manifest.json)
[ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \
|| { printf 'Release artifact digest does not match the signed manifest\n' >&2; exit 1; }
[ "$(jq '.sboms | length' release-manifest.json)" -eq 2 ] \
|| { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; }
jq -e --arg version "$VERSION" \
'.source.revision | strings | test("^[0-9a-f]{40}$")' release-manifest.json >/dev/null \
|| { printf 'Release manifest source revision is invalid\n' >&2; exit 1; }
jq -e \
'.source.ref | strings | (length > 0 and length <= 128)' release-manifest.json >/dev/null \
|| { printf 'Release manifest source ref is invalid\n' >&2; exit 1; }
jq -e '(.source.dirty | type) == "boolean"' release-manifest.json >/dev/null \
|| { printf 'Release manifest source dirty flag is invalid\n' >&2; exit 1; }
SOURCE_REVISION=$(jq -er '.source.revision | ascii_downcase' release-manifest.json)
SOURCE_REF=$(jq -er '.source.ref' release-manifest.json)
SOURCE_DIRTY=$(jq -er '.source.dirty | tostring' release-manifest.json)
if [[ "$SOURCE_REF" == v* ]] && [ "$SOURCE_REF" != "v$VERSION" ]; then
printf 'Release manifest source tag does not match the version\n' >&2
exit 1
fi
if [ -n "$EXPECTED_SOURCE_REVISION" ]; then
NORMALIZED_EXPECTED_SOURCE_REVISION=$(printf '%s' "$EXPECTED_SOURCE_REVISION" | tr '[:upper:]' '[:lower:]')
[[ "$NORMALIZED_EXPECTED_SOURCE_REVISION" =~ ^[0-9a-f]{40}$ ]] \
|| { printf 'KAIDI_EXPECTED_SOURCE_REVISION must be a 40-character Git commit SHA\n' >&2; exit 1; }
[ "$SOURCE_REVISION" = "$NORMALIZED_EXPECTED_SOURCE_REVISION" ] \
|| { printf 'Release manifest source revision does not match the expected commit\n' >&2; exit 1; }
fi
if [ -n "$EXPECTED_SOURCE_REF" ] && [ "$SOURCE_REF" != "$EXPECTED_SOURCE_REF" ]; then
printf 'Release manifest source ref does not match the expected ref\n' >&2
exit 1
fi
if [ -n "$EXPECTED_SOURCE_DIRTY" ]; then
[[ "$EXPECTED_SOURCE_DIRTY" = true || "$EXPECTED_SOURCE_DIRTY" = false ]] \
|| { printf 'KAIDI_EXPECTED_SOURCE_DIRTY must be true or false\n' >&2; exit 1; }
[ "$SOURCE_DIRTY" = "$EXPECTED_SOURCE_DIRTY" ] \
|| { printf 'Release manifest source dirty flag does not match the expected state\n' >&2; exit 1; }
fi
jq -e --arg version "$VERSION" \
'.buildVersions.backend == $version and .buildVersions.frontend == $version' \
release-manifest.json >/dev/null \
|| { printf 'Release build versions do not match the manifest version\n' >&2; exit 1; }
for ecosystem in maven npm; do
file=$(jq -er --arg ecosystem "$ecosystem" \
'.sboms[] | select(.ecosystem == $ecosystem) | .artifact' release-manifest.json)
expected=$(jq -er --arg ecosystem "$ecosystem" \
'.sboms[] | select(.ecosystem == $ecosystem) | .sha256' release-manifest.json)
[ "$(sha256_file "$file")" = "$expected" ] \
|| { printf '%s SBOM digest does not match the manifest\n' "$ecosystem" >&2; exit 1; }
jq -e --arg version "$VERSION" \
'. as $document | .metadata.component.version == $version
and any(.dependencies[]; .ref == $document.metadata.component["bom-ref"])' \
"$file" >/dev/null
done
PUBLIC_KEY_SHA256=$(sha256_file release-public.pem)
INSTALLER_SHA256=$(sha256_file install.sh)
BOOTSTRAP_SHA256=$(sha256_file bootstrap-checksums.txt)
SIGNED_INSTALLER=$(jq -er '.bootstrap.installer' release-manifest.json)
SIGNED_PUBLIC_KEY=$(jq -er '.bootstrap.publicKey' release-manifest.json)
SIGNED_BOOTSTRAP=$(jq -er '.bootstrap.checksums' release-manifest.json)
[ "$SIGNED_INSTALLER" = install.sh ] && [ "$SIGNED_PUBLIC_KEY" = release-public.pem ] \
&& [ "$SIGNED_BOOTSTRAP" = bootstrap-checksums.txt ] \
|| { printf 'Release manifest bootstrap asset names are invalid\n' >&2; exit 1; }
jq -e --arg installer "$INSTALLER_SHA256" --arg publicKey "$PUBLIC_KEY_SHA256" \
--arg bootstrap "$BOOTSTRAP_SHA256" \
'.bootstrap.installerSha256 == $installer
and .bootstrap.publicKeySha256 == $publicKey
and .bootstrap.checksumsSha256 == $bootstrap' release-manifest.json >/dev/null \
|| { printf 'Bootstrap assets do not match the signed manifest\n' >&2; exit 1; }
NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]')
[[ "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|| { printf 'KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256 must be 64 hexadecimal characters\n' >&2; exit 1; }
[ "$PUBLIC_KEY_SHA256" = "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" ] \
|| { printf 'Release public key does not match the trusted fingerprint\n' >&2; exit 1; }
[ "$(sed -n 's/^KAIDI_RELEASE_PUBLIC_KEY_SHA256=//p' bootstrap-checksums.txt)" = "$PUBLIC_KEY_SHA256" ] \
|| { printf 'Bootstrap public-key fingerprint does not match\n' >&2; exit 1; }
[ "$(sed -n 's/^KAIDI_INSTALLER_SHA256=//p' bootstrap-checksums.txt)" = "$INSTALLER_SHA256" ] \
|| { printf 'Bootstrap installer digest does not match\n' >&2; exit 1; }
ARCHIVE_LIST=$(tar -tzf "$ARTIFACT")
if grep -Eq '(^/|(^|/)\.\.(/|$))' <<< "$ARCHIVE_LIST"; then
printf 'Release archive contains an unsafe path\n' >&2
exit 1
fi
if grep -Eq '(^|/)(signing-private|private-key|id_rsa)' <<< "$ARCHIVE_LIST"; then
printf 'Release archive contains private key material\n' >&2
exit 1
fi
[ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \
|| { printf 'Release archive version does not match the manifest\n' >&2; exit 1; }
tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar"
(cd "$WORK" && jar -xf app.jar \
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties)
JAR_POM_VERSION=$(sed -n 's/^version=//p' \
"$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties")
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
"$WORK/META-INF/MANIFEST.MF" | tr -d '\r')
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|| { printf 'Release JAR metadata versions do not match the manifest\n' >&2; exit 1; }
compare_archive_file() {
archive_path=$1
source_path=$2
cmp <(tar -xOf "$ARTIFACT" "$archive_path") "$ROOT/$source_path"
}
compare_archive_file ./ops/update.sh deploy/update.sh
compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path
compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service
compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.service
compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf
cmp install.sh "$ROOT/deploy/install.sh" \
|| { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; }
printf 'Release %s verified; installer SHA-256: %s\n' "$VERSION" "$INSTALLER_SHA256"