From 3366ea0f5a903c99a329d0cd67a45f646f3da3fa Mon Sep 17 00:00:00 2001 From: hy Date: Tue, 14 Jul 2026 14:02:44 +0800 Subject: [PATCH] refactor: publish desktop releases directly to R2 --- .github/workflows/release.yml | 509 ++---------------- .github/workflows/sync-r2.yml | 216 -------- doc/release.md | 208 ++++--- package.json | 3 + scripts/build-web-crawler-sidecar.mjs | 11 +- scripts/create-release-fragment.mjs | 88 +++ scripts/finalize-r2-release.mjs | 319 +++++++++++ scripts/prepare-desktop-release.mjs | 87 +++ scripts/prepare-r2-update-metadata.mjs | 4 +- scripts/release-macos-local.mjs | 196 +++++++ scripts/upload-r2-release-fragment.mjs | 103 ++++ scripts/verify-mac-update-signature.mjs | 36 ++ .../update/app-update-publish-config.test.ts | 35 +- .../r2-update-metadata-contract.test.ts | 4 +- .../update/release-fragment-contract.test.ts | 55 ++ 15 files changed, 1067 insertions(+), 807 deletions(-) delete mode 100644 .github/workflows/sync-r2.yml create mode 100644 scripts/create-release-fragment.mjs create mode 100644 scripts/finalize-r2-release.mjs create mode 100644 scripts/prepare-desktop-release.mjs create mode 100644 scripts/release-macos-local.mjs create mode 100644 scripts/upload-r2-release-fragment.mjs create mode 100644 scripts/verify-mac-update-signature.mjs create mode 100644 tests/case/settings/update/release-fragment-contract.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28a5c020..0c8b109a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/.github/workflows/sync-r2.yml b/.github/workflows/sync-r2.yml deleted file mode 100644 index 86ad25e1..00000000 --- a/.github/workflows/sync-r2.yml +++ /dev/null @@ -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 diff --git a/doc/release.md b/doc/release.md index c08b7954..cad3a7fd 100644 --- a/doc/release.md +++ b/doc/release.md @@ -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 校验、签名替换、重启版本和用户数据保留。 diff --git a/package.json b/package.json index 184b59c9..c8006a69 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-web-crawler-sidecar.mjs b/scripts/build-web-crawler-sidecar.mjs index ec8c0eb4..430adeda 100644 --- a/scripts/build-web-crawler-sidecar.mjs +++ b/scripts/build-web-crawler-sidecar.mjs @@ -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, diff --git a/scripts/create-release-fragment.mjs b/scripts/create-release-fragment.mjs new file mode 100644 index 00000000..70fcbbd5 --- /dev/null +++ b/scripts/create-release-fragment.mjs @@ -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 ' + ) +} + +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' +) diff --git a/scripts/finalize-r2-release.mjs b/scripts/finalize-r2-release.mjs new file mode 100644 index 00000000..c609feb8 --- /dev/null +++ b/scripts/finalize-r2-release.mjs @@ -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 [--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 }) +} diff --git a/scripts/prepare-desktop-release.mjs b/scripts/prepare-desktop-release.mjs new file mode 100644 index 00000000..f442db1b --- /dev/null +++ b/scripts/prepare-desktop-release.mjs @@ -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 ]' + ) +} + +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`) diff --git a/scripts/prepare-r2-update-metadata.mjs b/scripts/prepare-r2-update-metadata.mjs index 3cba1351..f64981e4 100644 --- a/scripts/prepare-r2-update-metadata.mjs +++ b/scripts/prepare-r2-update-metadata.mjs @@ -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') diff --git a/scripts/release-macos-local.mjs b/scripts/release-macos-local.mjs new file mode 100644 index 00000000..c22d168b --- /dev/null +++ b/scripts/release-macos-local.mjs @@ -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 ') +} + +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 }) +} diff --git a/scripts/upload-r2-release-fragment.mjs b/scripts/upload-r2-release-fragment.mjs new file mode 100644 index 00000000..e67dfec1 --- /dev/null +++ b/scripts/upload-r2-release-fragment.mjs @@ -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 ') +} + +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` +) diff --git a/scripts/verify-mac-update-signature.mjs b/scripts/verify-mac-update-signature.mjs new file mode 100644 index 00000000..8a1c6661 --- /dev/null +++ b/scripts/verify-mac-update-signature.mjs @@ -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 ' + ) +} + +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`) diff --git a/tests/case/settings/update/app-update-publish-config.test.ts b/tests/case/settings/update/app-update-publish-config.test.ts index 38758ba4..bbe8c8ae 100644 --- a/tests/case/settings/update/app-update-publish-config.test.ts +++ b/tests/case/settings/update/app-update-publish-config.test.ts @@ -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() }) }) diff --git a/tests/case/settings/update/r2-update-metadata-contract.test.ts b/tests/case/settings/update/r2-update-metadata-contract.test.ts index 5c411dd1..28d3459a 100644 --- a/tests/case/settings/update/r2-update-metadata-contract.test.ts +++ b/tests/case/settings/update/r2-update-metadata-contract.test.ts @@ -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') }) }) diff --git a/tests/case/settings/update/release-fragment-contract.test.ts b/tests/case/settings/update/release-fragment-contract.test.ts new file mode 100644 index 00000000..b85505b1 --- /dev/null +++ b/tests/case/settings/update/release-fragment-contract.test.ts @@ -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') + }) + ]) + ) + }) +})