#!/usr/bin/env bash # Run on the target host after baseline backup, image imports and TLS bootstrap. set -euo pipefail release=${1:?usage: publish.sh RELEASE} [[ "$release" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$ ]] || exit 2 root=/opt/cadworld-sim www=/opt/1panel/apps/openresty/openresty/www site=$www/sites/cadworld-sim.robotquan.com nginx=1Panel-openresty-m72w stage=$root/releases/$release [[ -f "$stage/manifest.json" ]] || { echo 'missing verified release'; exit 1; } python3 - "$stage" <<'PY' import hashlib,json,pathlib,sys root=pathlib.Path(sys.argv[1]) manifest=json.loads((root/'manifest.json').read_text()) for name,digest in manifest['files'].items(): path=root/name if '..' in pathlib.Path(name).parts or pathlib.Path(name).is_absolute() or path.is_symlink(): raise RuntimeError('invalid manifest path') if hashlib.sha256(path.read_bytes()).hexdigest()!=digest: raise RuntimeError('artifact checksum mismatch: '+name) print('Release manifest verified') PY old='' [[ ! -f $root/current.txt ]] || read -r old < "$root/current.txt" docker build --network none --pull=false -t "cadworld-decision:$release" "$stage/app" # Fail before replacing the live service if a runtime contract/module was omitted. # No server keys, network, volumes or inference are available to this check. docker run --rm --network none --read-only --cap-drop ALL \ --security-opt no-new-privileges --entrypoint python "cadworld-decision:$release" \ -c 'import decision_server.web_server; from decision_server.language_tasks import resolve_target; assert resolve_target("B", [])[1] == "table"; print("Runtime imports and v2 contracts verified")' # No current traffic is changed before the build and static manifest succeed. mkdir -p "$site/releases/$release" cp -a "$stage/static/." "$site/releases/$release/" chmod -R a+rX "$site/releases/$release" restore() { trap - ERR if [[ -n "$old" ]]; then cp "$root/releases/$old/deploy/compose.yaml" "$root/compose.yaml" cp "$root/releases/$old/deploy/openresty.conf" /opt/1panel/apps/openresty/openresty/conf/conf.d/cadworld-sim.robotquan.com.conf printf 'CADWORLD_RELEASE=%s\n' "$old" > "$root/release.env" docker compose --env-file "$root/release.env" -f "$root/compose.yaml" up -d --wait decision || true ln -sfn "releases/$old" "$site/current.next" mv -Tf "$site/current.next" "$site/current" docker exec "$nginx" nginx -t && docker exec "$nginx" nginx -s reload || true else docker compose --env-file "$root/release.env" -f "$root/compose.yaml" stop decision || true conf=/opt/1panel/apps/openresty/openresty/conf/conf.d/cadworld-sim.robotquan.com.conf [[ ! -f "$conf" ]] || mv "$conf" "$root/failed-first-vhost.conf" docker exec "$nginx" nginx -t && docker exec "$nginx" nginx -s reload || true fi echo 'Publish failed; previous deployment retained/restored. Inspect logs before retry.' >&2 exit 1 } trap restore ERR cp "$stage/deploy/compose.yaml" "$root/compose.yaml" printf 'CADWORLD_RELEASE=%s\n' "$release" > "$root/release.env" docker compose --env-file "$root/release.env" -f "$root/compose.yaml" up -d --wait --wait-timeout 75 decision curl --fail --silent --show-error --max-time 5 -H 'Host: cadworld-sim.robotquan.com' http://127.0.0.1:8768/healthz ln -sfn "releases/$release" "$site/current.next" mv -Tf "$site/current.next" "$site/current" cp "$stage/deploy/openresty.conf" /opt/1panel/apps/openresty/openresty/conf/conf.d/cadworld-sim.robotquan.com.conf docker exec "$nginx" nginx -t docker exec "$nginx" nginx -s reload curl --fail --silent --show-error --max-time 15 https://cadworld-sim.robotquan.com/ -o /dev/null printf '%s\n' "$old" > "$root/previous.txt" printf '%s\n' "$release" > "$root/current.txt" docker image inspect "cadworld-decision:$release" --format '{{.Id}}' > "$stage/image-id.txt" trap - ERR echo "Published $release; previous=$old"