#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" fail() { echo "release-preflight: $*" >&2 exit 1 } require_file() { [[ -f "$1" ]] || fail "missing artifact: $1; build it before release" } sha256() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else sha256sum "$1" | awk '{print $1}' fi } sha256_stream() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 | awk '{print $1}' else sha256sum | awk '{print $1}' fi } normalized_jar_content_sha256() { local jar_path="$1" local manifest_path manifest_path="$(mktemp)" while IFS= read -r entry; do case "$entry" in */|META-INF/*.SF|META-INF/*.RSA|META-INF/*.DSA) continue ;; esac printf '%s %s\n' "$(unzip -p "$jar_path" "$entry" | sha256_stream)" "$entry" >> "$manifest_path" done < <(unzip -Z1 "$jar_path" | LC_ALL=C sort) sha256 "$manifest_path" rm -f "$manifest_path" } frontend_index="frontend/dist/index.html" mobile_index="mobile-uni/dist/build/h5/index.html" backend_jar="backend/ruoyi-admin/target/ruoyi-admin.jar" require_file "$frontend_index" require_file "$mobile_index" require_file "$backend_jar" backend_module_jar_name="$(unzip -Z1 "$backend_jar" | sed -nE 's#BOOT-INF/lib/(ruoyi-aihr-[^/]+\.jar)#\1#p' | head -1)" [[ -n "$backend_module_jar_name" ]] || fail "backend jar does not contain the ruoyi-aihr module" frontend_asset="$(sed -nE 's/.*src="([^"]+\.js)".*/\1/p' "$frontend_index" | head -1)" mobile_asset="$(sed -nE 's/.*src="([^"]+\.js)".*/\1/p' "$mobile_index" | head -1)" [[ -n "$frontend_asset" ]] || fail "frontend index does not reference a JavaScript entry" [[ -n "$mobile_asset" ]] || fail "mobile H5 index does not reference a JavaScript entry" frontend_asset_path="frontend/dist/${frontend_asset#/}" mobile_asset_rel="${mobile_asset#/}" mobile_asset_rel="${mobile_asset_rel#h5/}" mobile_asset_path="mobile-uni/dist/build/h5/$mobile_asset_rel" require_file "$frontend_asset_path" require_file "$mobile_asset_path" grep -q '/h5/' "$mobile_index" || fail "mobile H5 index does not contain the /h5/ base path" if [[ "${RELEASE_VERIFY_REMOTE_BACKEND:-false}" == "true" && "${RELEASE_VERIFY_REMOTE_MATCH:-false}" != "true" ]]; then fail "RELEASE_VERIFY_REMOTE_BACKEND=true requires RELEASE_VERIFY_REMOTE_MATCH=true" fi if [[ "${RELEASE_VERIFY_REMOTE_SCHEMA:-false}" == "true" && "${RELEASE_VERIFY_REMOTE_MATCH:-false}" != "true" ]]; then fail "RELEASE_VERIFY_REMOTE_SCHEMA=true requires RELEASE_VERIFY_REMOTE_MATCH=true" fi head_epoch="$(git show -s --format=%ct HEAD)" file_epoch() { if stat -f %m "$1" >/dev/null 2>&1; then stat -f %m "$1" else stat -c %Y "$1" fi } require_fresh_artifact() { local artifact="$1" local artifact_epoch artifact_epoch="$(file_epoch "$artifact")" || fail "cannot read artifact timestamp: $artifact" [[ "$artifact_epoch" -ge "$head_epoch" ]] || fail "artifact is older than HEAD; rebuild before release: $artifact" } require_fresh_artifact "$frontend_index" require_fresh_artifact "$frontend_asset_path" require_fresh_artifact "$mobile_index" require_fresh_artifact "$mobile_asset_path" require_fresh_artifact "$backend_jar" require_remote_business_success() { local label="$1" local url="$2" local body body="$(curl -fsS --max-time 15 "$url")" || fail "$label HTTP check failed: $url" if ! printf '%s' "$body" | LC_ALL=C grep -Eq '^[[:space:]]*\{[[:space:]]*"code"[[:space:]]*:[[:space:]]*200([[:space:]]*[,}])'; then fail "$label business check failed: $url" fi echo "$label=200 $url" } require_remote_schema() { local remote_ssh="${RELEASE_REMOTE_SSH:-YCWY}" local remote_db="${RELEASE_REMOTE_DB_NAME:-ry-vue}" [[ "$remote_db" =~ ^[A-Za-z0-9_-]+$ ]] || fail "remote database name is invalid: $remote_db" local missing missing="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT required.table_name FROM ( SELECT 'aihr_prompt_template' AS table_name UNION ALL SELECT 'aihr_practice_scenario' UNION ALL SELECT 'aihr_practice_rubric' UNION ALL SELECT 'aihr_practice_rubric_dimension' UNION ALL SELECT 'aihr_practice_audio' UNION ALL SELECT 'aihr_practice_audio_upload' UNION ALL SELECT 'aihr_practice_calibration' UNION ALL SELECT 'aihr_practice_help_event' UNION ALL SELECT 'aihr_knowledge_gap' UNION ALL SELECT 'aihr_knowledge_answer_feedback' UNION ALL SELECT 'aihr_knowledge_space_grant' UNION ALL SELECT 'aihr_knowledge_app' UNION ALL SELECT 'aihr_knowledge_app_space' UNION ALL SELECT 'aihr_knowledge_category' UNION ALL SELECT 'aihr_knowledge_query_log' UNION ALL SELECT 'aihr_knowledge_conversation' UNION ALL SELECT 'aihr_knowledge_admin_audit' UNION ALL SELECT 'aihr_learning_question' UNION ALL SELECT 'aihr_practice_question_feedback' UNION ALL SELECT 'aihr_onboard_exam' UNION ALL SELECT 'aihr_onboard_exam_target' UNION ALL SELECT 'aihr_onboard_exam_question' UNION ALL SELECT 'aihr_onboard_exam_attempt' UNION ALL SELECT 'aihr_onboard_exam_answer' UNION ALL SELECT 'aihr_web_search_provider' UNION ALL SELECT 'aihr_web_ai_secret' UNION ALL SELECT 'aihr_web_ai_query_audit' UNION ALL SELECT 'aihr_community_question' UNION ALL SELECT 'aihr_community_answer' UNION ALL SELECT 'aihr_incentive_rule' UNION ALL SELECT 'aihr_points_ledger' UNION ALL SELECT 'aihr_candidate_material' UNION ALL SELECT 'aihr_interview_result' UNION ALL SELECT 'aihr_candidate_employee_link' UNION ALL SELECT 'aihr_position_responsibility' UNION ALL SELECT 'aihr_sop_applicability' UNION ALL SELECT 'aihr_onboard_task' UNION ALL SELECT 'aihr_qualification_gate' UNION ALL SELECT 'aihr_org_directory' UNION ALL SELECT 'aihr_tenant_org_binding' UNION ALL SELECT 'aihr_memory_candidate' UNION ALL SELECT 'aihr_assistant_capture' UNION ALL SELECT 'aihr_assistant_capture_status_log' UNION ALL SELECT 'aihr_daily_work_result' UNION ALL SELECT 'aihr_service_memory' UNION ALL SELECT 'aihr_service_memory_version' UNION ALL SELECT 'aihr_work_report' UNION ALL SELECT 'aihr_broadcast_message' UNION ALL SELECT 'aihr_broadcast_read' UNION ALL SELECT 'aihr_broadcast_version' UNION ALL SELECT 'aihr_broadcast_target_rule' UNION ALL SELECT 'aihr_broadcast_target_recipient' UNION ALL SELECT 'aihr_broadcast_attachment' UNION ALL SELECT 'aihr_direct_feedback' UNION ALL SELECT 'aihr_personal_space' UNION ALL SELECT 'aihr_personal_item' UNION ALL SELECT 'aihr_personal_fragment' UNION ALL SELECT 'aihr_personal_chat_session' UNION ALL SELECT 'aihr_personal_chat_message' UNION ALL SELECT 'aihr_personal_cleanup_job' UNION ALL SELECT 'aihr_personal_ocr_job' UNION ALL SELECT 'aihr_personal_ocr_page' UNION ALL SELECT 'aihr_personal_export_task' UNION ALL SELECT 'aihr_personal_publish_request' ) required LEFT JOIN information_schema.tables actual ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name WHERE actual.table_name IS NULL ORDER BY required.table_name; SQL REMOTE )" || fail "remote schema check failed: $remote_ssh:$remote_db" [[ -z "$missing" ]] || fail "remote schema missing required tables: $(printf '%s' "$missing" | tr '\n' ', ' | sed 's/, $//')" local personal_oss_config personal_oss_config="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( bucket_name, '|', access_policy, '|', status, '|', CASE WHEN access_key <> '' AND secret_key <> '' THEN 'configured' ELSE 'credentials-missing' END ) FROM sys_oss_config WHERE tenant_id = '000000' AND config_key = 'personal-minio'; SQL REMOTE )" || fail "remote personal OSS configuration check failed: $remote_ssh:$remote_db" [[ "$personal_oss_config" = "ruoyi-personal|0|1|configured" ]] \ || fail "remote personal OSS configuration is invalid: expected private ruoyi-personal configuration; got ${personal_oss_config:-missing}" local missing_work_report_columns missing_work_report_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT required.column_name FROM ( SELECT 'request_key' AS column_name UNION ALL SELECT 'request_hash' ) required LEFT JOIN information_schema.columns actual ON actual.table_schema = DATABASE() AND actual.table_name = 'aihr_work_report' AND actual.column_name = required.column_name WHERE actual.column_name IS NULL ORDER BY required.column_name; SQL REMOTE )" || fail "remote work-report schema check failed: $remote_ssh:$remote_db" [[ -z "$missing_work_report_columns" ]] \ || fail "remote work-report schema missing required columns: $(printf '%s' "$missing_work_report_columns" | tr '\n' ', ' | sed 's/, $//')" local missing_knowledge_category_columns missing_knowledge_category_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT(required.table_name, '.', required.column_name) FROM ( SELECT 'aihr_knowledge_category' AS table_name, 'tenant_id' AS column_name UNION ALL SELECT 'aihr_knowledge_category', 'knowledge_id' UNION ALL SELECT 'aihr_knowledge_category', 'code' UNION ALL SELECT 'aihr_knowledge_category', 'name' UNION ALL SELECT 'aihr_knowledge_category', 'status' UNION ALL SELECT 'aihr_knowledge_category', 'sort_order' UNION ALL SELECT 'aihr_knowledge_attach', 'category_id' ) required LEFT JOIN information_schema.columns actual ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name AND actual.column_name = required.column_name WHERE actual.column_name IS NULL ORDER BY required.table_name, required.column_name; SQL REMOTE )" || fail "remote knowledge-category schema check failed: $remote_ssh:$remote_db" [[ -z "$missing_knowledge_category_columns" ]] \ || fail "remote knowledge-category schema missing required columns: $(printf '%s' "$missing_knowledge_category_columns" | tr '\n' ', ' | sed 's/, $//')" local knowledge_attach_category_index knowledge_attach_category_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( MIN(non_unique), '|', COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', SUM(sub_part IS NOT NULL) ) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_attach' AND index_name = 'idx_aihr_knowledge_attach_category'; SQL REMOTE )" || fail "remote knowledge-attachment category-index check failed: $remote_ssh:$remote_db" [[ "$knowledge_attach_category_index" = "1|tenant_id,knowledge_id,category_id|0" ]] \ || fail "remote knowledge-attachment category index invalid: expected full columns tenant_id,knowledge_id,category_id; got ${knowledge_attach_category_index:-missing}" local knowledge_category_code_index knowledge_category_code_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( MIN(non_unique), '|', COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', SUM(sub_part IS NOT NULL) ) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_knowledge_category' AND index_name = 'uk_aihr_knowledge_category_code'; SQL REMOTE )" || fail "remote knowledge-category unique-index check failed: $remote_ssh:$remote_db" [[ "$knowledge_category_code_index" = "0|tenant_id,knowledge_id,code|0" ]] \ || fail "remote knowledge-category unique index invalid: expected unique full columns tenant_id,knowledge_id,code; got ${knowledge_category_code_index:-missing}" local work_report_unique_index work_report_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( MIN(non_unique), '|', COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', SUM(sub_part IS NOT NULL) ) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_work_report' AND index_name = 'uk_aihr_work_report_request'; SQL REMOTE )" || fail "remote work-report unique-index check failed: $remote_ssh:$remote_db" [[ "$work_report_unique_index" = "0|tenant_id,submitter_user_id,request_key|0" ]] \ || fail "remote work-report unique index invalid: expected unique full columns tenant_id,submitter_user_id,request_key; got ${work_report_unique_index:-missing}" local missing_broadcast_publish_columns missing_broadcast_publish_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT required.column_name FROM ( SELECT 'publish_request_key' AS column_name UNION ALL SELECT 'publish_request_hash' UNION ALL SELECT 'withdraw_reason' ) required LEFT JOIN information_schema.columns actual ON actual.table_schema = DATABASE() AND actual.table_name = 'aihr_broadcast_message' AND actual.column_name = required.column_name WHERE actual.column_name IS NULL ORDER BY required.column_name; SQL REMOTE )" || fail "remote broadcast publish-audit schema check failed: $remote_ssh:$remote_db" [[ -z "$missing_broadcast_publish_columns" ]] \ || fail "remote broadcast message schema missing required columns: $(printf '%s' "$missing_broadcast_publish_columns" | tr '\n' ', ' | sed 's/, $//')" local broadcast_publish_unique_index broadcast_publish_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( MIN(non_unique), '|', COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', SUM(sub_part IS NOT NULL) ) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' AND index_name = 'uk_aihr_broadcast_message_publish_request'; SQL REMOTE )" || fail "remote broadcast publish unique-index check failed: $remote_ssh:$remote_db" [[ "$broadcast_publish_unique_index" = "0|tenant_id,published_by,publish_request_key|0" ]] \ || fail "remote broadcast publish unique index invalid: expected unique full columns tenant_id,published_by,publish_request_key; got ${broadcast_publish_unique_index:-missing}" local broadcast_read_unique_index broadcast_read_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( MIN(non_unique), '|', COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', SUM(sub_part IS NOT NULL) ) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_read' AND index_name = 'uk_aihr_broadcast_read'; SQL REMOTE )" || fail "remote broadcast-read unique-index check failed: $remote_ssh:$remote_db" [[ "$broadcast_read_unique_index" = "0|tenant_id,message_id,user_id|0" ]] \ || fail "remote broadcast-read unique index invalid: expected unique full columns tenant_id,message_id,user_id; got ${broadcast_read_unique_index:-missing}" local broadcast_version_unique_index broadcast_version_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( MIN(non_unique), '|', COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', SUM(sub_part IS NOT NULL) ) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_version' AND index_name = 'uk_aihr_broadcast_version'; SQL REMOTE )" || fail "remote broadcast-version unique-index check failed: $remote_ssh:$remote_db" [[ "$broadcast_version_unique_index" = "0|tenant_id,message_id,version|0" ]] \ || fail "remote broadcast-version unique index invalid: expected unique full columns tenant_id,message_id,version; got ${broadcast_version_unique_index:-missing}" local broadcast_targeting_column_contract broadcast_targeting_column_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT(column_name, '|', column_type, '|', is_nullable, '|', COALESCE(column_default, '')) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' AND column_name IN ('required_read', 'target_payload_hash') ORDER BY FIELD(column_name, 'required_read', 'target_payload_hash'); SQL REMOTE )" || fail "remote broadcast M1 column check failed: $remote_ssh:$remote_db" [[ "$broadcast_targeting_column_contract" = $'required_read|tinyint(1)|NO|0\ntarget_payload_hash|char(64)|YES|' ]] \ || fail "remote broadcast M1 column contract invalid: expected required_read tinyint(1) NOT NULL DEFAULT 0 and nullable target_payload_hash char(64); got ${broadcast_targeting_column_contract:-missing}" local broadcast_target_table_contract broadcast_target_table_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT(table_name, '.', column_name, '|', column_type, '|', is_nullable) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name IN ('aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient') ORDER BY FIELD(table_name, 'aihr_broadcast_target_rule', 'aihr_broadcast_target_recipient'), ordinal_position; SQL REMOTE )" || fail "remote broadcast target-table contract check failed: $remote_ssh:$remote_db" [[ "$broadcast_target_table_contract" = $'aihr_broadcast_target_rule.id|bigint|NO\naihr_broadcast_target_rule.tenant_id|varchar(20)|NO\naihr_broadcast_target_rule.message_id|bigint|NO\naihr_broadcast_target_rule.target_type|varchar(20)|NO\naihr_broadcast_target_rule.target_value|varchar(100)|NO\naihr_broadcast_target_rule.create_time|datetime|NO\naihr_broadcast_target_recipient.id|bigint|NO\naihr_broadcast_target_recipient.tenant_id|varchar(20)|NO\naihr_broadcast_target_recipient.message_id|bigint|NO\naihr_broadcast_target_recipient.recipient_key|varchar(180)|NO\naihr_broadcast_target_recipient.subject_ref|varchar(100)|NO\naihr_broadcast_target_recipient.user_id|bigint|YES\naihr_broadcast_target_recipient.target_status|varchar(20)|NO\naihr_broadcast_target_recipient.target_reason|varchar(100)|NO\naihr_broadcast_target_recipient.create_time|datetime|NO\naihr_broadcast_target_recipient.update_time|datetime|NO' ]] \ || fail "remote broadcast target-table contract invalid: expected server-written recipient identity, status, reason, and timing fields; got ${broadcast_target_table_contract:-missing}" local broadcast_target_rule_unique_index broadcast_target_rule_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( MIN(non_unique), '|', COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', SUM(sub_part IS NOT NULL) ) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_target_rule' AND index_name = 'uk_aihr_broadcast_target_rule'; SQL REMOTE )" || fail "remote broadcast target-rule unique-index check failed: $remote_ssh:$remote_db" [[ "$broadcast_target_rule_unique_index" = "0|tenant_id,message_id,target_type,target_value|0" ]] \ || fail "remote broadcast target-rule unique index invalid: expected unique full columns tenant_id,message_id,target_type,target_value; got ${broadcast_target_rule_unique_index:-missing}" local broadcast_target_recipient_unique_index broadcast_target_recipient_unique_index="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( MIN(non_unique), '|', COALESCE(GROUP_CONCAT(column_name ORDER BY seq_in_index), ''), '|', SUM(sub_part IS NOT NULL) ) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_target_recipient' AND index_name = 'uk_aihr_broadcast_target_recipient'; SQL REMOTE )" || fail "remote broadcast target-recipient unique-index check failed: $remote_ssh:$remote_db" [[ "$broadcast_target_recipient_unique_index" = "0|tenant_id,message_id,recipient_key|0" ]] \ || fail "remote broadcast target-recipient unique index invalid: expected unique full columns tenant_id,message_id,recipient_key; got ${broadcast_target_recipient_unique_index:-missing}" local direct_feedback_contract direct_feedback_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( COUNT(DISTINCT c.column_name), '|', MIN(s.non_unique), '|', COALESCE(GROUP_CONCAT(DISTINCT s.column_name ORDER BY s.seq_in_index), ''), '|', SUM(s.sub_part IS NOT NULL) ) FROM information_schema.columns c LEFT JOIN information_schema.statistics s ON s.table_schema = c.table_schema AND s.table_name = c.table_name AND s.index_name = 'uk_aihr_direct_feedback_submit' WHERE c.table_schema = DATABASE() AND c.table_name = 'aihr_direct_feedback' AND c.column_name IN ('tenant_id','channel_code','sender_user_id','anonymous_flag','content','status','submit_request_key','submit_request_hash','reply_content','replied_by','replied_time'); SQL REMOTE )" || fail "remote direct-feedback schema check failed: $remote_ssh:$remote_db" [[ "$direct_feedback_contract" = "11|0|tenant_id,sender_user_id,submit_request_key|0" ]] \ || fail "remote direct-feedback contract invalid: expected 11 business columns and unique tenant_id,sender_user_id,submit_request_key; got ${direct_feedback_contract:-missing}" local direct_role_count direct_role_count="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT COUNT(DISTINCT role_key) FROM sys_role WHERE tenant_id = '000000' AND del_flag = '0' AND role_key IN ('direct_president','direct_finance','direct_hr','direct_audit','direct_operations'); SQL REMOTE )" || fail "remote direct-feedback role check failed: $remote_ssh:$remote_db" [[ "$direct_role_count" = "5" ]] \ || fail "remote direct-feedback roles incomplete: expected 5/5; got ${direct_role_count:-0}/5" local broadcast_attachment_contract broadcast_attachment_contract="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT( (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_attachment' AND column_name IN ('tenant_id','message_id','oss_id','file_name','file_size','content_type','status','extracted_text','summary','error_message','uploaded_by')), '|', (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' AND column_name = 'attachment_id'), '|', COALESCE((SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'aihr_broadcast_message' AND index_name = 'idx_aihr_broadcast_message_attachment'), '') ); SQL REMOTE )" || fail "remote broadcast-attachment schema check failed: $remote_ssh:$remote_db" [[ "$broadcast_attachment_contract" = "11|1|tenant_id,attachment_id" ]] \ || fail "remote broadcast-attachment contract invalid: expected 11 attachment columns and indexed message attachment_id; got ${broadcast_attachment_contract:-missing}" local missing_work_assistant_columns missing_work_assistant_columns="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_db" <<'REMOTE' set -euo pipefail db="$1" mysql --batch --skip-column-names --connect-timeout=5 "$db" <<'SQL' SELECT CONCAT(required.table_name, '.', required.column_name) FROM ( SELECT 'aihr_memory_candidate' AS table_name, 'work_date' AS column_name UNION ALL SELECT 'aihr_memory_candidate', 'source_snapshot_json' UNION ALL SELECT 'aihr_assistant_capture', 'work_date' UNION ALL SELECT 'aihr_assistant_capture', 'source_snapshot_json' UNION ALL SELECT 'aihr_assistant_capture', 'business_status' UNION ALL SELECT 'aihr_knowledge_conversation', 'project_code' UNION ALL SELECT 'aihr_knowledge_conversation', 'broadcast_message_id' UNION ALL SELECT 'aihr_daily_work_result', 'content_hash' ) required LEFT JOIN information_schema.columns actual ON actual.table_schema = DATABASE() AND actual.table_name = required.table_name AND actual.column_name = required.column_name WHERE actual.column_name IS NULL ORDER BY required.table_name, required.column_name; SQL REMOTE )" || fail "remote work-assistant schema check failed: $remote_ssh:$remote_db" [[ -z "$missing_work_assistant_columns" ]] \ || fail "remote work-assistant schema missing required columns: $(printf '%s' "$missing_work_assistant_columns" | tr '\n' ', ' | sed 's/, $//')" echo "remote_schema=64/64 $remote_ssh:$remote_db" echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_knowledge_category_columns=7/7 $remote_ssh:$remote_db" echo "remote_broadcast_publish_audit_columns=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_publish_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_read_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_version_uniqueness=3/3 $remote_ssh:$remote_db" echo "remote_broadcast_targeting_contract=2/2 $remote_ssh:$remote_db" echo "remote_broadcast_target_table_contract=16/16 $remote_ssh:$remote_db" echo "remote_broadcast_target_rule_uniqueness=4/4 $remote_ssh:$remote_db" echo "remote_broadcast_target_recipient_uniqueness=3/3 $remote_ssh:$remote_db" echo "remote_direct_feedback_contract=11/11 $remote_ssh:$remote_db" echo "remote_direct_feedback_roles=5/5 $remote_ssh:$remote_db" echo "remote_broadcast_attachment_contract=12/12 $remote_ssh:$remote_db" echo "remote_broadcast_question_context=1/1 $remote_ssh:$remote_db" echo "remote_work_assistant_columns=8/8 $remote_ssh:$remote_db" echo "remote_personal_oss_configuration=true $remote_ssh:$remote_db" } changed_files="$(git status --porcelain)" [[ -z "$changed_files" ]] || fail "worktree has uncommitted changes; commit the release batch before publishing" echo "commit=$(git rev-parse HEAD)" echo "head_epoch=$head_epoch" echo "worktree=clean" echo "frontend_index_sha256=$(sha256 "$frontend_index")" echo "frontend_asset=$frontend_asset" echo "frontend_asset_sha256=$(sha256 "$frontend_asset_path")" echo "mobile_index_sha256=$(sha256 "$mobile_index")" echo "mobile_asset=$mobile_asset" echo "mobile_asset_sha256=$(sha256 "$mobile_asset_path")" echo "backend_jar_sha256=$(sha256 "$backend_jar")" if [[ -n "${RELEASE_REMOTE_URL:-}" ]]; then remote="${RELEASE_REMOTE_URL%/}" curl -fsS --max-time 15 "$remote/" >/dev/null || fail "remote root check failed: $remote/" echo "remote_root=200 $remote/" require_remote_business_success "remote_tenant_list" "$remote/prod-api/auth/tenant/list" require_remote_business_success "remote_mobile_home" "$remote/prod-api/api/aihr/mobile/home/user" if [[ "${RELEASE_VERIFY_REMOTE_SCHEMA:-false}" == "true" ]]; then require_remote_schema fi if [[ "${RELEASE_VERIFY_REMOTE_MATCH:-false}" == "true" ]]; then remote_frontend_asset="$(curl -fsS --max-time 15 "$remote/" | sed -nE 's/.*src="(\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)" remote_mobile_asset="$(curl -fsS --max-time 15 "$remote/h5/" | sed -nE 's/.*src="(\/h5\/assets\/[^" ]+\.js)".*/\1/p' | head -n 1)" [[ "$remote_frontend_asset" == /assets/*.js ]] || fail "remote frontend entry asset not found" [[ "$remote_mobile_asset" == /h5/assets/*.js ]] || fail "remote mobile H5 entry asset not found" remote_frontend_sha256="$(curl -fsS --max-time 15 "$remote$remote_frontend_asset" | sha256_stream)" remote_mobile_sha256="$(curl -fsS --max-time 15 "$remote$remote_mobile_asset" | sha256_stream)" local_frontend_sha256="$(sha256 "$frontend_asset_path")" local_mobile_sha256="$(sha256 "$mobile_asset_path")" echo "remote_frontend_asset=$remote_frontend_asset" echo "remote_frontend_asset_sha256=$remote_frontend_sha256" echo "remote_mobile_asset=$remote_mobile_asset" echo "remote_mobile_asset_sha256=$remote_mobile_sha256" [[ "$remote_frontend_sha256" == "$local_frontend_sha256" ]] || fail "remote frontend asset does not match local build" [[ "$remote_mobile_sha256" == "$local_mobile_sha256" ]] || fail "remote mobile H5 asset does not match local build" echo "remote_asset_match=true" if [[ "${RELEASE_VERIFY_REMOTE_BACKEND:-false}" == "true" ]]; then command -v ssh >/dev/null 2>&1 || fail "ssh is required for remote backend verification" remote_ssh="${RELEASE_REMOTE_SSH:-YCWY}" remote_backend_path="${RELEASE_REMOTE_BACKEND_PATH:-/opt/wygj/app/ruoyi-admin.jar}" [[ "$remote_backend_path" =~ ^/[A-Za-z0-9._/-]+$ ]] || fail "remote backend path must be an absolute safe path: $remote_backend_path" remote_backend_jar_sha256="$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" "sha256sum '$remote_backend_path'" | awk '{print $1}')" || fail "remote backend hash check failed: $remote_ssh:$remote_backend_path" [[ "$remote_backend_jar_sha256" =~ ^[0-9a-f]{64}$ ]] || fail "remote backend hash is invalid: $remote_ssh:$remote_backend_path" local_backend_jar_sha256="$(sha256 "$backend_jar")" local_backend_module_jar="$(mktemp)" unzip -p "$backend_jar" "BOOT-INF/lib/$backend_module_jar_name" > "$local_backend_module_jar" local_backend_module_sha256="$(normalized_jar_content_sha256 "$local_backend_module_jar")" rm -f "$local_backend_module_jar" remote_backend_module_sha_file="$(mktemp)" ssh -o BatchMode=yes -o ConnectTimeout=10 "$remote_ssh" bash -s -- "$remote_backend_path" "$backend_module_jar_name" > "$remote_backend_module_sha_file" <<'REMOTE' set -euo pipefail outer="$1" module="$2" nested="$(mktemp)" manifest="$(mktemp)" cleanup() { rm -f "$nested" "$manifest" } trap cleanup EXIT unzip -p "$outer" "BOOT-INF/lib/$module" > "$nested" while IFS= read -r entry; do case "$entry" in */|META-INF/*.SF|META-INF/*.RSA|META-INF/*.DSA) continue ;; esac printf '%s %s\n' "$(unzip -p "$nested" "$entry" | sha256sum | awk '{print $1}')" "$entry" >> "$manifest" done < <(unzip -Z1 "$nested" | LC_ALL=C sort) sha256sum "$manifest" | awk '{print $1}' REMOTE remote_backend_module_sha256="$(sed -n '1p' "$remote_backend_module_sha_file")" rm -f "$remote_backend_module_sha_file" [[ -n "$remote_backend_module_sha256" ]] || fail "remote backend module hash check returned no value: $remote_ssh:$remote_backend_path" [[ "$remote_backend_module_sha256" =~ ^[0-9a-f]{64}$ ]] || fail "remote backend module hash is invalid: $remote_ssh:$remote_backend_path" echo "remote_backend_jar_sha256=$remote_backend_jar_sha256" echo "local_backend_jar_sha256=$local_backend_jar_sha256" echo "backend_module=$backend_module_jar_name" echo "remote_backend_module_sha256=$remote_backend_module_sha256" echo "local_backend_module_sha256=$local_backend_module_sha256" [[ "$remote_backend_module_sha256" == "$local_backend_module_sha256" ]] || fail "remote AIHR module does not match local build" echo "remote_backend_module_match=true" fi fi fi echo "release-preflight: read-only checks passed"