refactor: publish desktop releases directly to R2

This commit is contained in:
hy committed 2026-07-14 14:02:44 +08:00
1 parent 6215e80420
commit 3366ea0f5a
15 files changed
+1067 -807

No files matched your search

+50 -459
View File
@@ -1,4 +1,4 @@
name: Release Desktop
name: Build Windows Release
on:
push:
@@ -7,490 +7,81 @@ on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag, for example v0.1.0'
description: 'Release tag, for example v0.1.3'
required: true
jobs:
validate-release:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
build-windows:
runs-on: windows-latest
environment: ncuratore
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
fetch-depth: 0
- name: Validate release version
id: release
- name: Use HTTPS for GitHub dependencies
shell: bash
run: |
git config --global url."https://github.com/".insteadOf git@github.com:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Validate release tag and R2 configuration
shell: bash
run: |
set -euo pipefail
package_version="$(node -p "require('./package.json').version")"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
tag="${{ inputs.tag }}"
else
tag="${GITHUB_REF_NAME}"
tag="$GITHUB_REF_NAME"
fi
if [ "$tag" != "v$package_version" ]; then
echo "Release tag $tag does not match package.json version v$package_version" >&2
exit 1
fi
if [ "$(git cat-file -t "$tag")" != "tag" ]; then
echo "Release tag $tag must be an annotated tag with release notes" >&2
exit 1
fi
release_notes="$(git for-each-ref --format='%(contents)' "refs/tags/$tag")"
if [ -z "${release_notes//[[:space:]]/}" ]; then
echo "Release tag $tag does not contain release notes" >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
build-macos-x64:
needs: validate-release
runs-on: macos-15-intel
environment: ncuratore
env:
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
MAC_CSC_CERT_BASE64: ${{ secrets.MAC_CSC_CERT_BASE64 }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.validate-release.outputs.tag }}
- name: Use HTTPS for GitHub dependencies
run: |
git config --global url."https://github.com/".insteadOf git@github.com:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Verify macOS runner architecture
shell: bash
run: |
test "$(uname -m)" = "x86_64"
- name: Prepare stable macOS update identity
shell: bash
run: |
set -euo pipefail
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$MAC_CSC_CERT_BASE64"
certificate="$RUNNER_TEMP/ncurator-update-signing.cer"
printf '%s' "$MAC_CSC_CERT_BASE64" | base64 --decode > "$certificate"
sudo security add-trusted-cert \
-d \
-r trustRoot \
-p codeSign \
-k /Library/Keychains/System.keychain \
"$certificate"
- run: pnpm install --frozen-lockfile
- run: pnpm run build:mac:release:x64
- name: Verify stable macOS update signature
shell: bash
run: |
set -euo pipefail
app_path="$(find dist -maxdepth 2 -type d -name Ncurator.app -print -quit)"
test -n "$app_path"
codesign --verify --deep --strict --verbose=2 "$app_path"
signature="$(codesign -d -r- --verbose=4 "$app_path" 2>&1)"
grep -q '^Authority=.' <<< "$signature"
grep -q 'designated => .*identifier "com.yoan.ncurator"' <<< "$signature"
if grep -q '^Signature=adhoc$' <<< "$signature"; then
echo 'macOS release must not use an ad-hoc signature' >&2
exit 1
fi
if grep -q 'designated => .*cdhash' <<< "$signature"; then
echo 'macOS designated requirement must remain stable across versions' >&2
exit 1
fi
grep -E '^(Authority=|designated =>)' <<< "$signature" \
> dist/mac-update-signature.txt
cat dist/mac-update-signature.txt
- uses: actions/upload-artifact@v4
with:
name: macos-x64-artifacts
path: |
dist/*.dmg
dist/*.zip
dist/*.yml
dist/*.blockmap
dist/mac-update-signature.txt
if-no-files-found: error
build-macos-arm64:
needs: validate-release
runs-on: macos-15
environment: ncuratore
env:
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
CSC_IDENTITY_AUTO_DISCOVERY: 'true'
MAC_CSC_CERT_BASE64: ${{ secrets.MAC_CSC_CERT_BASE64 }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.validate-release.outputs.tag }}
- name: Use HTTPS for GitHub dependencies
run: |
git config --global url."https://github.com/".insteadOf git@github.com:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Verify macOS runner architecture
shell: bash
run: |
test "$(uname -m)" = "arm64"
- name: Prepare stable macOS update identity
shell: bash
run: |
set -euo pipefail
test -n "$CSC_LINK"
test -n "$CSC_KEY_PASSWORD"
test -n "$MAC_CSC_CERT_BASE64"
certificate="$RUNNER_TEMP/ncurator-update-signing.cer"
printf '%s' "$MAC_CSC_CERT_BASE64" | base64 --decode > "$certificate"
sudo security add-trusted-cert \
-d \
-r trustRoot \
-p codeSign \
-k /Library/Keychains/System.keychain \
"$certificate"
- run: pnpm install --frozen-lockfile
- run: pnpm run build:mac:release:arm64
- name: Verify stable macOS update signature
shell: bash
run: |
set -euo pipefail
app_path="$(find dist -maxdepth 2 -type d -name Ncurator.app -print -quit)"
test -n "$app_path"
codesign --verify --deep --strict --verbose=2 "$app_path"
signature="$(codesign -d -r- --verbose=4 "$app_path" 2>&1)"
grep -q '^Authority=.' <<< "$signature"
grep -q 'designated => .*identifier "com.yoan.ncurator"' <<< "$signature"
if grep -q '^Signature=adhoc$' <<< "$signature"; then
echo 'macOS release must not use an ad-hoc signature' >&2
exit 1
fi
if grep -q 'designated => .*cdhash' <<< "$signature"; then
echo 'macOS designated requirement must remain stable across versions' >&2
exit 1
fi
grep -E '^(Authority=|designated =>)' <<< "$signature" \
> dist/mac-update-signature.txt
cat dist/mac-update-signature.txt
- uses: actions/upload-artifact@v4
with:
name: macos-arm64-artifacts
path: |
dist/*.dmg
dist/*.zip
dist/*.yml
dist/*.blockmap
dist/mac-update-signature.txt
if-no-files-found: error
build-windows:
needs: validate-release
runs-on: windows-latest
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.validate-release.outputs.tag }}
- name: Use HTTPS for GitHub dependencies
run: |
git config --global url."https://github.com/".insteadOf git@github.com:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pnpm install --frozen-lockfile
- run: pnpm run build:win
- uses: actions/upload-artifact@v4
with:
name: windows-artifacts
path: |
dist/*.exe
dist/*.yml
dist/*.blockmap
if-no-files-found: error
publish-release:
runs-on: ubuntu-latest
environment: ncuratore
needs:
- validate-release
- build-macos-x64
- build-macos-arm64
- build-windows
env:
GH_TOKEN: ${{ secrets.NCURATOR_RELEASE_TOKEN }}
RELEASE_REPOSITORY: Yoan98/Ncurator
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }}
RELEASE_TAG: ${{ needs.validate-release.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.validate-release.outputs.tag }}
fetch-depth: 0
- uses: actions/download-artifact@v4
with:
path: release-artifacts
- name: Merge macOS update metadata
run: node scripts/merge-latest-mac-yml.mjs release-artifacts
- name: Verify both macOS architectures use the same update identity
shell: bash
run: |
set -euo pipefail
cmp \
release-artifacts/macos-x64-artifacts/mac-update-signature.txt \
release-artifacts/macos-arm64-artifacts/mac-update-signature.txt
- name: Prepare release notes from annotated tag
shell: bash
run: |
set -euo pipefail
git for-each-ref --format='%(contents)' "refs/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release-notes.md"
test -s "$RUNNER_TEMP/release-notes.md"
- name: Publish release assets
shell: bash
run: |
set -euo pipefail
tag="${{ needs.validate-release.outputs.tag }}"
test -f release-artifacts/macos-arm64-artifacts/Ncurator-mac-arm64.dmg
test -f release-artifacts/macos-x64-artifacts/Ncurator-mac-x64.dmg
test -f release-artifacts/windows-artifacts/Ncurator-windows-x64.exe
if ! gh release view "$tag" --repo "$RELEASE_REPOSITORY" >/dev/null 2>&1; then
gh release create "$tag" \
--repo "$RELEASE_REPOSITORY" \
--title "Ncurator $tag" \
--notes-file "$RUNNER_TEMP/release-notes.md"
else
gh release edit "$tag" \
--repo "$RELEASE_REPOSITORY" \
--title "Ncurator $tag" \
--notes-file "$RUNNER_TEMP/release-notes.md"
fi
gh release delete-asset "$tag" builder-debug.yml --repo "$RELEASE_REPOSITORY" --yes >/dev/null 2>&1 || true
find release-artifacts -type f \
\( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' -o -name 'latest.yml' -o -name 'latest-mac.yml' \) \
-print0 | xargs -0 gh release upload "$tag" --repo "$RELEASE_REPOSITORY" --clobber
- name: Validate Cloudflare R2 configuration
shell: bash
run: |
set -euo pipefail
package_version="$(node -p "require('./package.json').version")"
test "$tag" = "v$package_version"
test "$(git cat-file -t "$tag")" = "tag"
test -n "$AWS_ACCESS_KEY_ID"
test -n "$AWS_SECRET_ACCESS_KEY"
test -n "$R2_ACCOUNT_ID"
test -n "$R2_BUCKET_NAME"
test "${R2_PUBLIC_BASE_URL%/}" = "https://download.ncurator.com"
aws --version
echo "RELEASE_TAG=$tag" >> "$GITHUB_ENV"
- name: Prepare R2 update metadata
run: |
node scripts/prepare-r2-update-metadata.mjs \
release-artifacts \
r2-update-metadata \
"$RELEASE_TAG"
- run: pnpm install --frozen-lockfile
- run: pnpm run build:win
- name: Upload versioned update assets to Cloudflare R2
- name: Upload Windows release directly to Cloudflare R2
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
while IFS= read -r -d '' source; do
asset="$(basename "$source")"
aws s3 cp "$source" \
"s3://${R2_BUCKET_NAME}/updates/${RELEASE_TAG}/$asset" \
--endpoint-url "$endpoint" \
--region auto \
--cache-control 'public, max-age=31536000, immutable' \
--no-progress
done < <(find release-artifacts -type f \
\( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) \
-print0)
mkdir -p release-status
node scripts/create-release-fragment.mjs \
dist \
release-status/windows-x64.json \
"$RELEASE_TAG" \
windows-x64
node scripts/upload-r2-release-fragment.mjs \
dist \
release-status/windows-x64.json
- name: Verify versioned R2 update assets
- name: Release summary
shell: bash
run: |
set -euo pipefail
base_url="${R2_PUBLIC_BASE_URL%/}"
while IFS= read -r -d '' source; do
asset="$(basename "$source")"
expected_size="$(stat -c '%s' "$source")"
actual_size="$(curl --fail --silent --show-error --head \
"$base_url/updates/$RELEASE_TAG/$asset" \
| tr -d '\r' \
| awk 'tolower($1)=="content-length:" {value=$2} END {print value}')"
test "$actual_size" = "$expected_size"
done < <(find release-artifacts -type f \
\( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) \
-print0)
- name: Promote R2 update metadata
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
for metadata in latest.yml latest-mac.yml; do
aws s3 cp "r2-update-metadata/$metadata" \
"s3://${R2_BUCKET_NAME}/updates/latest/$metadata" \
--endpoint-url "$endpoint" \
--region auto \
--content-type application/yaml \
--cache-control 'no-store' \
--no-progress
done
- name: Verify latest R2 update metadata
shell: bash
run: |
set -euo pipefail
base_url="${R2_PUBLIC_BASE_URL%/}"
for metadata in latest.yml latest-mac.yml; do
content="$(curl --fail --silent --show-error \
"$base_url/updates/latest/$metadata?noCache=${GITHUB_RUN_ID}")"
grep -F "version: ${RELEASE_TAG#v}" <<< "$content"
grep -F "../${RELEASE_TAG}/" <<< "$content"
done
- name: Upload versioned installers to Cloudflare R2
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
version="${RELEASE_TAG#v}"
declare -A assets=(
[Ncurator-mac-arm64.dmg]="release-artifacts/macos-arm64-artifacts/Ncurator-mac-arm64.dmg"
[Ncurator-mac-x64.dmg]="release-artifacts/macos-x64-artifacts/Ncurator-mac-x64.dmg"
[Ncurator-windows-x64.exe]="release-artifacts/windows-artifacts/Ncurator-windows-x64.exe"
)
for asset in "${!assets[@]}"; do
download_name="${asset/Ncurator-/Ncurator-${version}-}"
aws s3 cp "${assets[$asset]}" \
"s3://${R2_BUCKET_NAME}/releases/${RELEASE_TAG}/$asset" \
--endpoint-url "$endpoint" \
--region auto \
--content-type application/octet-stream \
--content-disposition "attachment; filename=$download_name" \
--cache-control 'public, max-age=31536000, immutable' \
--no-progress
done
- name: Verify versioned Cloudflare R2 downloads
shell: bash
run: |
set -euo pipefail
base_url="${R2_PUBLIC_BASE_URL%/}"
version="${RELEASE_TAG#v}"
declare -A assets=(
[Ncurator-mac-arm64.dmg]="release-artifacts/macos-arm64-artifacts/Ncurator-mac-arm64.dmg"
[Ncurator-mac-x64.dmg]="release-artifacts/macos-x64-artifacts/Ncurator-mac-x64.dmg"
[Ncurator-windows-x64.exe]="release-artifacts/windows-artifacts/Ncurator-windows-x64.exe"
)
for asset in "${!assets[@]}"; do
expected_size="$(stat -c '%s' "${assets[$asset]}")"
expected_name="${asset/Ncurator-/Ncurator-${version}-}"
headers="$(curl --fail --silent --show-error --location --head \
"$base_url/releases/$RELEASE_TAG/$asset")"
actual_size="$(printf '%s\n' "$headers" | tr -d '\r' | awk 'tolower($1)=="content-length:" {value=$2} END {print value}')"
disposition="$(printf '%s\n' "$headers" | tr -d '\r' | sed -n 's/^[Cc]ontent-[Dd]isposition: *//p' | tail -1)"
test "$actual_size" = "$expected_size"
test "$disposition" = "attachment; filename=$expected_name"
printf '%s %s bytes, filename %s verified\n' "$asset" "$actual_size" "$expected_name"
done
- name: Promote installers to Cloudflare R2 latest
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
version="${RELEASE_TAG#v}"
declare -A assets=(
[Ncurator-mac-arm64.dmg]="release-artifacts/macos-arm64-artifacts/Ncurator-mac-arm64.dmg"
[Ncurator-mac-x64.dmg]="release-artifacts/macos-x64-artifacts/Ncurator-mac-x64.dmg"
[Ncurator-windows-x64.exe]="release-artifacts/windows-artifacts/Ncurator-windows-x64.exe"
)
for asset in "${!assets[@]}"; do
download_name="${asset/Ncurator-/Ncurator-${version}-}"
aws s3 cp "${assets[$asset]}" \
"s3://${R2_BUCKET_NAME}/releases/latest/$asset" \
--endpoint-url "$endpoint" \
--region auto \
--content-type application/octet-stream \
--content-disposition "attachment; filename=$download_name" \
--cache-control 'public, max-age=300' \
--no-progress
done
- name: Verify latest Cloudflare R2 downloads
shell: bash
run: |
set -euo pipefail
base_url="${R2_PUBLIC_BASE_URL%/}"
version="${RELEASE_TAG#v}"
declare -A assets=(
[Ncurator-mac-arm64.dmg]="release-artifacts/macos-arm64-artifacts/Ncurator-mac-arm64.dmg"
[Ncurator-mac-x64.dmg]="release-artifacts/macos-x64-artifacts/Ncurator-mac-x64.dmg"
[Ncurator-windows-x64.exe]="release-artifacts/windows-artifacts/Ncurator-windows-x64.exe"
)
for asset in "${!assets[@]}"; do
expected_size="$(stat -c '%s' "${assets[$asset]}")"
expected_name="${asset/Ncurator-/Ncurator-${version}-}"
headers="$(curl --fail --silent --show-error --location --head \
"$base_url/releases/latest/$asset")"
actual_size="$(printf '%s\n' "$headers" | tr -d '\r' | awk 'tolower($1)=="content-length:" {value=$2} END {print value}')"
disposition="$(printf '%s\n' "$headers" | tr -d '\r' | sed -n 's/^[Cc]ontent-[Dd]isposition: *//p' | tail -1)"
test "$actual_size" = "$expected_size"
test "$disposition" = "attachment; filename=$expected_name"
printf '%s %s bytes, filename %s verified\n' "$asset" "$actual_size" "$expected_name"
done
echo "Windows $RELEASE_TAG uploaded to Cloudflare R2." >> "$GITHUB_STEP_SUMMARY"
echo "The local macOS release command will publish latest metadata after both Mac builds are ready." >> "$GITHUB_STEP_SUMMARY"
-216
View File
@@ -1,216 +0,0 @@
name: Sync Release to Cloudflare R2
on:
workflow_dispatch:
inputs:
tag:
description: 'GitHub Release tag to sync, for example v0.1.1'
required: true
jobs:
sync-release:
runs-on: ubuntu-latest
environment: ncuratore
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
R2_PUBLIC_BASE_URL: ${{ secrets.R2_PUBLIC_BASE_URL }}
RELEASE_TAG: ${{ inputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Validate R2 configuration
shell: bash
run: |
set -euo pipefail
test -n "$AWS_ACCESS_KEY_ID"
test -n "$AWS_SECRET_ACCESS_KEY"
test -n "$R2_ACCOUNT_ID"
test -n "$R2_BUCKET_NAME"
test "${R2_PUBLIC_BASE_URL%/}" = "https://download.ncurator.com"
[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
- name: Download update assets from GitHub Release
shell: bash
run: |
set -euo pipefail
mkdir -p release-assets
for asset in \
latest.yml \
latest-mac.yml \
Ncurator-mac-arm64.dmg \
Ncurator-mac-arm64.dmg.blockmap \
Ncurator-mac-arm64.zip \
Ncurator-mac-arm64.zip.blockmap \
Ncurator-mac-x64.dmg \
Ncurator-mac-x64.dmg.blockmap \
Ncurator-mac-x64.zip \
Ncurator-mac-x64.zip.blockmap \
Ncurator-windows-x64.exe \
Ncurator-windows-x64.exe.blockmap
do
curl --fail --location --retry 3 \
--output "release-assets/$asset" \
"https://github.com/Yoan98/Ncurator/releases/download/$RELEASE_TAG/$asset"
done
ls -lh release-assets
- name: Prepare R2 update metadata
run: |
node scripts/prepare-r2-update-metadata.mjs \
release-assets \
r2-update-metadata \
"$RELEASE_TAG"
- name: Upload versioned update assets to R2
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
for source in release-assets/*; do
asset="$(basename "$source")"
case "$asset" in
latest.yml|latest-mac.yml) continue ;;
esac
aws s3 cp "$source" \
"s3://${R2_BUCKET_NAME}/updates/${RELEASE_TAG}/$asset" \
--endpoint-url "$endpoint" \
--region auto \
--cache-control 'public, max-age=31536000, immutable' \
--no-progress
done
- name: Verify versioned R2 update assets
shell: bash
run: |
set -euo pipefail
base_url="${R2_PUBLIC_BASE_URL%/}"
for source in release-assets/*; do
asset="$(basename "$source")"
case "$asset" in
latest.yml|latest-mac.yml) continue ;;
esac
expected_size="$(stat -c '%s' "$source")"
actual_size="$(curl --fail --silent --show-error --head \
"$base_url/updates/$RELEASE_TAG/$asset" \
| tr -d '\r' \
| awk 'tolower($1)=="content-length:" {value=$2} END {print value}')"
test "$actual_size" = "$expected_size"
done
- name: Promote R2 update metadata
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
for metadata in latest.yml latest-mac.yml; do
aws s3 cp "r2-update-metadata/$metadata" \
"s3://${R2_BUCKET_NAME}/updates/latest/$metadata" \
--endpoint-url "$endpoint" \
--region auto \
--content-type application/yaml \
--cache-control 'no-store' \
--no-progress
done
- name: Verify latest R2 update metadata
shell: bash
run: |
set -euo pipefail
base_url="${R2_PUBLIC_BASE_URL%/}"
for metadata in latest.yml latest-mac.yml; do
content="$(curl --fail --silent --show-error \
"$base_url/updates/latest/$metadata?noCache=${GITHUB_RUN_ID}")"
grep -F "version: ${RELEASE_TAG#v}" <<< "$content"
grep -F "../${RELEASE_TAG}/" <<< "$content"
done
- name: Upload versioned installers to R2
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
version="${RELEASE_TAG#v}"
for asset in \
Ncurator-mac-arm64.dmg \
Ncurator-mac-x64.dmg \
Ncurator-windows-x64.exe
do
download_name="${asset/Ncurator-/Ncurator-${version}-}"
aws s3 cp "release-assets/$asset" \
"s3://${R2_BUCKET_NAME}/releases/${RELEASE_TAG}/$asset" \
--endpoint-url "$endpoint" \
--region auto \
--content-type application/octet-stream \
--content-disposition "attachment; filename=$download_name" \
--cache-control 'public, max-age=31536000, immutable' \
--no-progress
done
- name: Verify versioned public download URLs
shell: bash
run: |
set -euo pipefail
base_url="${R2_PUBLIC_BASE_URL%/}"
version="${RELEASE_TAG#v}"
for asset in \
Ncurator-mac-arm64.dmg \
Ncurator-mac-x64.dmg \
Ncurator-windows-x64.exe
do
expected_size="$(stat -c '%s' "release-assets/$asset")"
expected_name="${asset/Ncurator-/Ncurator-${version}-}"
headers="$(curl --fail --silent --show-error --location --head \
"$base_url/releases/$RELEASE_TAG/$asset")"
actual_size="$(printf '%s\n' "$headers" | tr -d '\r' | awk 'tolower($1)=="content-length:" {value=$2} END {print value}')"
disposition="$(printf '%s\n' "$headers" | tr -d '\r' | sed -n 's/^[Cc]ontent-[Dd]isposition: *//p' | tail -1)"
test "$actual_size" = "$expected_size"
test "$disposition" = "attachment; filename=$expected_name"
printf '%s %s bytes, filename %s verified\n' "$asset" "$actual_size" "$expected_name"
done
- name: Promote installers to R2 latest
shell: bash
run: |
set -euo pipefail
endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
version="${RELEASE_TAG#v}"
for asset in \
Ncurator-mac-arm64.dmg \
Ncurator-mac-x64.dmg \
Ncurator-windows-x64.exe
do
download_name="${asset/Ncurator-/Ncurator-${version}-}"
aws s3 cp "release-assets/$asset" \
"s3://${R2_BUCKET_NAME}/releases/latest/$asset" \
--endpoint-url "$endpoint" \
--region auto \
--content-type application/octet-stream \
--content-disposition "attachment; filename=$download_name" \
--cache-control 'public, max-age=300' \
--no-progress
done
- name: Verify latest public download URLs
shell: bash
run: |
set -euo pipefail
base_url="${R2_PUBLIC_BASE_URL%/}"
version="${RELEASE_TAG#v}"
for asset in \
Ncurator-mac-arm64.dmg \
Ncurator-mac-x64.dmg \
Ncurator-windows-x64.exe
do
expected_size="$(stat -c '%s' "release-assets/$asset")"
expected_name="${asset/Ncurator-/Ncurator-${version}-}"
headers="$(curl --fail --silent --show-error --location --head \
"$base_url/releases/latest/$asset")"
actual_size="$(printf '%s\n' "$headers" | tr -d '\r' | awk 'tolower($1)=="content-length:" {value=$2} END {print value}')"
disposition="$(printf '%s\n' "$headers" | tr -d '\r' | sed -n 's/^[Cc]ontent-[Dd]isposition: *//p' | tail -1)"
test "$actual_size" = "$expected_size"
test "$disposition" = "attachment; filename=$expected_name"
printf '%s %s bytes, filename %s verified\n' "$asset" "$actual_size" "$expected_name"
done
+90 -118
View File
@@ -1,156 +1,128 @@
# Ncurator 发版说明
# 桌面端发布说明
## 1. 三个发布位置的职责
桌面端安装包和自动更新文件统一由 Cloudflare R2 提供。GitHub Release 只保存版本说明和 R2 下载链接,不保存二进制文件。
- 源码仓库 `Yoan98/Ncurator-Desktop`:保存源码、Tag 和 GitHub Actions。
- 公共 Release 仓库 `Yoan98/Ncurator`:保存完整发布归档,也作为 R2 同步来源。
- Cloudflare R2 `https://download.ncurator.com`:保存官网安装包和应用内自动更新文件。
## 发布结构
官网安装包使用 R2 的 `releases/latest`,应用内自动更新使用 R2 的 `updates/latest`。
- macOS arm64:在 Apple 芯片 Mac 本地构建。
- macOS x64:在同一台 Mac 的 Rosetta x64 Node/Python 环境构建。
- Windows x64:由 `.github/workflows/release.yml` 使用 GitHub Windows Runner 构建并直接上传 R2。
- GitHub Actions 不保存 Artifact,也不再运行 macOS 或 Linux 发布任务。
`NCURATOR_RELEASE_TOKEN` 只供 GitHub Actions 向公共 Release 仓库上传文件,不会被打进应用。
R2 目录:
## 2. 发布产物
Windows:
- `Ncurator-windows-x64.exe`:官网安装包。
- `latest.yml` 和 `*.blockmap`:应用内自动更新文件。
macOS:
- `Ncurator-mac-x64.dmg`:Intel Mac 官网安装包。
- `Ncurator-mac-arm64.dmg`:Apple 芯片 Mac 官网安装包。
- `Ncurator-mac-x64.zip`、`Ncurator-mac-arm64.zip`、`latest-mac.yml` 和 `*.blockmap`:应用内自动更新文件。
macOS 的 `dmg` 用于首次安装,`zip` 用于应用内自动更新。CI 会分别构建 Intel 和 Apple 芯片版本,再合并更新信息。
## 3. 官网固定下载地址
官网始终使用下面三个地址,发布新版本后不用修改官网:
- Apple 芯片 Mac:`https://download.ncurator.com/releases/latest/Ncurator-mac-arm64.dmg`
- Intel 芯片 Mac:`https://download.ncurator.com/releases/latest/Ncurator-mac-x64.dmg`
- Windows 64 位:`https://download.ncurator.com/releases/latest/Ncurator-windows-x64.exe`
每个版本也有不会变化的独立地址,例如:
`https://download.ncurator.com/releases/v0.1.1/Ncurator-windows-x64.exe`
版本目录使用长期缓存,不能覆盖旧版本。`latest` 目录只缓存 5 分钟,可以指向新版本。
## 4. Release 文案
Release 文案不保存到仓库文件中。每次发布前由 Codex 根据本次改动生成简短、面向用户的中文文案。
文案保存在源码仓库的 Annotated Tag(带说明的 Tag)里。发布工作流会读取 Tag 正文,并创建或更新公共 Release 的标题和正文。
必须使用 Annotated Tag,普通 Tag 会被工作流拒绝。
示例:
```bash
git tag -a v0.1.1 -F /tmp/ncurator-v0.1.1-release-notes.md
git push origin v0.1.1
```text
artifacts/vX.Y.Z/* 当前版本的全部安装包和更新文件
release-status/vX.Y.Z/*.json 三个平台的构建完成标记
updates/latest/latest.yml Windows 最新版自动更新元数据
updates/latest/latest-mac.yml macOS 最新版自动更新元数据
releases/latest.json 官网最新版下载清单
```
临时文案文件只用于创建 Tag,不提交到仓库。
`latest.yml`、`latest-mac.yml` 和 `latest.json` 最后发布。它们更新之前,新版本不会被应用或官网发现。
## 5. 正常发版流程
版本文件使用一年不可变缓存,最新版元数据使用 `no-store`。
1. 完成本次功能和测试。
2. 修改 `package.json` 的 `version`。
3. 提交并推送源码。
4. 生成本版本的中文用户文案。
5. 创建同版本的 Annotated Tag 并推送,例如 `package.json` 是 `0.1.1`,Tag 必须是 `v0.1.1`。
6. GitHub Actions 分别构建 macOS Intel、macOS Apple 芯片和 Windows 安装包。
7. CI 将完整安装包和更新文件发布到 `Yoan98/Ncurator`。
8. CI 先上传并验证 R2 的版本目录,验证成功后才更新并验证 `latest` 目录。
## R2 保留规则
如果 Tag 版本、`package.json` 版本或 Tag 文案不符合要求,发布会直接停止。
新版本发布并通过公网校验后,发布脚本会删除:
## 6. R2 验证和回滚
- 旧的 `artifacts/vX.Y.Z`;
- 旧的 `release-status/vX.Y.Z`;
- 旧逻辑产生的 `updates/vX.Y.Z`;
- 旧逻辑产生的 `releases/vX.Y.Z` 和 `releases/latest/*` 安装包。
上传后,CI 会检查三个安装包的文件大小和下载文件名。只有版本目录全部验证成功,才会更新官网的 `latest`。
R2 稳态只保存最新版本。需要回滚时,从旧 Tag 重新构建并发布。
如果正式发布流程中的 R2 同步失败,可以手动运行 `Sync Release to Cloudflare R2`,输入要恢复的 Tag。这个流程同样会先验证版本目录,再更新 `latest`。
## 一次性本地配置
需要回滚官网时,手动同步上一个正常版本的 Tag 即可。版本目录不会被删除,公共 GitHub Release 和应用内更新不会自动跟着回滚。
Mac 需要:
## 7. 应用内更新
- 在登录钥匙串安装并信任固定的 `Ncurator Update Signing` P12;
- 安装 arm64 Node.js、pnpm 和 Python;
- 安装 Rosetta x64 Node.js 和 Python;
- 安装 AWS CLI;
- 配置 R2 环境变量;
- 如需自动创建 GitHub Release,配置 `NCURATOR_RELEASE_TOKEN`。
- Windows 打包应用会自动检查更新。
- macOS 使用一张长期固定的自签名代码证书建立更新身份,不依赖付费 Developer ID。
- 用户也可以在 `设置 -> 应用更新` 手动检查。
- 找到新版本后,应用从 `https://download.ncurator.com/updates/latest` 下载更新包。
- 下载完成后,用户可以点击重启安装。
```bash
export NCURATOR_X64_NODE=/path/to/x64/node
export NCURATOR_X64_PYTHON=/path/to/x64/python3
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export R2_ACCOUNT_ID=...
export R2_BUCKET_NAME=...
export R2_PUBLIC_BASE_URL=https://download.ncurator.com
export NCURATOR_RELEASE_TOKEN=...
```
macOS 发布包必须满足以下条件:
敏感值不要写入仓库。
- 不是 ad-hoc 签名。
- Intel 和 Apple 芯片版本使用同一张证书。
- 所有后续版本继续使用同一张证书和 `com.yoan.ncurator` Bundle ID。
- CI 中 `codesign --verify --deep --strict` 校验通过。
- designated requirement 不能退化成随版本变化的 `cdhash`。
## 正式发布
不满足任一条件时 CI 会停止发布,应用运行时也会关闭 macOS 自动更新,不会先下载完整更新包再安装失败。
1. 修改 `package.json` 版本并提交。
2. 创建带发布说明的 annotated Tag 并推送,例如 `v0.1.3`。
3. Tag 自动触发 Windows 构建并直传 R2。
4. 在 Mac 执行:
当前还没有公开用户,因此第一个公开版本必须直接使用这张固定证书。只要首版开始保持同一身份,就不需要设计旧版迁移流程。
```bash
pnpm release:mac -- --tag v0.1.3
```
## 8. 当前签名限制
本地命令会:
- macOS 没有 Developer ID 和 Apple 公证,因此首次打开时仍可能需要用户右键选择“打开”或在系统设置中允许。
- 自签名证书只用于保证 Ncurator 新旧版本属于同一更新身份,不能让 Gatekeeper 把应用识别为受 Apple 信任的开发者。
- Windows 还没有代码签名证书,SmartScreen 可能提示未知发布者。
- 自动更新文件通过 HTTPS 从 R2 下载,并进行 `sha512` 与 macOS 代码签名双重校验;任一校验失败都不会安装。
1. 从 Tag 导出两份干净源码;
2. 分别构建 arm64 和 Rosetta x64;
3. 验证两个 App 使用相同的稳定签名;
4. 上传 macOS 文件;
5. 等待 Windows 文件;
6. 生成并发布最新元数据;
7. 创建只含 R2 链接的 GitHub Release;
8. 清理 R2 旧版本。
## 9. macOS 自签名证书
如果只想重新执行最后发布步骤:
在首个公开版本发布前,只创建一次长期证书:
```bash
pnpm release:r2:finalize -- --tag v0.1.3
```
1. 在 macOS“钥匙串访问 -> 证书助理 -> 创建证书”中创建自签名根证书。
2. 名称固定为 `Ncurator Update Signing`,证书类型选择“代码签名”,有效期建议设置为 20 年。
3. 导出包含私钥的 `.p12`,并设置强密码。
4. 另外导出不含私钥的公开 `.cer`。
5. 将 `.p12`、密码和公开证书写入 GitHub Actions Environment `ncuratore` 的 Secrets。
调试时可以添加 `--keep-old` 暂时不删除旧版本。
Secrets:
## 下载地址
- `MAC_CSC_LINK`:`.p12` 的 base64 内容或安全下载地址。
- `MAC_CSC_KEY_PASSWORD`:证书密码。
- `MAC_CSC_CERT_BASE64`:公开 `.cer` 的 base64 内容。
官网读取:
`.p12` 和私钥禁止提交到仓库。公开 `.cer` 可以公开,但当前只通过 Secret 提供给临时 CI runner。
```text
https://download.ncurator.com/releases/latest.json
```
这张证书是现有安装进入后续自动更新的根身份:
应用自动更新读取:
- 不得重新生成同名证书替换。
- 不得丢失 `.p12` 和密码,必须离线备份。
- 两个 macOS 架构和所有版本必须使用同一个 `.p12`。
- 如果更换证书,已经安装的用户将无法直接自动更新,需要重新下载安装。
```text
https://download.ncurator.com/updates/latest/latest.yml
https://download.ncurator.com/updates/latest/latest-mac.yml
```
CI 会临时信任公开证书,让构建工具可以使用自签名身份;runner 销毁后不会留下证书。用户电脑不需要安装这张证书。
实际文件始终来自:
## 10. R2 自动更新目录
```text
https://download.ncurator.com/artifacts/vX.Y.Z/*
```
客户端只访问 Cloudflare R2,不从 GitHub 拉取更新:
## 测试
- Windows 元数据:`https://download.ncurator.com/updates/latest/latest.yml`
- macOS 元数据:`https://download.ncurator.com/updates/latest/latest-mac.yml`
- 版本化更新包:`https://download.ncurator.com/updates/vX.Y.Z/*`
基础测试:
发布顺序固定为:
```bash
pnpm typecheck
pnpm test:update:unit
pnpm test:update:e2e
```
1. 上传带版本号的更新包到 `updates/vX.Y.Z`,使用长期不可变缓存。
2. 从公网地址校验文件大小。
3. 最后更新 `updates/latest` 元数据,使用 `no-store`。
4. 再从公网读取最新元数据,确认版本号和版本化下载地址正确。
真实自动更新只在明确需要时执行:
`updates/latest` 只保存很小的入口元数据,实际安装包始终从不可变的版本目录下载,避免 CDN 缓存把新旧文件混在一起。
```bash
pnpm test:update:real -- --source-version 0.1.2 --expected-version 0.1.3
```
## 11. 自动更新测试
- 单元测试和模拟 E2E 可以随功能开发执行。
- 真实更新测试不设置成每次发版自动执行,只在明确要求测试时运行,例如 `pnpm test:update:real -- --source-version 0.1.1 --expected-version 0.1.2`。
- 真实测试必须使用固定自签名证书构建旧版本,并从 R2 下载已经发布的新版本。
- 真实测试验证版本发现、完整下载、`sha512`、签名替换、重启版本和用户数据保留。
真实测试必须确认发现新版、R2 下载、sha512 校验、签名替换、重启版本和用户数据保留。
+3
View File
@@ -26,6 +26,9 @@
"test:update:unit": "node scripts/test-workflow.mjs --domain settings --suite update --type backend",
"test:update:e2e": "node scripts/test-workflow.mjs --domain settings --suite update --type e2e",
"test:update:real": "node scripts/test-app-update-real.mjs",
"release:prepare": "node scripts/prepare-desktop-release.mjs",
"release:mac": "node scripts/release-macos-local.mjs",
"release:r2:finalize": "node scripts/finalize-r2-release.mjs",
"test:workbench": "node scripts/test-workflow.mjs --domain workbench",
"test:workbench:backend": "node scripts/test-workflow.mjs --domain workbench --type backend",
"test:workbench:e2e": "node scripts/test-workflow.mjs --domain workbench --type e2e",
+7 -4
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
@@ -10,7 +11,11 @@ const SIDECAR_DIR = path.join(ROOT, 'resources', 'web_crawler_sidecar')
const SIDECAR_SCRIPT_PATH = path.join(SIDECAR_DIR, 'web_crawler_sidecar.py')
const REQUIREMENTS_PATH = path.join(SIDECAR_DIR, 'requirements.txt')
const BUILD_SCRIPT_PATH = path.join(ROOT, 'scripts', 'build_web_crawler_sidecar.py')
const VENV_DIR = path.join(ROOT, '.runvenv', 'web_crawler_sidecar')
const VENV_DIR = path.join(
ROOT,
'.runvenv',
`web_crawler_sidecar-${process.platform}-${process.arch}`
)
const STAMP_PATH = path.join(VENV_DIR, '.requirements.sha256')
const PLAYWRIGHT_BROWSER_STAMP_PATH = path.join(VENV_DIR, '.playwright-browser.sha256')
const PIP_CACHE_DIR = path.join(ROOT, '.pip-cache', 'web_crawler_sidecar')
@@ -60,9 +65,7 @@ const resolveBootstrapPython = () => {
const override = String(process.env.NCURATOR_SIDECAR_BUILD_PYTHON || '').trim()
if (override) {
if (!canRunSupportedPython(override, [])) {
throw new Error(
`NCURATOR_SIDECAR_BUILD_PYTHON must point to Python >= 3.10: ${override}`
)
throw new Error(`NCURATOR_SIDECAR_BUILD_PYTHON must point to Python >= 3.10: ${override}`)
}
return {
command: override,
+88
View File
@@ -0,0 +1,88 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { createHash } from 'node:crypto'
import { readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const [inputDirectory = '', outputPath = '', releaseTag = '', platform = ''] = process.argv.slice(2)
const definitions = {
'windows-x64': {
metadata: 'latest.yml',
signature: undefined,
assets: [
['Ncurator-windows-x64.exe', 'installer'],
['Ncurator-windows-x64.exe.blockmap', 'update']
]
},
'macos-arm64': {
metadata: 'latest-mac.yml',
signature: 'mac-update-signature.txt',
assets: [
['Ncurator-mac-arm64.dmg', 'installer'],
['Ncurator-mac-arm64.dmg.blockmap', 'update'],
['Ncurator-mac-arm64.zip', 'update'],
['Ncurator-mac-arm64.zip.blockmap', 'update']
]
},
'macos-x64': {
metadata: 'latest-mac.yml',
signature: 'mac-update-signature.txt',
assets: [
['Ncurator-mac-x64.dmg', 'installer'],
['Ncurator-mac-x64.dmg.blockmap', 'update'],
['Ncurator-mac-x64.zip', 'update'],
['Ncurator-mac-x64.zip.blockmap', 'update']
]
}
}
if (!inputDirectory || !outputPath || !/^v\d+\.\d+\.\d+$/u.test(releaseTag)) {
throw new Error(
'usage: node scripts/create-release-fragment.mjs <input-dir> <output-json> <v1.2.3> <platform>'
)
}
const definition = definitions[platform]
if (!definition) throw new Error(`unsupported release platform: ${platform}`)
const version = releaseTag.slice(1)
const sha512 = async (filePath) =>
createHash('sha512')
.update(await readFile(filePath))
.digest('base64')
const files = []
for (const [name, role] of definition.assets) {
const filePath = path.join(inputDirectory, name)
const fileStat = await stat(filePath)
files.push({ name, role, size: fileStat.size, sha512: await sha512(filePath) })
}
const metadata = await readFile(path.join(inputDirectory, definition.metadata), 'utf8')
if (!new RegExp(`^version:\\s*${version.replaceAll('.', '\\.')}\\s*$`, 'mu').test(metadata)) {
throw new Error(`${definition.metadata} does not declare version ${version}`)
}
const signature = definition.signature
? (await readFile(path.join(inputDirectory, definition.signature), 'utf8')).trim()
: undefined
await writeFile(
outputPath,
`${JSON.stringify(
{
schemaVersion: 1,
tag: releaseTag,
version,
platform,
createdAt: new Date().toISOString(),
files,
metadata,
...(signature ? { signature } : {})
},
null,
2
)}\n`,
'utf8'
)
+319
View File
@@ -0,0 +1,319 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { execFile } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const ROOT = process.cwd()
const args = process.argv.slice(2)
const valueAfter = (name) => {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
const releaseTag = valueAfter('--tag') || ''
const waitTimeoutMs = Number(valueAfter('--wait-timeout-ms') || 60 * 60 * 1000)
const keepOld = args.includes('--keep-old')
if (!/^v\d+\.\d+\.\d+$/u.test(releaseTag)) {
throw new Error('usage: node scripts/finalize-r2-release.mjs --tag <v1.2.3> [--keep-old]')
}
for (const name of [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'R2_ACCOUNT_ID',
'R2_BUCKET_NAME',
'R2_PUBLIC_BASE_URL'
]) {
if (!String(process.env[name] || '').trim())
throw new Error(`missing environment variable: ${name}`)
}
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
const bucket = process.env.R2_BUCKET_NAME
const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '')
if (publicBaseUrl !== 'https://download.ncurator.com') {
throw new Error(`unexpected R2 public URL: ${publicBaseUrl}`)
}
const aws = async (awsArgs, options = {}) =>
execFileAsync('aws', [...awsArgs, '--endpoint-url', endpoint, '--region', 'auto'], {
maxBuffer: 20 * 1024 * 1024,
...options
})
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const workDirectory = await mkdtemp(path.join(os.tmpdir(), `ncurator-release-${releaseTag}-`))
const downloadFragment = async (platform) => {
const target = path.join(workDirectory, `${platform}.json`)
const key = `release-status/${releaseTag}/${platform}.json`
const deadline = Date.now() + waitTimeoutMs
while (Date.now() < deadline) {
try {
await aws(['s3', 'cp', `s3://${bucket}/${key}`, target, '--no-progress'])
return JSON.parse(await readFile(target, 'utf8'))
} catch {
process.stdout.write(`[release] waiting for ${platform}\n`)
await delay(15_000)
}
}
throw new Error(`timed out waiting for ${key}`)
}
const verifyFragment = async (fragment, expectedPlatform) => {
if (
fragment.schemaVersion !== 1 ||
fragment.tag !== releaseTag ||
fragment.platform !== expectedPlatform
) {
throw new Error(`invalid ${expectedPlatform} release fragment`)
}
for (const file of fragment.files) {
const key = `artifacts/${releaseTag}/${file.name}`
const { stdout } = await aws([
's3api',
'head-object',
'--bucket',
bucket,
'--key',
key,
'--output',
'json'
])
const remote = JSON.parse(stdout)
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
throw new Error(`R2 object does not match release fragment: ${key}`)
}
}
}
const uploadLatestFile = async (source, key, contentType) => {
await aws([
's3',
'cp',
source,
`s3://${bucket}/${key}`,
'--content-type',
contentType,
'--cache-control',
'no-store',
'--no-progress'
])
}
const listKeys = async (prefix) => {
const keys = []
let continuationToken
do {
const command = [
's3api',
'list-objects-v2',
'--bucket',
bucket,
'--prefix',
prefix,
'--output',
'json'
]
if (continuationToken) command.push('--continuation-token', continuationToken)
const { stdout } = await aws(command)
const page = JSON.parse(stdout)
keys.push(...(page.Contents || []).map((entry) => entry.Key))
continuationToken = page.IsTruncated ? page.NextContinuationToken : undefined
} while (continuationToken)
return keys
}
const cleanupOldObjects = async () => {
const removable = []
for (const prefix of [
'artifacts/',
'release-status/',
'updates/v',
'releases/v',
'releases/latest/'
]) {
for (const key of await listKeys(prefix)) {
if (key.startsWith(`artifacts/${releaseTag}/`)) continue
if (key.startsWith(`release-status/${releaseTag}/`)) continue
removable.push(key)
}
}
for (const key of removable) {
await aws(['s3api', 'delete-object', '--bucket', bucket, '--key', key])
process.stdout.write(`[release] removed old R2 object ${key}\n`)
}
}
const createGithubRelease = async (manifest) => {
const token = String(process.env.NCURATOR_RELEASE_TOKEN || '').trim()
if (!token) {
process.stdout.write(
'[release] NCURATOR_RELEASE_TOKEN is not set; skipped GitHub Release notes\n'
)
return
}
const repository = String(process.env.RELEASE_REPOSITORY || 'Yoan98/Ncurator')
const installerLines = [
['Apple 芯片 Mac', manifest.downloads.macArm64],
['Intel Mac', manifest.downloads.macX64],
['Windows 64 位', manifest.downloads.windowsX64]
]
.map(([label, file]) => `- [${label}](${file.url}) — sha512: \`${file.sha512}\``)
.join('\n')
const body = `所有安装包均由 Cloudflare R2 提供下载。\n\n${installerLines}\n`
const headers = {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json'
}
const releaseUrl = `https://api.github.com/repos/${repository}/releases/tags/${releaseTag}`
const existing = await fetch(releaseUrl, { headers })
if (existing.status !== 404 && !existing.ok) {
throw new Error(
`GitHub Release lookup failed: HTTP ${existing.status} ${await existing.text()}`
)
}
const payload = {
tag_name: releaseTag,
name: `Ncurator ${releaseTag}`,
body,
draft: false,
prerelease: false
}
const response =
existing.status === 404
? await fetch(`https://api.github.com/repos/${repository}/releases`, {
method: 'POST',
headers,
body: JSON.stringify(payload)
})
: await fetch((await existing.json()).url, {
method: 'PATCH',
headers,
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(
`GitHub Release update failed: HTTP ${response.status} ${await response.text()}`
)
}
}
try {
const [windows, macArm64, macX64] = await Promise.all([
downloadFragment('windows-x64'),
downloadFragment('macos-arm64'),
downloadFragment('macos-x64')
])
await Promise.all([
verifyFragment(windows, 'windows-x64'),
verifyFragment(macArm64, 'macos-arm64'),
verifyFragment(macX64, 'macos-x64')
])
if (!macArm64.signature || macArm64.signature !== macX64.signature) {
throw new Error('macOS arm64 and x64 release signatures do not match')
}
const metadataInput = path.join(workDirectory, 'metadata-input')
const metadataOutput = path.join(workDirectory, 'metadata-output')
await mkdir(path.join(metadataInput, 'windows'), { recursive: true })
await mkdir(path.join(metadataInput, 'mac-arm64'), { recursive: true })
await mkdir(path.join(metadataInput, 'mac-x64'), { recursive: true })
await writeFile(path.join(metadataInput, 'windows', 'latest.yml'), windows.metadata, 'utf8')
await writeFile(
path.join(metadataInput, 'mac-arm64', 'latest-mac.yml'),
macArm64.metadata,
'utf8'
)
await writeFile(path.join(metadataInput, 'mac-x64', 'latest-mac.yml'), macX64.metadata, 'utf8')
await execFileAsync(process.execPath, [
path.join(ROOT, 'scripts', 'merge-latest-mac-yml.mjs'),
metadataInput
])
await writeFile(
path.join(metadataInput, 'latest.yml'),
await readFile(path.join(metadataInput, 'windows', 'latest.yml'), 'utf8'),
'utf8'
)
await execFileAsync(process.execPath, [
path.join(ROOT, 'scripts', 'prepare-r2-update-metadata.mjs'),
metadataInput,
metadataOutput,
releaseTag
])
const installer = (fragment) => fragment.files.find((file) => file.role === 'installer')
const toDownload = (fragment) => {
const file = installer(fragment)
if (!file) throw new Error(`missing installer in ${fragment.platform} fragment`)
return {
name: file.name,
url: `${publicBaseUrl}/artifacts/${releaseTag}/${file.name}`,
size: file.size,
sha512: file.sha512
}
}
const manifest = {
schemaVersion: 1,
version: releaseTag.slice(1),
tag: releaseTag,
publishedAt: new Date().toISOString(),
downloads: {
macArm64: toDownload(macArm64),
macX64: toDownload(macX64),
windowsX64: toDownload(windows)
}
}
const manifestPath = path.join(workDirectory, 'latest.json')
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
await createGithubRelease(manifest)
await uploadLatestFile(
path.join(metadataOutput, 'latest.yml'),
'updates/latest/latest.yml',
'application/yaml'
)
await uploadLatestFile(
path.join(metadataOutput, 'latest-mac.yml'),
'updates/latest/latest-mac.yml',
'application/yaml'
)
await uploadLatestFile(manifestPath, 'releases/latest.json', 'application/json')
const [windowsMetadata, macMetadata, publicManifest] = await Promise.all([
fetch(`${publicBaseUrl}/updates/latest/latest.yml?release=${Date.now()}`).then((response) => {
if (!response.ok) throw new Error(`latest.yml public verification failed: ${response.status}`)
return response.text()
}),
fetch(`${publicBaseUrl}/updates/latest/latest-mac.yml?release=${Date.now()}`).then(
(response) => {
if (!response.ok)
throw new Error(`latest-mac.yml public verification failed: ${response.status}`)
return response.text()
}
),
fetch(`${publicBaseUrl}/releases/latest.json?release=${Date.now()}`).then((response) => {
if (!response.ok)
throw new Error(`latest.json public verification failed: ${response.status}`)
return response.json()
})
])
if (
!windowsMetadata.includes(`version: ${releaseTag.slice(1)}`) ||
!macMetadata.includes(`version: ${releaseTag.slice(1)}`) ||
publicManifest.tag !== releaseTag
) {
throw new Error('latest R2 metadata does not match the release tag')
}
if (!keepOld) await cleanupOldObjects()
process.stdout.write(`[release] ${releaseTag} is live on Cloudflare R2\n`)
} finally {
await rm(workDirectory, { recursive: true, force: true })
}
+87
View File
@@ -0,0 +1,87 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { execFile, spawn } from 'node:child_process'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const ROOT = process.cwd()
const args = process.argv.slice(2)
const valueAfter = (name) => {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
const version = valueAfter('--version') || ''
const releaseTag = `v${version}`
const notes = valueAfter('--notes') || `Ncurator ${releaseTag}`
if (!/^\d+\.\d+\.\d+$/u.test(version)) {
throw new Error(
'usage: node scripts/prepare-desktop-release.mjs --version <1.2.3> [--notes <text>]'
)
}
const run = (command, commandArgs) =>
new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
cwd: ROOT,
env: process.env,
stdio: 'inherit'
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with ${code ?? signal}`))
})
})
const compareVersions = (left, right) => {
const a = left.split('.').map(Number)
const b = right.split('.').map(Number)
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return a[index] - b[index]
}
return 0
}
const branch = (
await execFileAsync('git', ['branch', '--show-current'], { cwd: ROOT })
).stdout.trim()
if (branch !== 'main') throw new Error(`desktop releases must be prepared on main, not ${branch}`)
await execFileAsync('git', ['diff', '--quiet', '--', 'package.json'], { cwd: ROOT }).catch(() => {
throw new Error('package.json has uncommitted changes')
})
await execFileAsync('git', ['diff', '--cached', '--quiet', '--', 'package.json'], {
cwd: ROOT
}).catch(() => {
throw new Error('package.json has staged changes')
})
const packagePath = path.join(ROOT, 'package.json')
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'))
if (compareVersions(version, packageJson.version) <= 0) {
throw new Error(`new version ${version} must be higher than ${packageJson.version}`)
}
const tagExists = await execFileAsync('git', ['rev-parse', '--verify', '--quiet', releaseTag], {
cwd: ROOT
})
.then(() => true)
.catch(() => false)
if (tagExists) throw new Error(`tag already exists: ${releaseTag}`)
packageJson.version = version
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8')
await run('pnpm', ['typecheck'])
await run('pnpm', ['test:update:unit'])
await run('pnpm', ['test:update:e2e'])
await run('git', ['add', '--', 'package.json'])
await run('git', ['commit', '-m', `release: prepare ${releaseTag}`])
await run('git', ['tag', '-a', releaseTag, '-m', notes])
await run('git', ['push', 'origin', 'main'])
await run('git', ['push', 'origin', releaseTag])
process.stdout.write(`[release] prepared and pushed ${releaseTag}\n`)
+2 -2
View File
@@ -14,7 +14,7 @@ if (!inputDirectory || !outputDirectory || !/^v\d+\.\d+\.\d+$/u.test(releaseTag)
const rewriteMetadata = (content) =>
content.replace(
/^(\s*(?:- url:|path:)\s+)([^\s/][^\s]*)$/gmu,
(_line, prefix, assetName) => `${prefix}../${releaseTag}/${assetName}`
(_line, prefix, assetName) => `${prefix}../../artifacts/${releaseTag}/${assetName}`
)
await mkdir(outputDirectory, { recursive: true })
@@ -24,7 +24,7 @@ for (const fileName of ['latest.yml', 'latest-mac.yml']) {
const outputPath = path.join(outputDirectory, fileName)
const content = await readFile(inputPath, 'utf8')
const rewritten = rewriteMetadata(content)
if (!rewritten.includes(`../${releaseTag}/`)) {
if (!rewritten.includes(`../../artifacts/${releaseTag}/`)) {
throw new Error(`${fileName} does not contain rewritten release asset URLs`)
}
await writeFile(outputPath, rewritten, 'utf8')
+196
View File
@@ -0,0 +1,196 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { execFile, spawn } from 'node:child_process'
import { access, mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const ROOT = process.cwd()
const args = process.argv.slice(2)
const valueAfter = (name) => {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
const releaseTag = valueAfter('--tag') || ''
const keepWork = args.includes('--keep-work')
const keepOld = args.includes('--keep-old')
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
throw new Error('local macOS release must run on an Apple Silicon Mac')
}
if (!/^v\d+\.\d+\.\d+$/u.test(releaseTag)) {
throw new Error('usage: node scripts/release-macos-local.mjs --tag <v1.2.3>')
}
const run = (command, commandArgs, options = {}) =>
new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
cwd: ROOT,
env: process.env,
stdio: 'inherit',
...options
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with ${code ?? signal}`))
})
})
const assertExecutable = async (filePath, label) => {
if (!filePath) throw new Error(`${label} is not configured`)
await access(filePath).catch(() => {
throw new Error(`${label} does not exist: ${filePath}`)
})
}
const findApp = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true, recursive: true })
const match = entries.find((entry) => entry.isDirectory() && entry.name === 'Ncurator.app')
if (!match) throw new Error(`Ncurator.app was not found under ${directory}`)
return path.join(match.parentPath, match.name)
}
const validatePreflight = async () => {
for (const name of [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'R2_ACCOUNT_ID',
'R2_BUCKET_NAME',
'R2_PUBLIC_BASE_URL'
]) {
if (!String(process.env[name] || '').trim()) {
throw new Error(`missing environment variable: ${name}`)
}
}
if (
String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '') !== 'https://download.ncurator.com'
) {
throw new Error('R2_PUBLIC_BASE_URL must be https://download.ncurator.com')
}
await execFileAsync('aws', ['--version']).catch(() => {
throw new Error('AWS CLI is not installed; install it before starting a local release')
})
const { stdout: tagType } = await execFileAsync('git', ['cat-file', '-t', releaseTag], {
cwd: ROOT
})
if (tagType.trim() !== 'tag') throw new Error(`${releaseTag} must be an annotated tag`)
const { stdout: packageJson } = await execFileAsync('git', ['show', `${releaseTag}:package.json`])
const version = JSON.parse(packageJson).version
if (releaseTag !== `v${version}`)
throw new Error(`${releaseTag} does not match package version ${version}`)
const { stdout: identities } = await execFileAsync('/usr/bin/security', [
'find-identity',
'-v',
'-p',
'codesigning'
])
if (!identities.includes('Ncurator Update Signing')) {
throw new Error(
'Ncurator Update Signing is not installed in the macOS login keychain; import and trust the fixed P12 certificate first'
)
}
const x64Node = String(process.env.NCURATOR_X64_NODE || '').trim()
const x64Python = String(process.env.NCURATOR_X64_PYTHON || '').trim()
await assertExecutable(x64Node, 'NCURATOR_X64_NODE')
await assertExecutable(x64Python, 'NCURATOR_X64_PYTHON')
const { stdout: nodeArch } = await execFileAsync('/usr/bin/arch', [
'-x86_64',
x64Node,
'-p',
'process.arch'
])
if (nodeArch.trim() !== 'x64') throw new Error('NCURATOR_X64_NODE is not an x64 Node.js binary')
const { stdout: pythonArch } = await execFileAsync('/usr/bin/arch', [
'-x86_64',
x64Python,
'-c',
'import platform; print(platform.machine())'
])
if (pythonArch.trim() !== 'x86_64') {
throw new Error('NCURATOR_X64_PYTHON is not an x64 Python binary')
}
const { stdout: pnpmPath } = await execFileAsync('/usr/bin/which', ['pnpm'])
return { x64Node, x64Python, pnpmPath: pnpmPath.trim() }
}
const extractTag = async (archivePath, directory) => {
await mkdir(directory, { recursive: true })
await run('/usr/bin/tar', ['-xf', archivePath, '-C', directory])
}
const createAndUploadFragment = async (sourceDirectory, platform) => {
const distDirectory = path.join(sourceDirectory, 'dist')
const appPath = await findApp(distDirectory)
await run(process.execPath, [
path.join(sourceDirectory, 'scripts', 'verify-mac-update-signature.mjs'),
appPath,
path.join(distDirectory, 'mac-update-signature.txt')
])
const fragmentPath = path.join(distDirectory, `${platform}.json`)
await run(process.execPath, [
path.join(sourceDirectory, 'scripts', 'create-release-fragment.mjs'),
distDirectory,
fragmentPath,
releaseTag,
platform
])
await run(process.execPath, [
path.join(sourceDirectory, 'scripts', 'upload-r2-release-fragment.mjs'),
distDirectory,
fragmentPath
])
}
const preflight = await validatePreflight()
const workDirectory = path.join(ROOT, '.codex-tmp', 'desktop-release', releaseTag)
const archivePath = path.join(workDirectory, 'source.tar')
const armSource = path.join(workDirectory, 'arm64')
const x64Source = path.join(workDirectory, 'x64')
await rm(workDirectory, { recursive: true, force: true })
await mkdir(workDirectory, { recursive: true })
try {
await run('git', ['archive', '--format=tar', '--output', archivePath, releaseTag])
await extractTag(archivePath, armSource)
await extractTag(archivePath, x64Source)
process.stdout.write(`[release] building ${releaseTag} for macOS arm64\n`)
await run(preflight.pnpmPath, ['install', '--frozen-lockfile'], { cwd: armSource })
await run(preflight.pnpmPath, ['run', 'build:mac:release:arm64'], { cwd: armSource })
await createAndUploadFragment(armSource, 'macos-arm64')
process.stdout.write(`[release] building ${releaseTag} for macOS x64 under Rosetta\n`)
const x64Environment = {
...process.env,
PATH: `${path.dirname(preflight.x64Node)}:${process.env.PATH || ''}`,
NCURATOR_SIDECAR_BUILD_PYTHON: preflight.x64Python
}
await run(
'/usr/bin/arch',
['-x86_64', preflight.x64Node, preflight.pnpmPath, 'install', '--frozen-lockfile'],
{ cwd: x64Source, env: x64Environment }
)
await run(
'/usr/bin/arch',
['-x86_64', preflight.x64Node, preflight.pnpmPath, 'run', 'build:mac:release:x64'],
{ cwd: x64Source, env: x64Environment }
)
await createAndUploadFragment(x64Source, 'macos-x64')
const finalizeArgs = [
path.join(armSource, 'scripts', 'finalize-r2-release.mjs'),
'--tag',
releaseTag
]
if (keepOld) finalizeArgs.push('--keep-old')
await run(process.execPath, finalizeArgs, { cwd: armSource })
} finally {
if (keepWork) process.stdout.write(`[release] kept work directory ${workDirectory}\n`)
else await rm(workDirectory, { recursive: true, force: true })
}
+103
View File
@@ -0,0 +1,103 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { execFile } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const [inputDirectory = '', fragmentPath = ''] = process.argv.slice(2)
const requiredEnvironment = [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'R2_ACCOUNT_ID',
'R2_BUCKET_NAME',
'R2_PUBLIC_BASE_URL'
]
for (const name of requiredEnvironment) {
if (!String(process.env[name] || '').trim())
throw new Error(`missing environment variable: ${name}`)
}
if (!inputDirectory || !fragmentPath) {
throw new Error('usage: node scripts/upload-r2-release-fragment.mjs <input-dir> <fragment-json>')
}
const fragment = JSON.parse(await readFile(fragmentPath, 'utf8'))
if (fragment.schemaVersion !== 1 || !/^v\d+\.\d+\.\d+$/u.test(fragment.tag)) {
throw new Error('invalid release fragment')
}
const endpoint = `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`
const bucket = process.env.R2_BUCKET_NAME
const publicBaseUrl = String(process.env.R2_PUBLIC_BASE_URL).replace(/\/$/u, '')
const aws = async (args) =>
execFileAsync('aws', [...args, '--endpoint-url', endpoint, '--region', 'auto'], {
maxBuffer: 10 * 1024 * 1024
})
const contentType = (name) => {
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.yml')) return 'application/yaml'
return 'application/octet-stream'
}
for (const file of fragment.files) {
const source = path.join(inputDirectory, file.name)
const key = `artifacts/${fragment.tag}/${file.name}`
const downloadName = file.name.replace('Ncurator-', `Ncurator-${fragment.version}-`)
await aws([
's3',
'cp',
source,
`s3://${bucket}/${key}`,
'--cache-control',
'public, max-age=31536000, immutable',
'--content-type',
contentType(file.name),
'--content-disposition',
`attachment; filename=${downloadName}`,
'--metadata',
`sha512=${file.sha512}`,
'--no-progress'
])
const { stdout } = await aws([
's3api',
'head-object',
'--bucket',
bucket,
'--key',
key,
'--output',
'json'
])
const remote = JSON.parse(stdout)
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
throw new Error(`R2 verification failed: ${key}`)
}
const publicResponse = await fetch(`${publicBaseUrl}/${key}`, { method: 'HEAD' })
if (!publicResponse.ok || Number(publicResponse.headers.get('content-length')) !== file.size) {
throw new Error(`public R2 verification failed: ${key}`)
}
}
const statusKey = `release-status/${fragment.tag}/${fragment.platform}.json`
await aws([
's3',
'cp',
fragmentPath,
`s3://${bucket}/${statusKey}`,
'--content-type',
'application/json',
'--cache-control',
'no-store',
'--no-progress'
])
process.stdout.write(
`[release] uploaded ${fragment.platform} to ${publicBaseUrl}/artifacts/${fragment.tag}/\n`
)
+36
View File
@@ -0,0 +1,36 @@
import { execFile } from 'node:child_process'
import { writeFile } from 'node:fs/promises'
import process from 'node:process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const [appPath = '', outputPath = ''] = process.argv.slice(2)
if (!appPath || !outputPath) {
throw new Error(
'usage: node scripts/verify-mac-update-signature.mjs <Ncurator.app> <output-file>'
)
}
await execFileAsync('/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath])
const { stderr } = await execFileAsync('/usr/bin/codesign', ['-d', '-r-', '--verbose=4', appPath])
if (/^Signature=adhoc$/mu.test(stderr)) {
throw new Error('macOS release must not use an ad-hoc signature')
}
if (/designated => .*cdhash\b/mu.test(stderr)) {
throw new Error('macOS designated requirement must remain stable across versions')
}
if (!/^Authority=.+$/mu.test(stderr)) {
throw new Error('macOS release does not contain a signing authority')
}
if (!/designated => .*identifier "com\.yoan\.ncurator"/mu.test(stderr)) {
throw new Error('macOS release has an unexpected designated requirement')
}
const identity = stderr
.split('\n')
.filter((line) => /^(Authority=|designated =>)/u.test(line))
.join('\n')
await writeFile(outputPath, `${identity}\n`, 'utf8')
process.stdout.write(`${identity}\n`)
@@ -12,13 +12,36 @@ describe('app update publish config', () => {
expect(config).not.toMatch(/publish:\s*\n\s*provider:\s*github/u)
})
it('requires one stable macOS signing identity before publishing update files', async () => {
it('builds Windows directly into R2 without GitHub artifacts', async () => {
const workflow = await readFile(path.resolve('.github/workflows/release.yml'), 'utf8')
expect(workflow).toContain('MAC_CSC_CERT_BASE64')
expect(workflow).toContain('build:mac:release:x64')
expect(workflow).toContain('build:mac:release:arm64')
expect(workflow).toContain('macOS release must not use an ad-hoc signature')
expect(workflow).toContain('Verify both macOS architectures use the same update identity')
expect(workflow).toContain('runs-on: windows-latest')
expect(workflow).toContain('upload-r2-release-fragment.mjs')
expect(workflow).not.toContain('actions/upload-artifact')
expect(workflow).not.toContain('runs-on: macos-')
expect(workflow).not.toContain('publish-release')
})
it('requires local Rosetta builds and stable macOS signatures', async () => {
const localRelease = await readFile(path.resolve('scripts/release-macos-local.mjs'), 'utf8')
const signatureVerifier = await readFile(
path.resolve('scripts/verify-mac-update-signature.mjs'),
'utf8'
)
expect(localRelease).toContain('NCURATOR_X64_NODE')
expect(localRelease).toContain('NCURATOR_X64_PYTHON')
expect(localRelease).toContain("'-x86_64'")
expect(signatureVerifier).toContain('macOS release must not use an ad-hoc signature')
expect(signatureVerifier).toContain('macOS designated requirement must remain stable')
})
it('keeps only R2 as the release binary source', async () => {
const finalizeScript = await readFile(path.resolve('scripts/finalize-r2-release.mjs'), 'utf8')
expect(finalizeScript).toContain('releases/latest.json')
expect(finalizeScript).toContain('cleanupOldObjects')
expect(finalizeScript).not.toContain('gh release upload')
await expect(readFile(path.resolve('.github/workflows/sync-r2.yml'), 'utf8')).rejects.toThrow()
})
})
@@ -42,7 +42,7 @@ describe('R2 update metadata', () => {
const windowsMetadata = await readFile(path.join(outputDirectory, 'latest.yml'), 'utf8')
const macMetadata = await readFile(path.join(outputDirectory, 'latest-mac.yml'), 'utf8')
expect(windowsMetadata).toContain('../v0.1.1/Ncurator-windows-x64.exe')
expect(macMetadata).toContain('../v0.1.1/Ncurator-mac-arm64.zip')
expect(windowsMetadata).toContain('../../artifacts/v0.1.1/Ncurator-windows-x64.exe')
expect(macMetadata).toContain('../../artifacts/v0.1.1/Ncurator-mac-arm64.zip')
})
})
@@ -0,0 +1,55 @@
import { createHash } from 'node:crypto'
import { execFile } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
const execFileAsync = promisify(execFile)
let temporaryDirectory = ''
afterEach(async () => {
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true })
})
describe('release fragment', () => {
it('records immutable Windows assets and update metadata', async () => {
temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-release-fragment-'))
const input = path.join(temporaryDirectory, 'input')
const output = path.join(temporaryDirectory, 'windows.json')
await mkdir(input, { recursive: true })
const installer = Buffer.from('windows-installer')
const blockmap = Buffer.from('windows-blockmap')
await writeFile(path.join(input, 'Ncurator-windows-x64.exe'), installer)
await writeFile(path.join(input, 'Ncurator-windows-x64.exe.blockmap'), blockmap)
await writeFile(
path.join(input, 'latest.yml'),
'version: 0.1.3\npath: Ncurator-windows-x64.exe\n',
'utf8'
)
await execFileAsync(process.execPath, [
path.resolve('scripts/create-release-fragment.mjs'),
input,
output,
'v0.1.3',
'windows-x64'
])
const fragment = JSON.parse(await readFile(output, 'utf8'))
expect(fragment.tag).toBe('v0.1.3')
expect(fragment.platform).toBe('windows-x64')
expect(fragment.metadata).toContain('version: 0.1.3')
expect(fragment.files).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Ncurator-windows-x64.exe',
role: 'installer',
size: installer.length,
sha512: createHash('sha512').update(installer).digest('base64')
})
])
)
})
})