ci: publish darwin runtime + split >2GiB offline packages

A) runtime-release.yaml: add a native build-darwin job (macos-14 arm64 /
   macos-13 amd64) that builds the dashboard + cross-correct Go API and
   publishes xworkspace-console-runtime-darwin-{arm64,amd64}.tar.gz, fixing the
   macOS deploy 404. publish now needs both build jobs and globs all runtimes.

B) offline-package workflow: GitHub caps release assets at 2 GiB. Split any
   package >= 2 GiB into 1900 MiB parts plus a <name>.parts manifest and upload
   the parts. The offline bootstrap (download_offline_split) falls back to the
   manifest and reassembles the parts when the single asset is absent. Verified
   the split/reassemble round-trips byte-for-byte.
This commit is contained in:
Haitao Pan 2026-06-19 22:17:11 +00:00
parent a7c6e41f89
commit 77230a5fd4
3 changed files with 162 additions and 18 deletions

View File

@ -248,28 +248,67 @@ jobs:
run: |
set -euo pipefail
shopt -s nullglob
# GitHub release assets are hard-capped at 2 GiB. Packages at/over the
# limit are split into <2 GiB parts plus a ".parts" manifest; the
# offline bootstrap reassembles them. Parts are 1900 MiB.
GH_ASSET_LIMIT=2147483648
SPLIT_SIZE=1900m
delete_asset_if_present() {
local name=$1
if gh release view "$TAG_NAME" --json assets --jq '.assets[].name' \
2>/dev/null | grep -Fxq "$name"; then
echo "Deleting existing release asset ${name}"
gh release delete-asset "$TAG_NAME" "$name" --yes
fi
}
upload_one() {
local file=$1 name attempt
name="$(basename "$file")"
delete_asset_if_present "$name"
for attempt in 1 2 3; do
if gh release upload "$TAG_NAME" "$file" --clobber; then
return 0
fi
if [[ "$attempt" -eq 3 ]]; then
echo "Failed to upload ${file} after ${attempt} attempts" >&2
return 1
fi
sleep $((attempt * 20))
done
}
packages=(release-artifacts/*.tar.gz)
if [[ ${#packages[@]} -eq 0 ]]; then
echo "No offline packages found in release-artifacts" >&2
exit 1
fi
for package in "${packages[@]}"; do
echo "Uploading ${package}"
asset_name="$(basename "$package")"
if gh release view "$TAG_NAME" --json assets --jq '.assets[].name' | grep -Fxq "$asset_name"; then
echo "Deleting existing release asset ${asset_name} before upload"
gh release delete-asset "$TAG_NAME" "$asset_name" --yes
size="$(stat -c%s "$package")"
if [[ "$size" -lt "$GH_ASSET_LIMIT" ]]; then
echo "Uploading ${asset_name} (${size} bytes)"
upload_one "$package"
else
echo "Splitting oversized ${asset_name} (${size} bytes) into ${SPLIT_SIZE} parts"
dir="$(dirname "$package")"
(
cd "$dir"
rm -f "${asset_name}".part-* "${asset_name}.parts"
split -b "$SPLIT_SIZE" -d -a 3 "$asset_name" "${asset_name}.part-"
ls "${asset_name}".part-* | LC_ALL=C sort > "${asset_name}.parts"
)
# Remove any stale whole asset, then publish the manifest + parts.
delete_asset_if_present "$asset_name"
upload_one "${dir}/${asset_name}.parts"
for part in "${dir}/${asset_name}".part-*; do
echo "Uploading $(basename "$part")"
upload_one "$part"
done
fi
for attempt in 1 2 3; do
if gh release upload "$TAG_NAME" "$package" --clobber; then
break
fi
if [[ "$attempt" -eq 3 ]]; then
echo "Failed to upload ${package} after ${attempt} attempts" >&2
exit 1
fi
sleep $((attempt * 20))
done
done
- name: Rsync packages to remote mirror

View File

@ -93,14 +93,90 @@ jobs:
dist/assets/SHA256SUMS-*
if-no-files-found: error
build-darwin:
name: Build darwin-${{ matrix.arch }}
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: macos-14
- arch: amd64
runner: macos-13
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v6
with:
go-version-file: api/go.mod
cache: false
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Build target runtime
env:
TARGET_ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
root="dist/runtime/xworkspace-console"
mkdir -p "${root}/dashboard" "${root}/bin" "dist/assets"
cp -a scripts "${root}/"
cp dashboard/package.json dashboard/package-lock.json dashboard/index.html \
dashboard/tsconfig.json dashboard/vite.config.ts "${root}/dashboard/"
cp -a dashboard/src "${root}/dashboard/"
# Native macOS build: npm + go run on the matching arch runner so the
# dashboard node_modules and the API binary are genuinely darwin/${TARGET_ARCH}.
(
cd dashboard
npm ci --no-audit --no-fund
npm run build
)
cp -a dashboard/dist dashboard/node_modules "${root}/dashboard/"
(
cd api
CGO_ENABLED=0 GOOS=darwin GOARCH="${TARGET_ARCH}" \
go build -buildvcs=false -trimpath -o "../${root}/bin/xworkspace-api" .
)
cat > "${root}/manifest.json" <<JSON
{
"component": "xworkspace-console",
"commit": "${GITHUB_SHA}",
"os": "darwin",
"arch": "${TARGET_ARCH}",
"apiBinary": "bin/xworkspace-api",
"dashboard": "dashboard"
}
JSON
tar -czf "dist/assets/xworkspace-console-runtime-darwin-${TARGET_ARCH}.tar.gz" \
-C "dist/runtime" xworkspace-console
(
cd dist/assets
shasum -a 256 -- ./*.tar.gz | sed 's# \./# #' > "SHA256SUMS-darwin-${TARGET_ARCH}"
)
- uses: actions/upload-artifact@v4
with:
name: xworkspace-console-runtime-darwin-${{ matrix.arch }}
path: |
dist/assets/*.tar.gz
dist/assets/SHA256SUMS-*
if-no-files-found: error
publish:
name: Publish runtime release
needs: build
needs: [build, build-darwin]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: xworkspace-console-runtime-linux-*
pattern: xworkspace-console-runtime-*
path: dist
merge-multiple: true

View File

@ -622,6 +622,32 @@ validate_offline_archive() {
return 1
}
download_offline_split() {
# Reassemble an offline package that was published as <2 GiB parts because it
# exceeded GitHub's 2 GiB asset cap. "$source" is the would-be single-file
# URL; the parts manifest lives at "${source}.parts" and lists the part asset
# names (one per line), which sit next to it in the same release.
local source=$1
local partial=$2
local base manifest part part_url
base="${source%/*}"
manifest="${partial}.parts"
curl -fL --retry 3 --retry-delay 5 -o "$manifest" "${source}.parts" 2>/dev/null || return 1
: > "$partial"
while IFS= read -r part; do
part="${part%$'\r'}"
[ -n "$part" ] || continue
part_url="${base}/${part}"
info "Downloading AI Workspace offline package part: ${part}"
if ! curl -fL --retry 3 --retry-delay 5 "$part_url" >> "$partial"; then
rm -f "$manifest"
return 1
fi
done < "$manifest"
rm -f "$manifest"
return 0
}
prepare_offline_package_root() {
local source=$1
local filename=$2
@ -640,9 +666,12 @@ prepare_offline_package_root() {
info "Reusing cached AI Workspace offline package: $package"
else
info "Downloading AI Workspace offline package: $resolved_source"
if ! curl -fL --retry 3 --retry-delay 5 --continue-at - -o "$partial" "$resolved_source"; then
if ! curl -fL --retry 3 --retry-delay 5 --continue-at - -o "$partial" "$resolved_source" 2>/dev/null \
&& ! curl -fL --retry 3 --retry-delay 5 -o "$partial" "$resolved_source" 2>/dev/null; then
# The single asset may have been split into <2 GiB parts.
rm -f "$partial"
curl -fL --retry 3 --retry-delay 5 -o "$partial" "$resolved_source" || return 1
info "Single offline asset unavailable; trying split parts..."
download_offline_split "$source" "$partial" || return 1
fi
validate_offline_archive "$partial" || return 1
mv "$partial" "$package"