refactor: publish desktop releases directly to R2
This commit is contained in:
1 parent
6215e80420
commit
3366ea0f5a
15 files changed
+1067
-807
No files matched your search
+50
-459
@@ -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"
|
||||
@@ -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
@@ -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 校验、签名替换、重启版本和用户数据保留。
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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`
|
||||
)
|
||||
@@ -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')
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user