From acc6a1999fdfb362c8ede6c84f9330500a2f5b8d Mon Sep 17 00:00:00 2001 From: Haoxiang Fei Date: Fri, 21 Aug 2026 23:48:16 +0800 Subject: [PATCH 1/4] ci(release): publish desktop releases directly to OSS --- .github/workflows/desktop-release.yml | 102 ++++----- desktop/README.md | 4 +- desktop/frontend/browser/main.mbt | 2 +- desktop/internal/update/update.mbt | 6 +- desktop/package/browser/main.mbt | 3 +- desktop/scripts/publish-release.sh | 308 +++++++++++++++++--------- 6 files changed, 256 insertions(+), 169 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 32fc59a55..3b1322539 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -81,6 +81,8 @@ jobs: MACOS_SIGNING_IDENTITY: "Developer ID Application: Haoxiang Fei (NYZ3LQ5QWC)" NOTARY_PROFILE: openseek-${{ github.event_name == 'schedule' && 'stable' || inputs.channel }} OPENSEEK_API_ORIGIN: ${{ (github.event_name != 'schedule' && inputs.channel == 'staging') && 'https://openseek-api-staging.moonbitlang.cn' || 'https://openseek-api.moonbitlang.cn' }} + OPENSEEK_RELEASES_ORIGIN: ${{ (github.event_name != 'schedule' && inputs.channel == 'staging') && 'https://cli.moonbitlang.cn/openseek/staging/desktop/releases' || 'https://cli.moonbitlang.cn/openseek/desktop/releases' }} + OPENSEEK_OSS_PREFIX: ${{ (github.event_name != 'schedule' && inputs.channel == 'staging') && 'openseek/staging/desktop/releases' || 'openseek/desktop/releases' }} RELEASE_CHANNEL: ${{ github.event_name == 'schedule' && 'stable' || inputs.channel }} steps: @@ -113,10 +115,13 @@ jobs: run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "schedule" ]]; then - current_version="$( - curl -fsSL "$OPENSEEK_API_ORIGIN/desktop/releases/latest.json" | - jq -er '.version | strings' + # The first run after migration can fall back to the API-owned + # pointer. Every later run reads the canonical OSS object. + current_manifest="$( + curl -fsSL "$OPENSEEK_RELEASES_ORIGIN/latest.json" 2>/dev/null || + curl -fsSL "$OPENSEEK_API_ORIGIN/desktop/releases/latest.json" )" + current_version="$(jq -er '.version | strings' <<< "$current_manifest")" if [[ ! "$current_version" =~ ^([0-9]{1,4})\.([0-9]{1,2})\.([0-9]{1,2})$ ]]; then echo "::error::Published $RELEASE_CHANNEL version '$current_version' is not an Apple-compatible three-part version" exit 1 @@ -175,7 +180,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 +251,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: | @@ -271,8 +277,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. + # Versioned artifacts are uploaded exactly once to OSS. Browser also + # enters its same-origin deployment path; only after that succeeds does + # the runner atomically replace OSS latest.json. - name: Publish release shell: bash env: @@ -282,64 +289,49 @@ 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 shell: bash run: | set -euo pipefail - manifest="$(curl -fsSL "$OPENSEEK_API_ORIGIN/desktop/releases/latest.json")" + manifest="$(curl -fsSL "$OPENSEEK_RELEASES_ORIGIN/latest.json")" 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 +365,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/README.md b/desktop/README.md index 7a83fc2c9..68ea4b8d5 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -126,8 +126,8 @@ conversation's engine process on the next prompt. ## Updates -After the webview connects, the host fetches the hosted release manifest -(`/desktop/releases/latest.json` on the SeekMoon relay origin, see +After the webview connects, the host fetches the OSS-backed release manifest +(`/openseek/desktop/releases/latest.json` on the release CDN, see `internal/version` for the version it compares against) in the background. On macOS, when the manifest lists a `macos-arm64` package and the running bundle is Developer ID signed, the host downloads the zip, checks its 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/internal/update/update.mbt b/desktop/internal/update/update.mbt index 4c6ea89bb..1c23d5554 100644 --- a/desktop/internal/update/update.mbt +++ b/desktop/internal/update/update.mbt @@ -5,14 +5,14 @@ // caller reports it rather than reading a broken release as "no update". ///| -/// The one manifest the app ever checks, served by the SeekMoon relay -/// origin from its releases directory. There is no other stream and no +/// The one manifest the app ever checks, served from the OSS-backed release +/// CDN. There is no other stream and no /// override: the staging channel went with the Settings server selector /// (nothing could reach it anymore), and the old /// OPENSEEK_UPDATE_MANIFEST_URL environment variable is gone because an /// exported variable silently redirecting the check is exactly how a /// production install ends up answering from somewhere else. -const ManifestUrl : String = "https://openseek-api.moonbitlang.cn/desktop/releases/latest.json" +const ManifestUrl : String = "https://cli.moonbitlang.cn/openseek/desktop/releases/latest.json" ///| /// A newer hosted release: the version to announce, and — when the manifest 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..555e3cfe3 100755 --- a/desktop/scripts/publish-release.sh +++ b/desktop/scripts/publish-release.sh @@ -1,139 +1,233 @@ #!/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. +# Publish Desktop releases with OSS as the canonical store. A normal publish +# uploads each versioned artifact once, verifies its public OSS metadata, +# deploys the small Browser archive to openseek-api for same-origin `/console/`, +# writes an immutable version manifest, and replaces latest.json last. # -# 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 publish [vX.Y.Z] publish the checkout's artifacts +# scripts/publish-release.sh rollback vX.Y.Z republish an immutable manifest +# scripts/publish-release.sh status print the OSS-owned latest.json # -# 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_RELEASES_ORIGIN, OPENSEEK_OSS_BUCKET, +# OPENSEEK_OSS_REGION, OPENSEEK_OSS_PREFIX, OPENSEEK_API_ORIGIN, and +# OPENSEEK_DEPLOY_TOKEN, plus ossutil 2.x credentials. 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 - 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" + publish) + 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 - # Resolve the OSS mirror destination up front, so a missing or wrong - # configuration fails before the slow API upload rather than after it. + releases_origin="${OPENSEEK_RELEASES_ORIGIN:?set OPENSEEK_RELEASES_ORIGIN}" oss_bucket="${OPENSEEK_OSS_BUCKET:?set OPENSEEK_OSS_BUCKET}" oss_region="${OPENSEEK_OSS_REGION:?set OPENSEEK_OSS_REGION}" - if [[ -n "${OPENSEEK_OSS_PREFIX:-}" ]]; then - oss_prefix="$OPENSEEK_OSS_PREFIX" - else - case "$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 $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" - 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 + oss_prefix="${OPENSEEK_OSS_PREFIX:?set OPENSEEK_OSS_PREFIX}" + api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" + deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" + releases_origin="${releases_origin%/}" + oss_prefix="${oss_prefix#/}" + oss_prefix="${oss_prefix%/}" + api_origin="${api_origin%/}" + + 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_urls=() + artifact_shas=() + + 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 - exit "$curl_status" + release_name="$(basename "$artifact")" + oss_destination="oss://$oss_bucket/$oss_prefix/$publish_version/$release_name" + public_url="$releases_origin/$publish_version/$release_name" + local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" + local_size="$(wc -c < "$artifact" | tr -d '[:space:]')" + + # Existing versioned objects are never overwritten. Retrying a partial + # release skips them, then proves they match this checkout below. + echo "uploading $artifact" + echo " to $oss_destination" + ossutil cp --ignore-existing --region "$oss_region" \ + --content-type "$content_type" \ + --cache-control "public, max-age=31536000, immutable" \ + --metadata "sha256=$local_sha" \ + "$artifact" "$oss_destination" + + headers="$(curl -fsSI --retry 3 "$public_url" | tr -d '\r')" + served_size="$(awk 'tolower($1) == "content-length:" { print $2; exit }' <<< "$headers")" + served_sha="$(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 [[ "$served_size" != "$local_size" || \ + "$served_sha" != "$local_sha" || \ + -z "$served_crc64" ]]; then + echo "OSS verification failed for $platform at $public_url" >&2 + exit 1 + fi + artifact_urls+=("$public_url") + artifact_shas+=("$local_sha") + echo "$platform verified: $public_url" + done + + # Browser remains on the API origin because its session cookies, HTTP API, + # and WebSocket routes are same-origin. A retry after Browser selection can + # safely skip this upload and continue to the OSS manifest. + browser_current="$(curl -fsSL "$api_origin/browser/releases/current.json" 2>/dev/null || true)" + if ! jq -e --arg version "$version" '.version == $version' \ + <<< "$browser_current" >/dev/null 2>&1; then + browser_archive="${artifacts[2]}" + browser_upload_url="$api_origin/desktop/releases/$publish_version/SeekMoon.browser.tar.gz?platform=browser" + response="$(curl -sS --fail-with-body -T "$browser_archive" \ + -H "Authorization: Bearer $deploy_token" "$browser_upload_url")" + response_sha="$(jq -er '.sha256' <<< "$response")" + if [[ "$response_sha" != "${artifact_shas[2]}" ]]; then + echo "DIGEST MISMATCH: API recorded $response_sha for Browser" >&2 + exit 1 + fi + curl -sS --fail-with-body -X POST \ + -H "Authorization: Bearer $deploy_token" \ + "$api_origin/browser/releases/$publish_version/publish" + echo 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 + + manifest_path="$desktop_dir/dist/latest.json" + jq -n \ + --arg version "$version" \ + --arg archive_url "${artifact_urls[0]}" \ + --arg archive_sha "${artifact_shas[0]}" \ + --arg dmg_url "${artifact_urls[1]}" \ + --arg dmg_sha "${artifact_shas[1]}" \ + --arg browser_url "${artifact_urls[2]}" \ + --arg browser_sha "${artifact_shas[2]}" \ + '{ + version: $version, + url: "https://github.com/moonbitlang/openseek/releases/latest", + platforms: { + "macos-arm64": {url: $archive_url, sha256: $archive_sha}, + "macos-arm64-dmg": {url: $dmg_url, sha256: $dmg_sha}, + browser: {url: $browser_url, sha256: $browser_sha} + } + }' > "$manifest_path" + + manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" + manifest_size="$(wc -c < "$manifest_path" | tr -d '[:space:]')" + version_manifest_destination="oss://$oss_bucket/$oss_prefix/$publish_version/manifest.json" + ossutil cp --ignore-existing --region "$oss_region" \ + --content-type "application/json" \ + --cache-control "public, max-age=31536000, immutable" \ + --metadata "sha256=$manifest_sha" \ + "$manifest_path" "$version_manifest_destination" + + version_manifest_url="$releases_origin/$publish_version/manifest.json" + headers="$(curl -fsSI --retry 3 "$version_manifest_url" | tr -d '\r')" + served_size="$(awk 'tolower($1) == "content-length:" { print $2; exit }' <<< "$headers")" + served_sha="$(awk 'tolower($1) == "x-oss-meta-sha256:" { print $2; exit }' <<< "$headers")" + if [[ "$served_size" != "$manifest_size" || "$served_sha" != "$manifest_sha" ]]; then + echo "version manifest verification failed at $version_manifest_url" >&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" + + # This small object is the only mutable release object and therefore the + # publication point. OSS replaces one object atomically. + latest_destination="oss://$oss_bucket/$oss_prefix/latest.json" 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" + --content-type "application/json" \ + --cache-control "no-cache" \ + --metadata "sha256=$manifest_sha" \ + "$manifest_path" "$latest_destination" ;; - publish) + + rollback) + rollback_version="${2:?usage: publish-release.sh rollback vX.Y.Z}" + if [[ ! "$rollback_version" =~ ^v[[:alnum:]][[:alnum:]_.-]*$ ]]; then + echo "invalid rollback version: $rollback_version" >&2 + exit 64 + fi + releases_origin="${OPENSEEK_RELEASES_ORIGIN:?set OPENSEEK_RELEASES_ORIGIN}" + oss_bucket="${OPENSEEK_OSS_BUCKET:?set OPENSEEK_OSS_BUCKET}" + oss_region="${OPENSEEK_OSS_REGION:?set OPENSEEK_OSS_REGION}" + oss_prefix="${OPENSEEK_OSS_PREFIX:?set OPENSEEK_OSS_PREFIX}" + api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" + deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" + releases_origin="${releases_origin%/}" + oss_prefix="${oss_prefix#/}" + oss_prefix="${oss_prefix%/}" + api_origin="${api_origin%/}" + manifest_path="$desktop_dir/dist/latest.json" + curl -fsSL --retry 3 \ + "$releases_origin/$rollback_version/manifest.json" \ + -o "$manifest_path" + jq -e --arg version "${rollback_version#v}" '.version == $version' \ + "$manifest_path" >/dev/null + manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" + + # Keep the same-origin Browser console on the same release as the Desktop + # pointer. The endpoint reselects an already-extracted immutable version. curl -sS --fail-with-body -X POST \ - -H "Authorization: Bearer $token" \ - "$origin/desktop/releases/${2:-v$version}/publish" + -H "Authorization: Bearer $deploy_token" \ + "$api_origin/browser/releases/$rollback_version/publish" echo + + ossutil cp --force --region "$oss_region" \ + --content-type "application/json" \ + --cache-control "no-cache" \ + --metadata "sha256=$manifest_sha" \ + "$manifest_path" "oss://$oss_bucket/$oss_prefix/latest.json" ;; + status) - curl -sS --fail-with-body \ - -H "Authorization: Bearer $token" "$origin/desktop/releases" + releases_origin="${OPENSEEK_RELEASES_ORIGIN:?set OPENSEEK_RELEASES_ORIGIN}" + curl -fsSL "${releases_origin%/}/latest.json" echo + exit 0 ;; + *) - usage >&2 + sed -n '2,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 exit 64 ;; esac + +# Publish and rollback both finish by observing the public CDN response. This +# catches a stale or misconfigured mutable cache without downloading artifacts. +expected_version="${publish_version:-${rollback_version#v}}" +expected_version="${expected_version#v}" +published="" +for attempt in 1 2 3 4 5 6 7 8 9 10; do + if published="$(curl -fsSL "${releases_origin%/}/latest.json")" && + jq -e --arg version "$expected_version" '.version == $version' \ + <<< "$published" >/dev/null; then + break + fi + if [[ "$attempt" == 10 ]]; then + echo "latest.json did not converge to $expected_version" >&2 + exit 1 + fi + sleep 2 +done +latest_headers="$(curl -fsSI "${releases_origin%/}/latest.json" | tr -d '\r')" +latest_sha="$(awk 'tolower($1) == "x-oss-meta-sha256:" { print $2; exit }' <<< "$latest_headers")" +if [[ "$latest_sha" != "$manifest_sha" ]]; then + echo "latest.json metadata does not match the published manifest" >&2 + exit 1 +fi +echo "$published" From b079e6c3477b2f20ebfdc608a7e89e91db61b7aa Mon Sep 17 00:00:00 2001 From: Haoxiang Fei Date: Sat, 22 Aug 2026 00:19:47 +0800 Subject: [PATCH 2/4] fix(release): preserve retries before sealing OSS versions --- .github/workflows/desktop-release.yml | 31 ++-- desktop/scripts/publish-release.sh | 237 +++++++++++++++++--------- 2 files changed, 176 insertions(+), 92 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 3b1322539..4f4033ec7 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -266,20 +266,9 @@ jobs: -czf desktop/dist/SeekMoon.browser.tar.gz \ browser - - name: Upload release artifacts - uses: actions/upload-artifact@v4 - with: - name: SeekMoon-macos-arm64-v${{ steps.release.outputs.version }} - path: | - desktop/dist/SeekMoon.app.zip - desktop/dist/SeekMoon.dmg - desktop/dist/SeekMoon.browser.tar.gz - if-no-files-found: error - retention-days: 14 - - # Versioned artifacts are uploaded exactly once to OSS. Browser also - # enters its same-origin deployment path; only after that succeeds does - # the runner atomically replace OSS latest.json. + # A missing version manifest leaves artifacts replaceable for retries. + # Once the manifest seals their exact bytes, Browser enters its + # same-origin deployment path and latest.json is replaced last. - name: Publish release shell: bash env: @@ -291,6 +280,20 @@ jobs: run: | desktop/scripts/publish-release.sh publish "v$RELEASE_VERSION" + # A sealed retry 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: + name: SeekMoon-macos-arm64-v${{ steps.release.outputs.version }} + path: | + desktop/dist/SeekMoon.app.zip + desktop/dist/SeekMoon.dmg + desktop/dist/SeekMoon.browser.tar.gz + if-no-files-found: error + retention-days: 14 + - name: Verify release manifest shell: bash run: | diff --git a/desktop/scripts/publish-release.sh b/desktop/scripts/publish-release.sh index 555e3cfe3..8c41c9bdc 100755 --- a/desktop/scripts/publish-release.sh +++ b/desktop/scripts/publish-release.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Publish Desktop releases with OSS as the canonical store. A normal publish -# uploads each versioned artifact once, verifies its public OSS metadata, -# deploys the small Browser archive to openseek-api for same-origin `/console/`, -# writes an immutable version manifest, and replaces latest.json last. +# replaces provisional version artifacts until an immutable manifest seals +# them, deploys the exact Browser archive to openseek-api for same-origin +# `/console/`, and replaces latest.json last. # # scripts/publish-release.sh publish [vX.Y.Z] publish the checkout's artifacts # scripts/publish-release.sh rollback vX.Y.Z republish an immutable manifest @@ -46,54 +46,109 @@ case "${1:-}" in ) platforms=("macos-arm64" "macos-arm64-dmg" "browser") content_types=("application/zip" "application/x-apple-diskimage" "application/gzip") + manifest_path="$desktop_dir/dist/latest.json" + version_manifest_key="$oss_prefix/$publish_version/manifest.json" + version_manifest_destination="oss://$oss_bucket/$version_manifest_key" + version_manifest_url="$releases_origin/$publish_version/manifest.json" artifact_urls=() artifact_shas=() + browser_staged=false - 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_destination="oss://$oss_bucket/$oss_prefix/$publish_version/$release_name" - public_url="$releases_origin/$publish_version/$release_name" - local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" - local_size="$(wc -c < "$artifact" | tr -d '[:space:]')" - - # Existing versioned objects are never overwritten. Retrying a partial - # release skips them, then proves they match this checkout below. - echo "uploading $artifact" - echo " to $oss_destination" - ossutil cp --ignore-existing --region "$oss_region" \ - --content-type "$content_type" \ - --cache-control "public, max-age=31536000, immutable" \ - --metadata "sha256=$local_sha" \ - "$artifact" "$oss_destination" - - headers="$(curl -fsSI --retry 3 "$public_url" | tr -d '\r')" - served_size="$(awk 'tolower($1) == "content-length:" { print $2; exit }' <<< "$headers")" - served_sha="$(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 [[ "$served_size" != "$local_size" || \ - "$served_sha" != "$local_sha" || \ - -z "$served_crc64" ]]; then - echo "OSS verification failed for $platform at $public_url" >&2 - exit 1 - fi - artifact_urls+=("$public_url") - artifact_shas+=("$local_sha") - echo "$platform verified: $public_url" - done + # The immutable version manifest is the commit marker. A failed HEAD must + # never be mistaken for an open version: only OSS's explicit NoSuchKey + # response permits this run to replace provisional artifacts. + manifest_head_status=0 + manifest_head="$( + ossutil api head-object \ + --region "$oss_region" \ + --bucket "$oss_bucket" \ + --key "$version_manifest_key" \ + --output-format json 2>&1 + )" || manifest_head_status=$? + if ((manifest_head_status == 0)); then + echo "$publish_version is sealed; reusing its OSS manifest and artifacts" + ossutil cp --force --region "$oss_region" \ + "$version_manifest_destination" "$manifest_path" + jq -e --arg version "$version" ' + .version == $version and + (.platforms["macos-arm64"].url | type == "string") and + (.platforms["macos-arm64"].sha256 | type == "string") and + (.platforms["macos-arm64-dmg"].url | type == "string") and + (.platforms["macos-arm64-dmg"].sha256 | type == "string") and + (.platforms.browser.url | type == "string") and + (.platforms.browser.sha256 | type == "string") + ' "$manifest_path" >/dev/null + artifact_urls=( + "$(jq -er '.platforms["macos-arm64"].url' "$manifest_path")" + "$(jq -er '.platforms["macos-arm64-dmg"].url' "$manifest_path")" + "$(jq -er '.platforms.browser.url' "$manifest_path")" + ) + artifact_shas=( + "$(jq -er '.platforms["macos-arm64"].sha256' "$manifest_path")" + "$(jq -er '.platforms["macos-arm64-dmg"].sha256' "$manifest_path")" + "$(jq -er '.platforms.browser.sha256' "$manifest_path")" + ) - # Browser remains on the API origin because its session cookies, HTTP API, - # and WebSocket routes are same-origin. A retry after Browser selection can - # safely skip this upload and continue to the OSS manifest. - browser_current="$(curl -fsSL "$api_origin/browser/releases/current.json" 2>/dev/null || true)" - if ! jq -e --arg version "$version" '.version == $version' \ - <<< "$browser_current" >/dev/null 2>&1; then + # The workflow rebuilt before discovering this sealed retry. Replace + # those nondeterministic bytes with the exact objects the manifest owns, + # so later artifact upload and verification use the published release. + for index in 0 1 2; do + artifact="${artifacts[$index]}" + release_name="$(basename "$artifact")" + oss_destination="oss://$oss_bucket/$oss_prefix/$publish_version/$release_name" + ossutil cp --force --region "$oss_region" "$oss_destination" "$artifact" + local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" + if [[ "$local_sha" != "${artifact_shas[$index]}" ]]; then + echo "sealed $release_name does not match $version_manifest_url" >&2 + exit 1 + fi + done + COPYFILE_DISABLE=1 tar -xzf "${artifacts[2]}" -C "$desktop_dir/dist" + elif [[ "$manifest_head" == *"Http Status Code: 404."* && \ + "$manifest_head" == *"Error Code: NoSuchKey."* ]]; then + echo "$publish_version is open; uploading replaceable 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_destination="oss://$oss_bucket/$oss_prefix/$publish_version/$release_name" + public_url="$releases_origin/$publish_version/$release_name" + local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" + local_size="$(wc -c < "$artifact" | tr -d '[:space:]')" + + # Until manifest.json exists these objects are provisional. Retrying a + # partial release must replace nondeterministic rebuilds instead of + # preserving bytes that no immutable manifest committed. + 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" + + headers="$(curl -fsSI --retry 3 "$public_url" | tr -d '\r')" + served_size="$(awk 'tolower($1) == "content-length:" { print $2; exit }' <<< "$headers")" + served_sha="$(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 [[ "$served_size" != "$local_size" || \ + "$served_sha" != "$local_sha" || \ + -z "$served_crc64" ]]; then + echo "OSS verification failed for $platform at $public_url" >&2 + exit 1 + fi + artifact_urls+=("$public_url") + artifact_shas+=("$local_sha") + echo "$platform verified: $public_url" + done + + # Stage Browser before sealing the manifest. It remains replaceable in + # the API until the later publish request selects this version. browser_archive="${artifacts[2]}" browser_upload_url="$api_origin/desktop/releases/$publish_version/SeekMoon.browser.tar.gz?platform=browser" response="$(curl -sS --fail-with-body -T "$browser_archive" \ @@ -103,41 +158,44 @@ case "${1:-}" in echo "DIGEST MISMATCH: API recorded $response_sha for Browser" >&2 exit 1 fi - curl -sS --fail-with-body -X POST \ - -H "Authorization: Bearer $deploy_token" \ - "$api_origin/browser/releases/$publish_version/publish" - echo - fi + browser_staged=true - manifest_path="$desktop_dir/dist/latest.json" - jq -n \ - --arg version "$version" \ - --arg archive_url "${artifact_urls[0]}" \ - --arg archive_sha "${artifact_shas[0]}" \ - --arg dmg_url "${artifact_urls[1]}" \ - --arg dmg_sha "${artifact_shas[1]}" \ - --arg browser_url "${artifact_urls[2]}" \ - --arg browser_sha "${artifact_shas[2]}" \ - '{ - version: $version, - url: "https://github.com/moonbitlang/openseek/releases/latest", - platforms: { - "macos-arm64": {url: $archive_url, sha256: $archive_sha}, - "macos-arm64-dmg": {url: $dmg_url, sha256: $dmg_sha}, - browser: {url: $browser_url, sha256: $browser_sha} - } - }' > "$manifest_path" + jq -n \ + --arg version "$version" \ + --arg archive_url "${artifact_urls[0]}" \ + --arg archive_sha "${artifact_shas[0]}" \ + --arg dmg_url "${artifact_urls[1]}" \ + --arg dmg_sha "${artifact_shas[1]}" \ + --arg browser_url "${artifact_urls[2]}" \ + --arg browser_sha "${artifact_shas[2]}" \ + '{ + version: $version, + url: "https://github.com/moonbitlang/openseek/releases/latest", + platforms: { + "macos-arm64": {url: $archive_url, sha256: $archive_sha}, + "macos-arm64-dmg": {url: $dmg_url, sha256: $dmg_sha}, + browser: {url: $browser_url, sha256: $browser_sha} + } + }' > "$manifest_path" + + manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" + ossutil api put-object \ + --region "$oss_region" \ + --bucket "$oss_bucket" \ + --key "$version_manifest_key" \ + --body "file://$manifest_path" \ + --content-type "application/json" \ + --cache-control "public, max-age=31536000, immutable" \ + --metadata "sha256=$manifest_sha" \ + --forbid-overwrite true + echo "$publish_version sealed at $version_manifest_url" + else + printf '%s\n' "$manifest_head" >&2 + exit "$manifest_head_status" + fi manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" manifest_size="$(wc -c < "$manifest_path" | tr -d '[:space:]')" - version_manifest_destination="oss://$oss_bucket/$oss_prefix/$publish_version/manifest.json" - ossutil cp --ignore-existing --region "$oss_region" \ - --content-type "application/json" \ - --cache-control "public, max-age=31536000, immutable" \ - --metadata "sha256=$manifest_sha" \ - "$manifest_path" "$version_manifest_destination" - - version_manifest_url="$releases_origin/$publish_version/manifest.json" headers="$(curl -fsSI --retry 3 "$version_manifest_url" | tr -d '\r')" served_size="$(awk 'tolower($1) == "content-length:" { print $2; exit }' <<< "$headers")" served_sha="$(awk 'tolower($1) == "x-oss-meta-sha256:" { print $2; exit }' <<< "$headers")" @@ -146,6 +204,29 @@ case "${1:-}" in exit 1 fi + # Browser remains on the API origin because its session cookies, HTTP API, + # and WebSocket routes are same-origin. A sealed retry stages the exact OSS + # archive; a retry after selection skips straight to latest.json. + browser_current="$(curl -fsSL "$api_origin/browser/releases/current.json" 2>/dev/null || true)" + if ! jq -e --arg version "$version" '.version == $version' \ + <<< "$browser_current" >/dev/null 2>&1; then + if [[ "$browser_staged" != true ]]; then + browser_archive="${artifacts[2]}" + browser_upload_url="$api_origin/desktop/releases/$publish_version/SeekMoon.browser.tar.gz?platform=browser" + response="$(curl -sS --fail-with-body -T "$browser_archive" \ + -H "Authorization: Bearer $deploy_token" "$browser_upload_url")" + response_sha="$(jq -er '.sha256' <<< "$response")" + if [[ "$response_sha" != "${artifact_shas[2]}" ]]; then + echo "DIGEST MISMATCH: API recorded $response_sha for Browser" >&2 + exit 1 + fi + fi + curl -sS --fail-with-body -X POST \ + -H "Authorization: Bearer $deploy_token" \ + "$api_origin/browser/releases/$publish_version/publish" + echo + fi + # This small object is the only mutable release object and therefore the # publication point. OSS replaces one object atomically. latest_destination="oss://$oss_bucket/$oss_prefix/latest.json" From ace8be53c982551abd2057cf5d1f952e85e6d31f Mon Sep 17 00:00:00 2001 From: Haoxiang Fei Date: Sat, 22 Aug 2026 00:24:31 +0800 Subject: [PATCH 3/4] refactor(release): separate upload from publish --- .github/workflows/desktop-release.yml | 22 ++++- desktop/scripts/publish-release.sh | 130 +++++++++++++++++--------- 2 files changed, 103 insertions(+), 49 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 4f4033ec7..d7c487acd 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -266,10 +266,9 @@ jobs: -czf desktop/dist/SeekMoon.browser.tar.gz \ browser - # A missing version manifest leaves artifacts replaceable for retries. - # Once the manifest seals their exact bytes, Browser enters its - # same-origin deployment path and latest.json is replaced last. - - name: Publish release + # Upload first. A missing version manifest leaves these objects + # replaceable; a retry of a sealed version restores the exact OSS bytes. + - name: Upload release to OSS shell: bash env: OPENSEEK_DEPLOY_TOKEN: ${{ secrets.OPENSEEK_DEPLOY_TOKEN }} @@ -278,7 +277,7 @@ 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 publish "v$RELEASE_VERSION" + desktop/scripts/publish-release.sh upload "v$RELEASE_VERSION" # A sealed retry replaces this run's nondeterministic rebuilds with the # exact OSS objects before reaching here. Keep those canonical bytes as @@ -294,6 +293,19 @@ jobs: if-no-files-found: error retention-days: 14 + # Publish seals the uploaded bytes, selects the staged Browser version, + # and replaces latest.json last. It does not upload release binaries. + - name: Publish release + 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 publish "v$RELEASE_VERSION" + - name: Verify release manifest shell: bash run: | diff --git a/desktop/scripts/publish-release.sh b/desktop/scripts/publish-release.sh index 8c41c9bdc..c38aadf99 100755 --- a/desktop/scripts/publish-release.sh +++ b/desktop/scripts/publish-release.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Publish Desktop releases with OSS as the canonical store. A normal publish -# replaces provisional version artifacts until an immutable manifest seals -# them, deploys the exact Browser archive to openseek-api for same-origin -# `/console/`, and replaces latest.json last. +# Upload Desktop release artifacts to OSS, then publish them by sealing an +# immutable version manifest, selecting the same Browser release on +# openseek-api, and replacing latest.json last. # -# scripts/publish-release.sh publish [vX.Y.Z] publish the checkout's artifacts +# 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 immutable manifest # scripts/publish-release.sh status print the OSS-owned latest.json # @@ -21,10 +21,10 @@ if [[ -z "$version" ]]; then fi case "${1:-}" in - publish) - publish_version="${2:-v$version}" - if [[ "$publish_version" != "v$version" ]]; then - echo "publish version $publish_version does not match checkout version v$version" >&2 + upload) + 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 @@ -47,12 +47,11 @@ case "${1:-}" in platforms=("macos-arm64" "macos-arm64-dmg" "browser") content_types=("application/zip" "application/x-apple-diskimage" "application/gzip") manifest_path="$desktop_dir/dist/latest.json" - version_manifest_key="$oss_prefix/$publish_version/manifest.json" + version_manifest_key="$oss_prefix/$upload_version/manifest.json" version_manifest_destination="oss://$oss_bucket/$version_manifest_key" - version_manifest_url="$releases_origin/$publish_version/manifest.json" + version_manifest_url="$releases_origin/$upload_version/manifest.json" artifact_urls=() artifact_shas=() - browser_staged=false # The immutable version manifest is the commit marker. A failed HEAD must # never be mistaken for an open version: only OSS's explicit NoSuchKey @@ -66,7 +65,7 @@ case "${1:-}" in --output-format json 2>&1 )" || manifest_head_status=$? if ((manifest_head_status == 0)); then - echo "$publish_version is sealed; reusing its OSS manifest and artifacts" + echo "$upload_version is sealed; reusing its OSS manifest and artifacts" ossutil cp --force --region "$oss_region" \ "$version_manifest_destination" "$manifest_path" jq -e --arg version "$version" ' @@ -95,7 +94,7 @@ case "${1:-}" in for index in 0 1 2; do artifact="${artifacts[$index]}" release_name="$(basename "$artifact")" - oss_destination="oss://$oss_bucket/$oss_prefix/$publish_version/$release_name" + oss_destination="oss://$oss_bucket/$oss_prefix/$upload_version/$release_name" ossutil cp --force --region "$oss_region" "$oss_destination" "$artifact" local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" if [[ "$local_sha" != "${artifact_shas[$index]}" ]]; then @@ -106,7 +105,7 @@ case "${1:-}" in COPYFILE_DISABLE=1 tar -xzf "${artifacts[2]}" -C "$desktop_dir/dist" elif [[ "$manifest_head" == *"Http Status Code: 404."* && \ "$manifest_head" == *"Error Code: NoSuchKey."* ]]; then - echo "$publish_version is open; uploading replaceable artifacts" + echo "$upload_version is open; uploading replaceable artifacts" for index in 0 1 2; do artifact="${artifacts[$index]}" platform="${platforms[$index]}" @@ -116,8 +115,8 @@ case "${1:-}" in exit 1 fi release_name="$(basename "$artifact")" - oss_destination="oss://$oss_bucket/$oss_prefix/$publish_version/$release_name" - public_url="$releases_origin/$publish_version/$release_name" + oss_destination="oss://$oss_bucket/$oss_prefix/$upload_version/$release_name" + public_url="$releases_origin/$upload_version/$release_name" local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" local_size="$(wc -c < "$artifact" | tr -d '[:space:]')" @@ -147,19 +146,6 @@ case "${1:-}" in echo "$platform verified: $public_url" done - # Stage Browser before sealing the manifest. It remains replaceable in - # the API until the later publish request selects this version. - browser_archive="${artifacts[2]}" - browser_upload_url="$api_origin/desktop/releases/$publish_version/SeekMoon.browser.tar.gz?platform=browser" - response="$(curl -sS --fail-with-body -T "$browser_archive" \ - -H "Authorization: Bearer $deploy_token" "$browser_upload_url")" - response_sha="$(jq -er '.sha256' <<< "$response")" - if [[ "$response_sha" != "${artifact_shas[2]}" ]]; then - echo "DIGEST MISMATCH: API recorded $response_sha for Browser" >&2 - exit 1 - fi - browser_staged=true - jq -n \ --arg version "$version" \ --arg archive_url "${artifact_urls[0]}" \ @@ -177,7 +163,73 @@ case "${1:-}" in browser: {url: $browser_url, sha256: $browser_sha} } }' > "$manifest_path" + else + printf '%s\n' "$manifest_head" >&2 + exit "$manifest_head_status" + fi + # Upload Browser in the same stage as the OSS artifacts. It stays + # replaceable until publish selects this version for same-origin /console/. + browser_current="$(curl -fsSL "$api_origin/browser/releases/current.json" 2>/dev/null || true)" + if ! jq -e --arg version "$version" '.version == $version' \ + <<< "$browser_current" >/dev/null 2>&1; then + browser_archive="${artifacts[2]}" + browser_upload_url="$api_origin/desktop/releases/$upload_version/SeekMoon.browser.tar.gz?platform=browser" + response="$(curl -sS --fail-with-body -T "$browser_archive" \ + -H "Authorization: Bearer $deploy_token" "$browser_upload_url")" + response_sha="$(jq -er '.sha256' <<< "$response")" + if [[ "$response_sha" != "${artifact_shas[2]}" ]]; then + echo "DIGEST MISMATCH: API recorded $response_sha for Browser" >&2 + exit 1 + fi + fi + echo "$upload_version is ready to publish" + exit 0 + ;; + + publish) + 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 + releases_origin="${OPENSEEK_RELEASES_ORIGIN:?set OPENSEEK_RELEASES_ORIGIN}" + oss_bucket="${OPENSEEK_OSS_BUCKET:?set OPENSEEK_OSS_BUCKET}" + oss_region="${OPENSEEK_OSS_REGION:?set OPENSEEK_OSS_REGION}" + oss_prefix="${OPENSEEK_OSS_PREFIX:?set OPENSEEK_OSS_PREFIX}" + api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" + deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" + releases_origin="${releases_origin%/}" + oss_prefix="${oss_prefix#/}" + oss_prefix="${oss_prefix%/}" + api_origin="${api_origin%/}" + manifest_path="$desktop_dir/dist/latest.json" + version_manifest_key="$oss_prefix/$publish_version/manifest.json" + version_manifest_destination="oss://$oss_bucket/$version_manifest_key" + version_manifest_url="$releases_origin/$publish_version/manifest.json" + + # Upload prepares this manifest locally. Creating it in OSS is the + # irreversible publish boundary that seals the version's artifact bytes. + manifest_head_status=0 + manifest_head="$( + ossutil api head-object \ + --region "$oss_region" \ + --bucket "$oss_bucket" \ + --key "$version_manifest_key" \ + --output-format json 2>&1 + )" || manifest_head_status=$? + if ((manifest_head_status == 0)); then + ossutil cp --force --region "$oss_region" \ + "$version_manifest_destination" "$manifest_path" + elif [[ "$manifest_head" == *"Http Status Code: 404."* && \ + "$manifest_head" == *"Error Code: NoSuchKey."* ]]; then + if [[ ! -f "$manifest_path" ]]; then + echo "release manifest not found: run upload before publish" >&2 + exit 1 + fi + jq -e --arg version "$version" '.version == $version' \ + "$manifest_path" >/dev/null manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" ossutil api put-object \ --region "$oss_region" \ @@ -194,6 +246,8 @@ case "${1:-}" in exit "$manifest_head_status" fi + jq -e --arg version "$version" '.version == $version' \ + "$manifest_path" >/dev/null manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" manifest_size="$(wc -c < "$manifest_path" | tr -d '[:space:]')" headers="$(curl -fsSI --retry 3 "$version_manifest_url" | tr -d '\r')" @@ -204,23 +258,11 @@ case "${1:-}" in exit 1 fi - # Browser remains on the API origin because its session cookies, HTTP API, - # and WebSocket routes are same-origin. A sealed retry stages the exact OSS - # archive; a retry after selection skips straight to latest.json. + # Browser was staged by upload. Publish only selects it before moving the + # public Desktop pointer, so clients never see a half-published release. browser_current="$(curl -fsSL "$api_origin/browser/releases/current.json" 2>/dev/null || true)" if ! jq -e --arg version "$version" '.version == $version' \ <<< "$browser_current" >/dev/null 2>&1; then - if [[ "$browser_staged" != true ]]; then - browser_archive="${artifacts[2]}" - browser_upload_url="$api_origin/desktop/releases/$publish_version/SeekMoon.browser.tar.gz?platform=browser" - response="$(curl -sS --fail-with-body -T "$browser_archive" \ - -H "Authorization: Bearer $deploy_token" "$browser_upload_url")" - response_sha="$(jq -er '.sha256' <<< "$response")" - if [[ "$response_sha" != "${artifact_shas[2]}" ]]; then - echo "DIGEST MISMATCH: API recorded $response_sha for Browser" >&2 - exit 1 - fi - fi curl -sS --fail-with-body -X POST \ -H "Authorization: Bearer $deploy_token" \ "$api_origin/browser/releases/$publish_version/publish" From 4c39054dfe33e526dde7243b317809509e3c621e Mon Sep 17 00:00:00 2001 From: Haoxiang Fei Date: Sat, 22 Aug 2026 01:30:21 +0800 Subject: [PATCH 4/4] fix(release): keep latest manifest in API --- .github/workflows/desktop-release.yml | 29 +- desktop/README.md | 4 +- desktop/internal/update/update.mbt | 6 +- desktop/scripts/publish-release.sh | 520 +++++++++++++------------- 4 files changed, 269 insertions(+), 290 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index d7c487acd..7fcae4a9f 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -81,8 +81,6 @@ jobs: MACOS_SIGNING_IDENTITY: "Developer ID Application: Haoxiang Fei (NYZ3LQ5QWC)" NOTARY_PROFILE: openseek-${{ github.event_name == 'schedule' && 'stable' || inputs.channel }} OPENSEEK_API_ORIGIN: ${{ (github.event_name != 'schedule' && inputs.channel == 'staging') && 'https://openseek-api-staging.moonbitlang.cn' || 'https://openseek-api.moonbitlang.cn' }} - OPENSEEK_RELEASES_ORIGIN: ${{ (github.event_name != 'schedule' && inputs.channel == 'staging') && 'https://cli.moonbitlang.cn/openseek/staging/desktop/releases' || 'https://cli.moonbitlang.cn/openseek/desktop/releases' }} - OPENSEEK_OSS_PREFIX: ${{ (github.event_name != 'schedule' && inputs.channel == 'staging') && 'openseek/staging/desktop/releases' || 'openseek/desktop/releases' }} RELEASE_CHANNEL: ${{ github.event_name == 'schedule' && 'stable' || inputs.channel }} steps: @@ -115,13 +113,10 @@ jobs: run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "schedule" ]]; then - # The first run after migration can fall back to the API-owned - # pointer. Every later run reads the canonical OSS object. - current_manifest="$( - curl -fsSL "$OPENSEEK_RELEASES_ORIGIN/latest.json" 2>/dev/null || - curl -fsSL "$OPENSEEK_API_ORIGIN/desktop/releases/latest.json" + current_version="$( + curl -fsSL "$OPENSEEK_API_ORIGIN/desktop/releases/latest.json" | + jq -er '.version | strings' )" - current_version="$(jq -er '.version | strings' <<< "$current_manifest")" if [[ ! "$current_version" =~ ^([0-9]{1,4})\.([0-9]{1,2})\.([0-9]{1,2})$ ]]; then echo "::error::Published $RELEASE_CHANNEL version '$current_version' is not an Apple-compatible three-part version" exit 1 @@ -266,8 +261,9 @@ jobs: -czf desktop/dist/SeekMoon.browser.tar.gz \ browser - # Upload first. A missing version manifest leaves these objects - # replaceable; a retry of a sealed version restores the exact OSS bytes. + # 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: @@ -279,9 +275,9 @@ jobs: run: | desktop/scripts/publish-release.sh upload "v$RELEASE_VERSION" - # A sealed retry replaces this run's nondeterministic rebuilds with the - # exact OSS objects before reaching here. Keep those canonical bytes as - # the downloadable Actions artifact too. + # 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: @@ -293,8 +289,9 @@ jobs: if-no-files-found: error retention-days: 14 - # Publish seals the uploaded bytes, selects the staged Browser version, - # and replaces latest.json last. It does not upload release binaries. + # 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: @@ -310,7 +307,7 @@ jobs: shell: bash run: | set -euo pipefail - manifest="$(curl -fsSL "$OPENSEEK_RELEASES_ORIGIN/latest.json")" + manifest="$(curl -fsSL "$OPENSEEK_API_ORIGIN/desktop/releases/latest.json")" jq -e --arg version "$RELEASE_VERSION" '.version == $version' \ <<< "$manifest" diff --git a/desktop/README.md b/desktop/README.md index 68ea4b8d5..7a83fc2c9 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -126,8 +126,8 @@ conversation's engine process on the next prompt. ## Updates -After the webview connects, the host fetches the OSS-backed release manifest -(`/openseek/desktop/releases/latest.json` on the release CDN, see +After the webview connects, the host fetches the hosted release manifest +(`/desktop/releases/latest.json` on the SeekMoon relay origin, see `internal/version` for the version it compares against) in the background. On macOS, when the manifest lists a `macos-arm64` package and the running bundle is Developer ID signed, the host downloads the zip, checks its diff --git a/desktop/internal/update/update.mbt b/desktop/internal/update/update.mbt index 1c23d5554..4c6ea89bb 100644 --- a/desktop/internal/update/update.mbt +++ b/desktop/internal/update/update.mbt @@ -5,14 +5,14 @@ // caller reports it rather than reading a broken release as "no update". ///| -/// The one manifest the app ever checks, served from the OSS-backed release -/// CDN. There is no other stream and no +/// The one manifest the app ever checks, served by the SeekMoon relay +/// origin from its releases directory. There is no other stream and no /// override: the staging channel went with the Settings server selector /// (nothing could reach it anymore), and the old /// OPENSEEK_UPDATE_MANIFEST_URL environment variable is gone because an /// exported variable silently redirecting the check is exactly how a /// production install ends up answering from somewhere else. -const ManifestUrl : String = "https://cli.moonbitlang.cn/openseek/desktop/releases/latest.json" +const ManifestUrl : String = "https://openseek-api.moonbitlang.cn/desktop/releases/latest.json" ///| /// A newer hosted release: the version to announce, and — when the manifest diff --git a/desktop/scripts/publish-release.sh b/desktop/scripts/publish-release.sh index c38aadf99..c34461ab6 100755 --- a/desktop/scripts/publish-release.sh +++ b/desktop/scripts/publish-release.sh @@ -1,16 +1,17 @@ #!/usr/bin/env bash -# Upload Desktop release artifacts to OSS, then publish them by sealing an -# immutable version manifest, selecting the same Browser release on -# openseek-api, and replacing latest.json last. +# 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 [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 immutable manifest -# scripts/publish-release.sh status print the OSS-owned latest.json +# scripts/publish-release.sh rollback vX.Y.Z republish an existing version +# scripts/publish-release.sh status list API-owned release state # -# Requires OPENSEEK_RELEASES_ORIGIN, OPENSEEK_OSS_BUCKET, -# OPENSEEK_OSS_REGION, OPENSEEK_OSS_PREFIX, OPENSEEK_API_ORIGIN, and -# OPENSEEK_DEPLOY_TOKEN, plus ossutil 2.x credentials. +# 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 desktop_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -28,16 +29,28 @@ case "${1:-}" in echo "use rollback for an already-published version" >&2 exit 64 fi - releases_origin="${OPENSEEK_RELEASES_ORIGIN:?set OPENSEEK_RELEASES_ORIGIN}" oss_bucket="${OPENSEEK_OSS_BUCKET:?set OPENSEEK_OSS_BUCKET}" oss_region="${OPENSEEK_OSS_REGION:?set OPENSEEK_OSS_REGION}" - oss_prefix="${OPENSEEK_OSS_PREFIX:?set OPENSEEK_OSS_PREFIX}" api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" - releases_origin="${releases_origin%/}" - oss_prefix="${oss_prefix#/}" - oss_prefix="${oss_prefix%/}" 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" @@ -46,142 +59,113 @@ case "${1:-}" in ) platforms=("macos-arm64" "macos-arm64-dmg" "browser") content_types=("application/zip" "application/x-apple-diskimage" "application/gzip") - manifest_path="$desktop_dir/dist/latest.json" - version_manifest_key="$oss_prefix/$upload_version/manifest.json" - version_manifest_destination="oss://$oss_bucket/$version_manifest_key" - version_manifest_url="$releases_origin/$upload_version/manifest.json" - artifact_urls=() artifact_shas=() - # The immutable version manifest is the commit marker. A failed HEAD must - # never be mistaken for an open version: only OSS's explicit NoSuchKey - # response permits this run to replace provisional artifacts. - manifest_head_status=0 - manifest_head="$( - ossutil api head-object \ - --region "$oss_region" \ - --bucket "$oss_bucket" \ - --key "$version_manifest_key" \ - --output-format json 2>&1 - )" || manifest_head_status=$? - if ((manifest_head_status == 0)); then - echo "$upload_version is sealed; reusing its OSS manifest and artifacts" - ossutil cp --force --region "$oss_region" \ - "$version_manifest_destination" "$manifest_path" - jq -e --arg version "$version" ' - .version == $version and - (.platforms["macos-arm64"].url | type == "string") and - (.platforms["macos-arm64"].sha256 | type == "string") and - (.platforms["macos-arm64-dmg"].url | type == "string") and - (.platforms["macos-arm64-dmg"].sha256 | type == "string") and - (.platforms.browser.url | type == "string") and - (.platforms.browser.sha256 | type == "string") - ' "$manifest_path" >/dev/null - artifact_urls=( - "$(jq -er '.platforms["macos-arm64"].url' "$manifest_path")" - "$(jq -er '.platforms["macos-arm64-dmg"].url' "$manifest_path")" - "$(jq -er '.platforms.browser.url' "$manifest_path")" - ) - artifact_shas=( - "$(jq -er '.platforms["macos-arm64"].sha256' "$manifest_path")" - "$(jq -er '.platforms["macos-arm64-dmg"].sha256' "$manifest_path")" - "$(jq -er '.platforms.browser.sha256' "$manifest_path")" - ) - - # The workflow rebuilt before discovering this sealed retry. Replace - # those nondeterministic bytes with the exact objects the manifest owns, - # so later artifact upload and verification use the published release. - for index in 0 1 2; do - artifact="${artifacts[$index]}" - release_name="$(basename "$artifact")" - oss_destination="oss://$oss_bucket/$oss_prefix/$upload_version/$release_name" - ossutil cp --force --region "$oss_region" "$oss_destination" "$artifact" - local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" - if [[ "$local_sha" != "${artifact_shas[$index]}" ]]; then - echo "sealed $release_name does not match $version_manifest_url" >&2 - exit 1 - fi - done - COPYFILE_DISABLE=1 tar -xzf "${artifacts[2]}" -C "$desktop_dir/dist" - elif [[ "$manifest_head" == *"Http Status Code: 404."* && \ - "$manifest_head" == *"Error Code: NoSuchKey."* ]]; then - echo "$upload_version is open; uploading replaceable artifacts" + # 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]}" - content_type="${content_types[$index]}" - if [[ ! -f "$artifact" ]]; then - echo "artifact not found: $artifact" >&2 - exit 1 - fi release_name="$(basename "$artifact")" - oss_destination="oss://$oss_bucket/$oss_prefix/$upload_version/$release_name" - public_url="$releases_origin/$upload_version/$release_name" - local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" + 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:]')" - - # Until manifest.json exists these objects are provisional. Retrying a - # partial release must replace nondeterministic rebuilds instead of - # preserving bytes that no immutable manifest committed. - 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" - - headers="$(curl -fsSI --retry 3 "$public_url" | tr -d '\r')" - served_size="$(awk 'tolower($1) == "content-length:" { print $2; exit }' <<< "$headers")" - served_sha="$(awk 'tolower($1) == "x-oss-meta-sha256:" { print $2; exit }' <<< "$headers")" - served_crc64="$(awk 'tolower($1) == "x-oss-hash-crc64ecma:" { print $2; exit }' <<< "$headers")" + local_sha="$(shasum -a 256 "$artifact" | cut -d' ' -f1)" if [[ "$served_size" != "$local_size" || \ "$served_sha" != "$local_sha" || \ -z "$served_crc64" ]]; then - echo "OSS verification failed for $platform at $public_url" >&2 + echo "published OSS artifact does not match its metadata: $platform" >&2 exit 1 fi - artifact_urls+=("$public_url") artifact_shas+=("$local_sha") - echo "$platform verified: $public_url" + echo "$platform restored: $oss_destination" done - jq -n \ - --arg version "$version" \ - --arg archive_url "${artifact_urls[0]}" \ - --arg archive_sha "${artifact_shas[0]}" \ - --arg dmg_url "${artifact_urls[1]}" \ - --arg dmg_sha "${artifact_shas[1]}" \ - --arg browser_url "${artifact_urls[2]}" \ - --arg browser_sha "${artifact_shas[2]}" \ - '{ - version: $version, - url: "https://github.com/moonbitlang/openseek/releases/latest", - platforms: { - "macos-arm64": {url: $archive_url, sha256: $archive_sha}, - "macos-arm64-dmg": {url: $dmg_url, sha256: $dmg_sha}, - browser: {url: $browser_url, sha256: $browser_sha} - } - }' > "$manifest_path" - else - printf '%s\n' "$manifest_head" >&2 - exit "$manifest_head_status" + # 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 + if [[ "$browser_status" != 404 ]]; then + echo "could not determine whether $upload_version is published: HTTP $browser_status" >&2 + exit 1 + fi + + 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" - # Upload Browser in the same stage as the OSS artifacts. It stays - # replaceable until publish selects this version for same-origin /console/. - browser_current="$(curl -fsSL "$api_origin/browser/releases/current.json" 2>/dev/null || true)" - if ! jq -e --arg version "$version" '.version == $version' \ - <<< "$browser_current" >/dev/null 2>&1; then - browser_archive="${artifacts[2]}" - browser_upload_url="$api_origin/desktop/releases/$upload_version/SeekMoon.browser.tar.gz?platform=browser" - response="$(curl -sS --fail-with-body -T "$browser_archive" \ - -H "Authorization: Bearer $deploy_token" "$browser_upload_url")" - response_sha="$(jq -er '.sha256' <<< "$response")" - if [[ "$response_sha" != "${artifact_shas[2]}" ]]; then - echo "DIGEST MISMATCH: API recorded $response_sha for Browser" >&2 + # 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 echo "$upload_version is ready to publish" exit 0 @@ -194,163 +178,161 @@ case "${1:-}" in echo "use rollback for an already-published version" >&2 exit 64 fi - releases_origin="${OPENSEEK_RELEASES_ORIGIN:?set OPENSEEK_RELEASES_ORIGIN}" - oss_bucket="${OPENSEEK_OSS_BUCKET:?set OPENSEEK_OSS_BUCKET}" - oss_region="${OPENSEEK_OSS_REGION:?set OPENSEEK_OSS_REGION}" - oss_prefix="${OPENSEEK_OSS_PREFIX:?set OPENSEEK_OSS_PREFIX}" - api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" - deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" - releases_origin="${releases_origin%/}" - oss_prefix="${oss_prefix#/}" - oss_prefix="${oss_prefix%/}" - api_origin="${api_origin%/}" - manifest_path="$desktop_dir/dist/latest.json" - version_manifest_key="$oss_prefix/$publish_version/manifest.json" - version_manifest_destination="oss://$oss_bucket/$version_manifest_key" - version_manifest_url="$releases_origin/$publish_version/manifest.json" - - # Upload prepares this manifest locally. Creating it in OSS is the - # irreversible publish boundary that seals the version's artifact bytes. - manifest_head_status=0 - manifest_head="$( - ossutil api head-object \ - --region "$oss_region" \ - --bucket "$oss_bucket" \ - --key "$version_manifest_key" \ - --output-format json 2>&1 - )" || manifest_head_status=$? - if ((manifest_head_status == 0)); then - ossutil cp --force --region "$oss_region" \ - "$version_manifest_destination" "$manifest_path" - elif [[ "$manifest_head" == *"Http Status Code: 404."* && \ - "$manifest_head" == *"Error Code: NoSuchKey."* ]]; then - if [[ ! -f "$manifest_path" ]]; then - echo "release manifest not found: run upload before publish" >&2 - exit 1 - fi - jq -e --arg version "$version" '.version == $version' \ - "$manifest_path" >/dev/null - manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" - ossutil api put-object \ - --region "$oss_region" \ - --bucket "$oss_bucket" \ - --key "$version_manifest_key" \ - --body "file://$manifest_path" \ - --content-type "application/json" \ - --cache-control "public, max-age=31536000, immutable" \ - --metadata "sha256=$manifest_sha" \ - --forbid-overwrite true - echo "$publish_version sealed at $version_manifest_url" - else - printf '%s\n' "$manifest_head" >&2 - exit "$manifest_head_status" - fi - - jq -e --arg version "$version" '.version == $version' \ - "$manifest_path" >/dev/null - manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" - manifest_size="$(wc -c < "$manifest_path" | tr -d '[:space:]')" - headers="$(curl -fsSI --retry 3 "$version_manifest_url" | tr -d '\r')" - served_size="$(awk 'tolower($1) == "content-length:" { print $2; exit }' <<< "$headers")" - served_sha="$(awk 'tolower($1) == "x-oss-meta-sha256:" { print $2; exit }' <<< "$headers")" - if [[ "$served_size" != "$manifest_size" || "$served_sha" != "$manifest_sha" ]]; then - echo "version manifest verification failed at $version_manifest_url" >&2 - exit 1 - fi - - # Browser was staged by upload. Publish only selects it before moving the - # public Desktop pointer, so clients never see a half-published release. - browser_current="$(curl -fsSL "$api_origin/browser/releases/current.json" 2>/dev/null || true)" - if ! jq -e --arg version "$version" '.version == $version' \ - <<< "$browser_current" >/dev/null 2>&1; then - curl -sS --fail-with-body -X POST \ - -H "Authorization: Bearer $deploy_token" \ - "$api_origin/browser/releases/$publish_version/publish" - echo - fi - - # This small object is the only mutable release object and therefore the - # publication point. OSS replaces one object atomically. - latest_destination="oss://$oss_bucket/$oss_prefix/latest.json" - ossutil cp --force --region "$oss_region" \ - --content-type "application/json" \ - --cache-control "no-cache" \ - --metadata "sha256=$manifest_sha" \ - "$manifest_path" "$latest_destination" + publish_from_checkout=true ;; rollback) - rollback_version="${2:?usage: publish-release.sh rollback vX.Y.Z}" - if [[ ! "$rollback_version" =~ ^v[[:alnum:]][[:alnum:]_.-]*$ ]]; then - echo "invalid rollback version: $rollback_version" >&2 + 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 - releases_origin="${OPENSEEK_RELEASES_ORIGIN:?set OPENSEEK_RELEASES_ORIGIN}" - oss_bucket="${OPENSEEK_OSS_BUCKET:?set OPENSEEK_OSS_BUCKET}" - oss_region="${OPENSEEK_OSS_REGION:?set OPENSEEK_OSS_REGION}" - oss_prefix="${OPENSEEK_OSS_PREFIX:?set OPENSEEK_OSS_PREFIX}" - api_origin="${OPENSEEK_API_ORIGIN:?set OPENSEEK_API_ORIGIN}" - deploy_token="${OPENSEEK_DEPLOY_TOKEN:?set OPENSEEK_DEPLOY_TOKEN}" - releases_origin="${releases_origin%/}" - oss_prefix="${oss_prefix#/}" - oss_prefix="${oss_prefix%/}" - api_origin="${api_origin%/}" - manifest_path="$desktop_dir/dist/latest.json" - curl -fsSL --retry 3 \ - "$releases_origin/$rollback_version/manifest.json" \ - -o "$manifest_path" - jq -e --arg version "${rollback_version#v}" '.version == $version' \ - "$manifest_path" >/dev/null - manifest_sha="$(shasum -a 256 "$manifest_path" | cut -d' ' -f1)" - - # Keep the same-origin Browser console on the same release as the Desktop - # pointer. The endpoint reselects an already-extracted immutable version. - curl -sS --fail-with-body -X POST \ - -H "Authorization: Bearer $deploy_token" \ - "$api_origin/browser/releases/$rollback_version/publish" - echo - - ossutil cp --force --region "$oss_region" \ - --content-type "application/json" \ - --cache-control "no-cache" \ - --metadata "sha256=$manifest_sha" \ - "$manifest_path" "oss://$oss_bucket/$oss_prefix/latest.json" + publish_from_checkout=false ;; status) - releases_origin="${OPENSEEK_RELEASES_ORIGIN:?set OPENSEEK_RELEASES_ORIGIN}" - curl -fsSL "${releases_origin%/}/latest.json" + 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 $deploy_token" \ + "${api_origin%/}/desktop/releases" echo exit 0 ;; *) - sed -n '2,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 + sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2 exit 64 ;; esac -# Publish and rollback both finish by observing the public CDN response. This -# catches a stale or misconfigured mutable cache without downloading artifacts. -expected_version="${publish_version:-${rollback_version#v}}" -expected_version="${expected_version#v}" -published="" -for attempt in 1 2 3 4 5 6 7 8 9 10; do - if published="$(curl -fsSL "${releases_origin%/}/latest.json")" && - jq -e --arg version "$expected_version" '.version == $version' \ - <<< "$published" >/dev/null; then - break - fi - if [[ "$attempt" == 10 ]]; then - echo "latest.json did not converge to $expected_version" >&2 +# 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 - sleep 2 + 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 -latest_headers="$(curl -fsSI "${releases_origin%/}/latest.json" | tr -d '\r')" -latest_sha="$(awk 'tolower($1) == "x-oss-meta-sha256:" { print $2; exit }' <<< "$latest_headers")" -if [[ "$latest_sha" != "$manifest_sha" ]]; then - echo "latest.json metadata does not match the published manifest" >&2 - exit 1 + +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 -echo "$published" + +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"