diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 32fc59a55..7fcae4a9f 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -175,7 +175,7 @@ jobs: - name: Update MoonBit package registry run: moon update - # The publish script mirrors release artifacts to OSS with ossutil 2.x, + # The publish script uploads release artifacts to OSS with ossutil 2.x, # pinned and installed from the official distribution. - name: Install ossutil shell: bash @@ -246,8 +246,9 @@ jobs: --sign "$MACOS_SIGNING_IDENTITY" \ --notarize "$NOTARY_PROFILE" - # Keep one compressed browser archive beside the desktop artifacts. The - # API extracts this exact archive when the release is published. + # Keep one compressed Browser archive beside the Desktop artifacts. OSS + # stores it as an immutable release artifact; the API receives the same + # small archive only because `/console/` must remain on the API origin. - name: Build browser release artifact shell: bash run: | @@ -260,6 +261,23 @@ jobs: -czf desktop/dist/SeekMoon.browser.tar.gz \ browser + # Upload artifacts before publication. Until API publish succeeds, a + # retry may replace nondeterministic rebuilds; a published Browser + # version restores the exact OSS bytes instead. + - name: Upload release to OSS + shell: bash + env: + OPENSEEK_DEPLOY_TOKEN: ${{ secrets.OPENSEEK_DEPLOY_TOKEN }} + OPENSEEK_OSS_BUCKET: ${{ secrets.OPENSEEK_OSS_BUCKET }} + OPENSEEK_OSS_REGION: ${{ secrets.OPENSEEK_OSS_REGION }} + OSS_ACCESS_KEY_ID: ${{ secrets.OPENSEEK_OSS_ACCESS_KEY_ID }} + OSS_ACCESS_KEY_SECRET: ${{ secrets.OPENSEEK_OSS_ACCESS_KEY_SECRET }} + run: | + desktop/scripts/publish-release.sh upload "v$RELEASE_VERSION" + + # A retry of an API-published version replaces this run's nondeterministic + # rebuilds with the exact OSS objects before reaching here. Keep those + # canonical bytes as the downloadable Actions artifact too. - name: Upload release artifacts uses: actions/upload-artifact@v4 with: @@ -271,8 +289,9 @@ jobs: if-no-files-found: error retention-days: 14 - # Every artifact is uploaded once. The same publish request regenerates - # latest.json and extracts/selects the browser archive on the server. + # Publish sends filenames and digests to the API. The API selects the + # staged Browser version and atomically replaces its own latest.json; it + # does not receive the ZIP or DMG. - name: Publish release shell: bash env: @@ -282,15 +301,6 @@ jobs: OSS_ACCESS_KEY_ID: ${{ secrets.OPENSEEK_OSS_ACCESS_KEY_ID }} OSS_ACCESS_KEY_SECRET: ${{ secrets.OPENSEEK_OSS_ACCESS_KEY_SECRET }} run: | - desktop/scripts/publish-release.sh upload \ - desktop/dist/SeekMoon.app.zip \ - macos-arm64 - desktop/scripts/publish-release.sh upload \ - desktop/dist/SeekMoon.dmg \ - macos-arm64-dmg - desktop/scripts/publish-release.sh upload \ - desktop/dist/SeekMoon.browser.tar.gz \ - browser desktop/scripts/publish-release.sh publish "v$RELEASE_VERSION" - name: Verify release manifest @@ -301,45 +311,39 @@ jobs: jq -e --arg version "$RELEASE_VERSION" '.version == $version' \ <<< "$manifest" - archive_sha256="$(shasum -a 256 desktop/dist/SeekMoon.app.zip | cut -d' ' -f1)" - dmg_sha256="$(shasum -a 256 desktop/dist/SeekMoon.dmg | cut -d' ' -f1)" - browser_sha256="$(shasum -a 256 desktop/dist/SeekMoon.browser.tar.gz | cut -d' ' -f1)" - - # The manifest is the client contract: whatever URL it names for a - # platform is what the updater and the download page fetch. URLs - # are read back rather than reconstructed, so the same check holds - # whether the server's releases base URL points at this API or at - # the OSS-backed CDN — each entry's digest must match the artifact - # built here, and so must the bytes its URL actually serves. - platform_url() { - jq -er --arg key "$1" '.platforms[$key].url' <<< "$manifest" - } - verify_platform() { - local key="$1" sha256="$2" - local url manifest_sha256 served served_sha256 - url="$(platform_url "$key")" + platforms=("macos-arm64" "macos-arm64-dmg" "browser") + artifacts=( + "desktop/dist/SeekMoon.app.zip" + "desktop/dist/SeekMoon.dmg" + "desktop/dist/SeekMoon.browser.tar.gz" + ) + for index in 0 1 2; do + key="${platforms[$index]}" + artifact="${artifacts[$index]}" + url="$(jq -er --arg key "$key" '.platforms[$key].url' <<< "$manifest")" manifest_sha256="$(jq -er --arg key "$key" '.platforms[$key].sha256' <<< "$manifest")" - if [[ "$manifest_sha256" != "$sha256" ]]; then - echo "::error::Manifest sha256 for $key does not match the built artifact" - exit 1 - fi - served="$RUNNER_TEMP/openseek-release-$key" - curl -fsSL --retry 3 -o "$served" "$url" - served_sha256="$(shasum -a 256 "$served" | cut -d' ' -f1)" - rm -f "$served" - if [[ "$served_sha256" != "$sha256" ]]; then - echo "::error::Bytes served for $key at $url do not match the built artifact" + local_sha256="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" + local_size="$(wc -c < "$artifact" | tr -d '[:space:]')" + headers="$(curl -fsSI --retry 3 "$url" | tr -d '\r')" + served_size="$(awk 'tolower($1) == "content-length:" { print $2; exit }' <<< "$headers")" + served_sha256="$(awk 'tolower($1) == "x-oss-meta-sha256:" { print $2; exit }' <<< "$headers")" + served_crc64="$(awk 'tolower($1) == "x-oss-hash-crc64ecma:" { print $2; exit }' <<< "$headers")" + if [[ "$manifest_sha256" != "$local_sha256" || \ + "$served_sha256" != "$local_sha256" || \ + "$served_size" != "$local_size" || \ + -z "$served_crc64" ]]; then + echo "::error::OSS metadata for $key does not match the built artifact" exit 1 fi echo "$key ok: $url" - } + done - # The updater installs from the bare macOS platform key. The DMG and - # browser bundle are separate release artifacts for manual install - # and later browser deployment respectively. - verify_platform macos-arm64 "$archive_sha256" - verify_platform macos-arm64-dmg "$dmg_sha256" - verify_platform browser "$browser_sha256" + archive_sha256="$(shasum -a 256 desktop/dist/SeekMoon.app.zip | cut -d' ' -f1)" + dmg_sha256="$(shasum -a 256 desktop/dist/SeekMoon.dmg | cut -d' ' -f1)" + browser_sha256="$(shasum -a 256 desktop/dist/SeekMoon.browser.tar.gz | cut -d' ' -f1)" + archive_url="$(jq -er '.platforms["macos-arm64"].url' <<< "$manifest")" + dmg_url="$(jq -er '.platforms["macos-arm64-dmg"].url' <<< "$manifest")" + browser_url="$(jq -er '.platforms.browser.url' <<< "$manifest")" browser_manifest="$(curl -fsSL "$OPENSEEK_API_ORIGIN/browser/releases/current.json")" browser_base_url="$OPENSEEK_API_ORIGIN/console/releases/v$RELEASE_VERSION" @@ -373,11 +377,11 @@ jobs: echo "## Desktop $RELEASE_CHANNEL release" echo echo "- Version: \`$RELEASE_VERSION\`" - echo "- Updater ZIP: $(platform_url macos-arm64)" + echo "- Updater ZIP: $archive_url" echo "- ZIP SHA-256: \`$archive_sha256\`" - echo "- Installer DMG: $(platform_url macos-arm64-dmg)" + echo "- Installer DMG: $dmg_url" echo "- DMG SHA-256: \`$dmg_sha256\`" - echo "- Browser bundle: $(platform_url browser)" + echo "- Browser bundle: $browser_url" echo "- Browser SHA-256: \`$browser_sha256\`" echo "- Browser console: $browser_base_url/" } >> "$GITHUB_STEP_SUMMARY" diff --git a/desktop/frontend/browser/main.mbt b/desktop/frontend/browser/main.mbt index 105b53aa9..55dc3651e 100644 --- a/desktop/frontend/browser/main.mbt +++ b/desktop/frontend/browser/main.mbt @@ -1,4 +1,4 @@ -// The browser shell: the console bundle openseek-api serves at `/` +// The browser shell: the console bundle openseek-api serves under `/console/` // (its Dockerfile builds this package and ships `browser.js` with the // sibling `index.html`). It shares everything with the desktop shell // today; console-only features will migrate here as the core splits. diff --git a/desktop/package/browser/main.mbt b/desktop/package/browser/main.mbt index 02413a6b1..931ec99cf 100644 --- a/desktop/package/browser/main.mbt +++ b/desktop/package/browser/main.mbt @@ -1,5 +1,6 @@ ///| -/// The complete browser application directory consumed by openseek-api. +/// The complete Browser application directory. Release CI archives this for +/// openseek-api's same-origin `/console/` deployment. const BrowserDistDir : String = "dist/browser" ///| diff --git a/desktop/scripts/publish-release.sh b/desktop/scripts/publish-release.sh index 9eab173ec..c34461ab6 100755 --- a/desktop/scripts/publish-release.sh +++ b/desktop/scripts/publish-release.sh @@ -1,78 +1,44 @@ #!/usr/bin/env bash -# Release flow against openseek-api: each platform's build machine uploads -# its own artifact, then one explicit publish regenerates latest.json -# server-side and switches clients over. Rolling back is publishing an -# older version again. +# Upload Desktop release artifacts to OSS, then ask openseek-api to generate +# and atomically replace its release manifest from the uploaded filenames and +# SHA-256 digests. OSS stores artifacts only; it does not own latest.json. # -# scripts/publish-release.sh upload [file] [platform] upload this platform's artifact -# scripts/publish-release.sh publish [vX.Y.Z] make a version the live release -# scripts/publish-release.sh status list uploaded versions + current -# Current Proton and browser filenames have defaults; legacy -# SeekMoon- names are inferred. +# scripts/publish-release.sh upload [vX.Y.Z] upload the checkout's artifacts +# scripts/publish-release.sh publish [vX.Y.Z] make the uploaded version live +# scripts/publish-release.sh rollback vX.Y.Z republish an existing version +# scripts/publish-release.sh status list API-owned release state # -# Requires OPENSEEK_DEPLOY_TOKEN (one of the server's OPENSEEK_DEPLOY_TOKENS). -# Uploads also mirror the artifact to OSS behind the download CDN: requires -# OPENSEEK_OSS_BUCKET and OPENSEEK_OSS_REGION, plus ossutil 2.x credentials -# (OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET or ~/.ossutilconfig). -# OPENSEEK_API_ORIGIN must name the target deployment explicitly, e.g. -# OPENSEEK_API_ORIGIN=https://openseek-api-staging.moonbitlang.cn +# Requires OPENSEEK_OSS_BUCKET, OPENSEEK_OSS_REGION, OPENSEEK_API_ORIGIN, +# and OPENSEEK_DEPLOY_TOKEN, plus ossutil 2.x credentials. The standard API +# origins select their OSS prefixes automatically; nonstandard deployments can +# set OPENSEEK_OSS_PREFIX explicitly. set -euo pipefail -usage() { - sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' -} - desktop_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# No production default: a stray local run must name its target on purpose. -origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN to the target deployment}" -token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" - version="$(sed -n 's/^version = "\(.*\)"$/\1/p' "$desktop_dir/moon.mod")" if [[ -z "$version" ]]; then echo "could not read version from moon.mod" >&2 exit 1 fi -# The default remains the ZIP consumed by the in-app updater. An explicit -# artifact, such as the DMG or browser bundle, uploads under its own basename -# so every file can coexist under the same version. -default_artifact="$desktop_dir/dist/SeekMoon.app.zip" - case "${1:-}" in upload) - artifact="${2:-$default_artifact}" - if [[ ! -f "$artifact" ]]; then - echo "artifact not found: $artifact" >&2 - echo "build it first: moon run ./package/macos -- --release --target dmg --target zip --sign '...'" >&2 - exit 1 + upload_version="${2:-v$version}" + if [[ "$upload_version" != "v$version" ]]; then + echo "upload version $upload_version does not match checkout version v$version" >&2 + echo "use rollback for an already-published version" >&2 + exit 64 fi - release_name="$(basename "$artifact")" - platform="${3:-}" - if [[ -z "$platform" ]]; then - case "$release_name" in - SeekMoon.app.zip) platform="macos-arm64" ;; - SeekMoon.dmg) platform="macos-arm64-dmg" ;; - SeekMoon.browser.tar.gz) platform="browser" ;; - esac - fi - url="$origin/desktop/releases/v$version/$release_name" - if [[ -n "$platform" ]]; then - if [[ ! "$platform" =~ ^[[:alnum:]][[:alnum:]_.-]*$ ]]; then - echo "invalid release platform: $platform" >&2 - exit 64 - fi - # The API needs an explicit manifest key for Proton artifact names such - # as SeekMoon.app.zip, which do not encode the target platform. - url="$url?platform=$platform" - fi - # Resolve the OSS mirror destination up front, so a missing or wrong - # configuration fails before the slow API upload rather than after it. oss_bucket="${OPENSEEK_OSS_BUCKET:?set OPENSEEK_OSS_BUCKET}" oss_region="${OPENSEEK_OSS_REGION:?set OPENSEEK_OSS_REGION}" + api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" + deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" + api_origin="${api_origin%/}" if [[ -n "${OPENSEEK_OSS_PREFIX:-}" ]]; then - oss_prefix="$OPENSEEK_OSS_PREFIX" + oss_prefix="${OPENSEEK_OSS_PREFIX#/}" + oss_prefix="${oss_prefix%/}" else - case "$origin" in + case "$api_origin" in https://openseek-api.moonbitlang.cn) oss_prefix="openseek/desktop/releases" ;; @@ -80,60 +46,293 @@ case "${1:-}" in oss_prefix="openseek/staging/desktop/releases" ;; *) - echo "no OSS prefix mapped for $origin; set OPENSEEK_OSS_PREFIX" >&2 + echo "no OSS prefix mapped for $api_origin; set OPENSEEK_OSS_PREFIX" >&2 exit 64 ;; esac fi - # Content type and caching mirror what the API serves for these files. - content_type="application/octet-stream" - if [[ "$release_name" == *.dmg ]]; then - content_type="application/x-apple-diskimage" + + artifacts=( + "$desktop_dir/dist/SeekMoon.app.zip" + "$desktop_dir/dist/SeekMoon.dmg" + "$desktop_dir/dist/SeekMoon.browser.tar.gz" + ) + platforms=("macos-arm64" "macos-arm64-dmg" "browser") + content_types=("application/zip" "application/x-apple-diskimage" "application/gzip") + artifact_shas=() + + # A published Browser directory is the API-owned per-version seal. If a + # previous attempt reached API publish but the job failed afterwards, its + # OSS bytes must be reused instead of overwritten by a fresh rebuild. + browser_status="$(curl -sS -o /dev/null -w '%{http_code}' \ + "$api_origin/console/releases/$upload_version/index.html")" + if [[ "$browser_status" == 200 ]]; then + echo "$upload_version is already published by the API; reusing its OSS artifacts" + for index in 0 1 2; do + artifact="${artifacts[$index]}" + platform="${platforms[$index]}" + release_name="$(basename "$artifact")" + oss_key="$oss_prefix/$upload_version/$release_name" + oss_destination="oss://$oss_bucket/$oss_key" + head_json="$( + ossutil api head-object \ + --region "$oss_region" \ + --bucket "$oss_bucket" \ + --key "$oss_key" \ + --output-format json + )" + served_size="$(jq -er '.Header["Content-Length"][0]' <<< "$head_json")" + served_sha="$(jq -er '.Header["X-Oss-Meta-Sha256"][0]' <<< "$head_json")" + served_crc64="$(jq -er '.Header["X-Oss-Hash-Crc64ecma"][0]' <<< "$head_json")" + ossutil cp --force --region "$oss_region" "$oss_destination" "$artifact" + local_size="$(wc -c < "$artifact" | tr -d '[:space:]')" + local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" + if [[ "$served_size" != "$local_size" || \ + "$served_sha" != "$local_sha" || \ + -z "$served_crc64" ]]; then + echo "published OSS artifact does not match its metadata: $platform" >&2 + exit 1 + fi + artifact_shas+=("$local_sha") + echo "$platform restored: $oss_destination" + done + + # The Actions artifact and Browser-content verification must use the + # same archive bytes that the API already published. + rm -rf "$desktop_dir/dist/browser" + COPYFILE_DISABLE=1 tar -xzf "${artifacts[2]}" -C "$desktop_dir/dist" + echo "$upload_version is ready to publish again" + exit 0 fi - oss_destination="oss://$oss_bucket/$oss_prefix/v$version/$release_name" - echo "uploading $artifact" - echo " to $url" - curl_status=0 - response="$(curl -sS --fail-with-body -T "$artifact" \ - -H "Authorization: Bearer $token" "$url")" || curl_status=$? - if ((curl_status != 0)); then - if [[ -n "$response" ]]; then - echo "$response" >&2 - fi - exit "$curl_status" + if [[ "$browser_status" != 404 ]]; then + echo "could not determine whether $upload_version is published: HTTP $browser_status" >&2 + exit 1 fi - echo "$response" - local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" - if [[ "$response" != *"\"sha256\":\"$local_sha\""* ]]; then - echo "DIGEST MISMATCH: local sha256 is $local_sha — do not publish" >&2 + + echo "$upload_version is unpublished; uploading replaceable OSS artifacts" + for index in 0 1 2; do + artifact="${artifacts[$index]}" + platform="${platforms[$index]}" + content_type="${content_types[$index]}" + if [[ ! -f "$artifact" ]]; then + echo "artifact not found: $artifact" >&2 + exit 1 + fi + release_name="$(basename "$artifact")" + oss_key="$oss_prefix/$upload_version/$release_name" + oss_destination="oss://$oss_bucket/$oss_key" + local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" + local_size="$(wc -c < "$artifact" | tr -d '[:space:]')" + + # Rebuilds are not byte-identical. Until the API publishes this version, + # a retry must replace provisional bytes instead of keeping an older run. + echo "uploading $artifact" + echo " to $oss_destination" + ossutil cp --force --region "$oss_region" \ + --content-type "$content_type" \ + --cache-control "public, max-age=31536000, immutable" \ + --metadata "sha256=$local_sha" \ + "$artifact" "$oss_destination" + + # Verify against OSS directly. Accessing the CDN before publication can + # cache provisional bytes under the immutable version URL. + head_json="$( + ossutil api head-object \ + --region "$oss_region" \ + --bucket "$oss_bucket" \ + --key "$oss_key" \ + --output-format json + )" + served_size="$(jq -er '.Header["Content-Length"][0]' <<< "$head_json")" + served_sha="$(jq -er '.Header["X-Oss-Meta-Sha256"][0]' <<< "$head_json")" + served_crc64="$(jq -er '.Header["X-Oss-Hash-Crc64ecma"][0]' <<< "$head_json")" + if [[ "$served_size" != "$local_size" || \ + "$served_sha" != "$local_sha" || \ + -z "$served_crc64" ]]; then + echo "OSS verification failed for $platform" >&2 + exit 1 + fi + artifact_shas+=("$local_sha") + echo "$platform verified in OSS" + done + + # `/console/` remains on the API origin, so only the much smaller Browser + # archive is uploaded twice. ZIP and DMG exist only in OSS. + browser_archive="${artifacts[2]}" + browser_upload_url="$api_origin/desktop/releases/$upload_version/SeekMoon.browser.tar.gz?platform=browser" + upload_response="$(curl -sS --fail-with-body -T "$browser_archive" \ + -H "Authorization: Bearer $deploy_token" "$browser_upload_url")" + response_sha="$(jq -er '.sha256' <<< "$upload_response")" + if [[ "$response_sha" != "${artifact_shas[2]}" ]]; then + echo "DIGEST MISMATCH: API recorded $response_sha for Browser" >&2 exit 1 fi - # Clients download from OSS behind the CDN once the server's - # OPENSEEK_RELEASES_BASE_URL points there, so every artifact the API - # accepts is mirrored to the same version path on OSS. The API upload - # above is the immutability gate — it rejects re-uploads of a published - # version — so the mirror can only ever rewrite an unpublished file; - # --force keeps that rewrite from asking for confirmation in CI. - echo "mirroring to $oss_destination" - ossutil cp --force --region "$oss_region" \ - --content-type "$content_type" \ - --cache-control "public, max-age=31536000, immutable" \ - "$artifact" "$oss_destination" - echo "digest verified and mirrored — go live with: ${BASH_SOURCE[0]} publish" + echo "$upload_version is ready to publish" + exit 0 ;; + publish) - curl -sS --fail-with-body -X POST \ - -H "Authorization: Bearer $token" \ - "$origin/desktop/releases/${2:-v$version}/publish" - echo + publish_version="${2:-v$version}" + if [[ "$publish_version" != "v$version" ]]; then + echo "publish version $publish_version does not match checkout version v$version" >&2 + echo "use rollback for an already-published version" >&2 + exit 64 + fi + publish_from_checkout=true + ;; + + rollback) + publish_version="${2:?usage: publish-release.sh rollback vX.Y.Z}" + if [[ ! "$publish_version" =~ ^v[[:alnum:]][[:alnum:]_.-]*$ ]]; then + echo "invalid rollback version: $publish_version" >&2 + exit 64 + fi + publish_from_checkout=false ;; + status) + api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" + deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" curl -sS --fail-with-body \ - -H "Authorization: Bearer $token" "$origin/desktop/releases" + -H "Authorization: Bearer $deploy_token" \ + "${api_origin%/}/desktop/releases" echo + exit 0 ;; + *) - usage >&2 + sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 exit 64 ;; esac + +# Publish and rollback share the same API contract. The runner knows OSS and +# supplies trusted filenames and SHA-256 values; the API knows only the public +# release base URL and owns latest.json generation. +oss_bucket="${OPENSEEK_OSS_BUCKET:?set OPENSEEK_OSS_BUCKET}" +oss_region="${OPENSEEK_OSS_REGION:?set OPENSEEK_OSS_REGION}" +api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" +deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" +api_origin="${api_origin%/}" +if [[ -n "${OPENSEEK_OSS_PREFIX:-}" ]]; then + oss_prefix="${OPENSEEK_OSS_PREFIX#/}" + oss_prefix="${oss_prefix%/}" +else + case "$api_origin" in + https://openseek-api.moonbitlang.cn) + oss_prefix="openseek/desktop/releases" + ;; + https://openseek-api-staging.moonbitlang.cn) + oss_prefix="openseek/staging/desktop/releases" + ;; + *) + echo "no OSS prefix mapped for $api_origin; set OPENSEEK_OSS_PREFIX" >&2 + exit 64 + ;; + esac +fi + +artifacts=( + "$desktop_dir/dist/SeekMoon.app.zip" + "$desktop_dir/dist/SeekMoon.dmg" + "$desktop_dir/dist/SeekMoon.browser.tar.gz" +) +files=("SeekMoon.app.zip" "SeekMoon.dmg" "SeekMoon.browser.tar.gz") +platforms=("macos-arm64" "macos-arm64-dmg" "browser") +artifact_shas=() + +for index in 0 1 2; do + artifact="${artifacts[$index]}" + platform="${platforms[$index]}" + release_name="${files[$index]}" + oss_key="$oss_prefix/$publish_version/$release_name" + head_json="$( + ossutil api head-object \ + --region "$oss_region" \ + --bucket "$oss_bucket" \ + --key "$oss_key" \ + --output-format json + )" + served_size="$(jq -er '.Header["Content-Length"][0]' <<< "$head_json")" + served_sha="$(jq -er '.Header["X-Oss-Meta-Sha256"][0]' <<< "$head_json")" + served_crc64="$(jq -er '.Header["X-Oss-Hash-Crc64ecma"][0]' <<< "$head_json")" + if [[ -z "$served_crc64" || ! "$served_sha" =~ ^[0-9a-f]{64}$ ]]; then + echo "OSS metadata is incomplete for $platform" >&2 + exit 1 + fi + if [[ "$publish_from_checkout" == true ]]; then + if [[ ! -f "$artifact" ]]; then + echo "artifact not found: $artifact" >&2 + exit 1 + fi + local_size="$(wc -c < "$artifact" | tr -d '[:space:]')" + local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" + if [[ "$served_size" != "$local_size" || "$served_sha" != "$local_sha" ]]; then + echo "OSS object does not match the built artifact: $platform" >&2 + exit 1 + fi + fi + artifact_shas+=("$served_sha") +done + +publish_payload="$(jq -cn \ + --arg archive_file "${files[0]}" \ + --arg archive_sha "${artifact_shas[0]}" \ + --arg dmg_file "${files[1]}" \ + --arg dmg_sha "${artifact_shas[1]}" \ + --arg browser_file "${files[2]}" \ + --arg browser_sha "${artifact_shas[2]}" \ + '{ + platforms: { + "macos-arm64": {file: $archive_file, sha256: $archive_sha}, + "macos-arm64-dmg": {file: $dmg_file, sha256: $dmg_sha}, + browser: {file: $browser_file, sha256: $browser_sha} + } + }')" + +publish_status=0 +publish_response="$(curl -sS --fail-with-body -X POST \ + -H "Authorization: Bearer $deploy_token" \ + -H "Content-Type: application/json" \ + --data-binary "$publish_payload" \ + "$api_origin/desktop/releases/$publish_version/publish")" || publish_status=$? +if ((publish_status != 0)); then + if [[ -n "$publish_response" ]]; then + printf '%s\n' "$publish_response" >&2 + fi + exit "$publish_status" +fi + +expected_version="${publish_version#v}" +jq -e \ + --arg version "$expected_version" \ + --arg archive_file "/$publish_version/${files[0]}" \ + --arg archive_sha "${artifact_shas[0]}" \ + --arg dmg_file "/$publish_version/${files[1]}" \ + --arg dmg_sha "${artifact_shas[1]}" \ + --arg browser_file "/$publish_version/${files[2]}" \ + --arg browser_sha "${artifact_shas[2]}" ' + .published.version == $version and + (.published.platforms | keys) == ["browser", "macos-arm64", "macos-arm64-dmg"] and + .published.platforms["macos-arm64"].sha256 == $archive_sha and + (.published.platforms["macos-arm64"].url | endswith($archive_file)) and + .published.platforms["macos-arm64-dmg"].sha256 == $dmg_sha and + (.published.platforms["macos-arm64-dmg"].url | endswith($dmg_file)) and + .published.platforms.browser.sha256 == $browser_sha and + (.published.platforms.browser.url | endswith($browser_file)) + ' <<< "$publish_response" >/dev/null + +# Read the canonical file back from the API. This verifies that publish wrote +# the same manifest it returned; no manifest is copied to OSS. +latest="$(curl -fsSL --retry 3 "$api_origin/desktop/releases/latest.json")" +jq -e \ + --arg version "$expected_version" \ + --arg archive_sha "${artifact_shas[0]}" \ + --arg dmg_sha "${artifact_shas[1]}" \ + --arg browser_sha "${artifact_shas[2]}" ' + .version == $version and + .platforms["macos-arm64"].sha256 == $archive_sha and + .platforms["macos-arm64-dmg"].sha256 == $dmg_sha and + .platforms.browser.sha256 == $browser_sha + ' <<< "$latest" >/dev/null +echo "$latest"