1
0
forked from boos/BoosAPI

Merge pull request #170 from cita-777/codex/release-mac-arch-guard-main-20260319

[codex] harden mac release architecture verification
This commit is contained in:
Cita
2026-03-19 21:00:35 +08:00
committed by GitHub
4 changed files with 609 additions and 459 deletions
+417 -443
View File
@@ -1,443 +1,417 @@
name: Release name: Release
on: on:
push: push:
tags: ['v*'] tags: ['v*']
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: write contents: write
packages: write packages: write
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
verify: verify:
name: Verify name: Verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Run tests - name: Run tests
run: npm test run: npm test
- name: Build - name: Build
run: npm run build run: npm run build
- name: Upload built dist - name: Upload built dist
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: metapi-dist-${{ github.sha }} name: metapi-dist-${{ github.sha }}
path: dist path: dist
if-no-files-found: error if-no-files-found: error
build-packages: build-packages:
name: Build Desktop Package (${{ matrix.platform }}) name: Build Desktop Package (${{ matrix.platform }})
needs: verify needs: verify
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
timeout-minutes: 60 timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- platform: linux - platform: linux
runner: ubuntu-latest runner: ubuntu-latest
artifactLabel: linux-x64 artifactLabel: linux-x64
- platform: windows - platform: windows
runner: windows-latest runner: windows-latest
artifactLabel: windows-x64 artifactLabel: windows-x64
- platform: mac-arm64 - platform: mac-arm64
runner: macos-15 runner: macos-15
artifactLabel: mac-arm64 artifactLabel: mac-arm64
packageCommand: npm run package:desktop -- --mac --arm64 packageCommand: npm run package:desktop -- --mac --arm64
expectedMacArch: arm64 expectedMacArch: arm64
- platform: mac-x64 - platform: mac-x64
runner: macos-15-intel runner: macos-15-intel
artifactLabel: mac-x64 artifactLabel: mac-x64
packageCommand: npm run package:desktop:mac:intel packageCommand: npm run package:desktop:mac:intel
expectedMacArch: x64 expectedMacArch: x64
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
- name: Restore Electron cache (Windows) - name: Restore Electron cache (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: | path: |
${{ runner.temp }}/.cache/electron ${{ runner.temp }}/.cache/electron
${{ runner.temp }}/.cache/electron-builder ${{ runner.temp }}/.cache/electron-builder
${{ env.LOCALAPPDATA }}\electron\Cache ${{ env.LOCALAPPDATA }}\electron\Cache
${{ env.LOCALAPPDATA }}\electron-builder\Cache ${{ env.LOCALAPPDATA }}\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
restore-keys: | restore-keys: |
electron-cache-${{ runner.os }}- electron-cache-${{ runner.os }}-
- name: Restore Electron cache (Linux) - name: Restore Electron cache (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: | path: |
${{ runner.temp }}/.cache/electron ${{ runner.temp }}/.cache/electron
${{ runner.temp }}/.cache/electron-builder ${{ runner.temp }}/.cache/electron-builder
${{ runner.temp }}/.cache/appimage-tools ${{ runner.temp }}/.cache/appimage-tools
~/.cache/electron ~/.cache/electron
~/.cache/electron-builder ~/.cache/electron-builder
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
restore-keys: | restore-keys: |
electron-cache-${{ runner.os }}- electron-cache-${{ runner.os }}-
- name: Restore Electron cache (macOS) - name: Restore Electron cache (macOS)
if: runner.os == 'macOS' if: runner.os == 'macOS'
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: | path: |
${{ runner.temp }}/.cache/electron ${{ runner.temp }}/.cache/electron
${{ runner.temp }}/.cache/electron-builder ${{ runner.temp }}/.cache/electron-builder
~/Library/Caches/electron ~/Library/Caches/electron
~/Library/Caches/electron-builder ~/Library/Caches/electron-builder
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }} key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
restore-keys: | restore-keys: |
electron-cache-${{ runner.os }}- electron-cache-${{ runner.os }}-
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Download built dist - name: Download built dist
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: metapi-dist-${{ github.sha }} name: metapi-dist-${{ github.sha }}
path: dist path: dist
- name: Build desktop artifacts (Windows) - name: Build desktop artifacts (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: pwsh shell: pwsh
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
npm_config_cache: ${{ runner.temp }}/.npm npm_config_cache: ${{ runner.temp }}/.npm
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
WIN_CSC_LINK_INPUT: ${{ secrets.CSC_LINK }} WIN_CSC_LINK_INPUT: ${{ secrets.CSC_LINK }}
WIN_CSC_KEY_PASSWORD_INPUT: ${{ secrets.CSC_KEY_PASSWORD }} WIN_CSC_KEY_PASSWORD_INPUT: ${{ secrets.CSC_KEY_PASSWORD }}
run: | run: |
if ($env:WIN_CSC_LINK_INPUT) { $env:WIN_CSC_LINK = $env:WIN_CSC_LINK_INPUT } if ($env:WIN_CSC_LINK_INPUT) { $env:WIN_CSC_LINK = $env:WIN_CSC_LINK_INPUT }
if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT } if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT }
for ($attempt = 1; $attempt -le 2; $attempt++) { for ($attempt = 1; $attempt -le 2; $attempt++) {
Write-Host "Desktop build attempt $attempt/2" Write-Host "Desktop build attempt $attempt/2"
npm run package:desktop npm run package:desktop
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
break break
} }
if ($attempt -eq 2) { if ($attempt -eq 2) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }
Start-Sleep -Seconds 90 Start-Sleep -Seconds 90
} }
- name: Smoke test packaged native modules (Windows) - name: Smoke test packaged native modules (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: pwsh shell: pwsh
env: env:
ELECTRON_RUN_AS_NODE: 1 ELECTRON_RUN_AS_NODE: 1
run: | run: |
$appRoot = Join-Path $PWD 'release\win-unpacked\resources\app' $appRoot = Join-Path $PWD 'release\win-unpacked\resources\app'
$exePath = Join-Path $PWD 'release\win-unpacked\Metapi.exe' $exePath = Join-Path $PWD 'release\win-unpacked\Metapi.exe'
Push-Location $appRoot Push-Location $appRoot
try { try {
& $exePath -e "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 ok')" & $exePath -e "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 ok')"
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE exit $LASTEXITCODE
} }
} finally { } finally {
Pop-Location Pop-Location
} }
- name: Build desktop artifacts (Linux) - name: Build desktop artifacts (Linux)
if: runner.os == 'Linux' if: runner.os == 'Linux'
shell: bash shell: bash
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
npm_config_cache: ${{ runner.temp }}/.npm npm_config_cache: ${{ runner.temp }}/.npm
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
run: | run: |
set -euo pipefail set -euo pipefail
for attempt in 1 2; do for attempt in 1 2; do
echo "Desktop build attempt ${attempt}/2" echo "Desktop build attempt ${attempt}/2"
if npm run package:desktop; then if npm run package:desktop; then
break break
fi fi
if [ "$attempt" -eq 2 ]; then if [ "$attempt" -eq 2 ]; then
exit 1 exit 1
fi fi
sleep 90 sleep 90
done done
- name: Build desktop artifacts (macOS) - name: Build desktop artifacts (macOS)
if: startsWith(matrix.platform, 'mac-') if: startsWith(matrix.platform, 'mac-')
shell: bash shell: bash
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
npm_config_cache: ${{ runner.temp }}/.npm npm_config_cache: ${{ runner.temp }}/.npm
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
APPLE_ID_INPUT: ${{ secrets.APPLE_ID }} APPLE_ID_INPUT: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }}
PACKAGE_COMMAND: ${{ matrix.packageCommand }} PACKAGE_COMMAND: ${{ matrix.packageCommand }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi
if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi
if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi
for attempt in 1 2; do for attempt in 1 2; do
echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2" echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2"
if eval "${PACKAGE_COMMAND}"; then if eval "${PACKAGE_COMMAND}"; then
break break
fi fi
if [ "$attempt" -eq 2 ]; then if [ "$attempt" -eq 2 ]; then
exit 1 exit 1
fi fi
sleep 90 sleep 90
done done
- name: Verify packaged macOS architecture - name: Verify packaged macOS architecture
if: startsWith(matrix.platform, 'mac-') if: startsWith(matrix.platform, 'mac-')
shell: bash shell: bash
env: env:
expectedMacArch: ${{ matrix.expectedMacArch }} expectedMacArch: ${{ matrix.expectedMacArch }}
run: | run: |
set -euo pipefail node scripts/desktop/verifyMacArchitecture.mjs --release-dir release --expected-arch "$expectedMacArch"
mapfile -t app_binaries < <(find release -type f -path '*/Metapi.app/Contents/MacOS/Metapi')
if [ "${#app_binaries[@]}" -eq 0 ]; then - name: Upload desktop package
echo "No packaged macOS app binary found under release/" uses: actions/upload-artifact@v7
exit 1 with:
fi name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }}
path: |
for binary in "${app_binaries[@]}"; do release/*.exe
archs="$(lipo -archs "$binary" 2>/dev/null | xargs)" release/*.dmg
echo "$binary => ${archs:-unknown}" release/*.zip
if [ -z "${archs}" ]; then release/*.AppImage
echo "Unable to detect architecture via lipo" release/*.deb
exit 1 release/*.blockmap
fi release/latest*.yml
release/latest*.json
if [ "${expectedMacArch}" = "x64" ]; then release/builder-debug.yml
[ "${archs}" = "x86_64" ] || { if-no-files-found: error
echo "Expected x86_64-only binary but got: ${archs}"
exit 1 publish-release:
} name: Publish GitHub Release
else needs: build-packages
[ "${archs}" = "arm64" ] || { runs-on: ubuntu-latest
echo "Expected arm64-only binary but got: ${archs}" timeout-minutes: 15
exit 1 if: startsWith(github.ref, 'refs/tags/')
}
fi steps:
done - name: Download package artifacts
uses: actions/download-artifact@v8
- name: Upload desktop package with:
uses: actions/upload-artifact@v7 path: release-assets
with: merge-multiple: true
name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }}
path: | - name: Show release files
release/*.exe run: ls -la release-assets
release/*.dmg
release/*.zip - name: Upload Release Assets
release/*.AppImage uses: softprops/action-gh-release@v2
release/*.deb with:
release/*.blockmap tag_name: ${{ github.ref_name }}
release/latest*.yml generate_release_notes: true
release/latest*.json files: release-assets/*
release/builder-debug.yml
if-no-files-found: error publish-docker-arch:
name: Publish Docker Image (${{ matrix.arch }})
publish-release: needs: build-packages
name: Publish GitHub Release runs-on: ${{ matrix.runner }}
needs: build-packages timeout-minutes: 45
runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/')
timeout-minutes: 15 strategy:
if: startsWith(github.ref, 'refs/tags/') fail-fast: false
matrix:
steps: include:
- name: Download package artifacts - arch: amd64
uses: actions/download-artifact@v8 platform: linux/amd64
with: runner: ubuntu-latest
path: release-assets - arch: arm64
merge-multiple: true platform: linux/arm64
runner: ubuntu-24.04-arm
- name: Show release files
run: ls -la release-assets steps:
- name: Checkout
- name: Upload Release Assets uses: actions/checkout@v6
uses: softprops/action-gh-release@v2
with: - name: Ensure Docker Hub secrets are configured
tag_name: ${{ github.ref_name }} run: |
generate_release_notes: true if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
files: release-assets/* echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN"
exit 1
publish-docker-arch: fi
name: Publish Docker Image (${{ matrix.arch }})
needs: build-packages - name: Set up Docker Buildx
runs-on: ${{ matrix.runner }} uses: docker/setup-buildx-action@v4
timeout-minutes: 45
if: startsWith(github.ref, 'refs/tags/') - name: Login to GitHub Container Registry
strategy: uses: docker/login-action@v4
fail-fast: false with:
matrix: registry: ghcr.io
include: username: ${{ github.actor }}
- arch: amd64 password: ${{ secrets.GITHUB_TOKEN }}
platform: linux/amd64
runner: ubuntu-latest - name: Login to Docker Hub
- arch: arm64 uses: docker/login-action@v4
platform: linux/arm64 with:
runner: ubuntu-24.04-arm username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Checkout - name: Docker metadata
uses: actions/checkout@v6 id: meta
uses: docker/metadata-action@v6
- name: Ensure Docker Hub secrets are configured with:
run: | images: |
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then 1467078763/metapi
echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" ghcr.io/${{ github.repository }}
exit 1 tags: |
fi type=ref,event=tag
type=raw,value=latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 - name: Add architecture suffix to tags
id: arch-tags
- name: Login to GitHub Container Registry shell: bash
uses: docker/login-action@v4 run: |
with: set -euo pipefail
registry: ghcr.io rm -f tags.txt
username: ${{ github.actor }} while IFS= read -r tag; do
password: ${{ secrets.GITHUB_TOKEN }} [ -n "$tag" ] || continue
echo "${tag}-${{ matrix.arch }}" >> tags.txt
- name: Login to Docker Hub done <<< "${{ steps.meta.outputs.tags }}"
uses: docker/login-action@v4 {
with: echo 'tags<<EOF'
username: ${{ secrets.DOCKERHUB_USERNAME }} cat tags.txt
password: ${{ secrets.DOCKERHUB_TOKEN }} echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Docker metadata
id: meta - name: Build and push ${{ matrix.platform }} image
uses: docker/metadata-action@v6 uses: docker/build-push-action@v7
with: with:
images: | context: .
1467078763/metapi file: docker/Dockerfile
ghcr.io/${{ github.repository }} platforms: ${{ matrix.platform }}
tags: | push: true
type=ref,event=tag tags: ${{ steps.arch-tags.outputs.tags }}
type=raw,value=latest labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-${{ matrix.arch }}
- name: Add architecture suffix to tags cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }}
id: arch-tags
shell: bash publish-docker:
run: | name: Publish Docker Manifests
set -euo pipefail needs: publish-docker-arch
rm -f tags.txt runs-on: ubuntu-latest
while IFS= read -r tag; do timeout-minutes: 15
[ -n "$tag" ] || continue if: startsWith(github.ref, 'refs/tags/')
echo "${tag}-${{ matrix.arch }}" >> tags.txt
done <<< "${{ steps.meta.outputs.tags }}" steps:
{ - name: Checkout
echo 'tags<<EOF' uses: actions/checkout@v6
cat tags.txt
echo 'EOF' - name: Ensure Docker Hub secrets are configured
} >> "$GITHUB_OUTPUT" run: |
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
- name: Build and push ${{ matrix.platform }} image echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN"
uses: docker/build-push-action@v7 exit 1
with: fi
context: .
file: docker/Dockerfile - name: Set up Docker Buildx
platforms: ${{ matrix.platform }} uses: docker/setup-buildx-action@v4
push: true
tags: ${{ steps.arch-tags.outputs.tags }} - name: Login to GitHub Container Registry
labels: ${{ steps.meta.outputs.labels }} uses: docker/login-action@v4
cache-from: type=gha,scope=release-${{ matrix.arch }} with:
cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} registry: ghcr.io
username: ${{ github.actor }}
publish-docker: password: ${{ secrets.GITHUB_TOKEN }}
name: Publish Docker Manifests
needs: publish-docker-arch - name: Login to Docker Hub
runs-on: ubuntu-latest uses: docker/login-action@v4
timeout-minutes: 15 with:
if: startsWith(github.ref, 'refs/tags/') username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Checkout - name: Docker metadata
uses: actions/checkout@v6 id: meta
uses: docker/metadata-action@v6
- name: Ensure Docker Hub secrets are configured with:
run: | images: |
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then 1467078763/metapi
echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN" ghcr.io/${{ github.repository }}
exit 1 tags: |
fi type=ref,event=tag
type=raw,value=latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 - name: Create multi-arch manifests
shell: bash
- name: Login to GitHub Container Registry run: |
uses: docker/login-action@v4 set -euo pipefail
with: while IFS= read -r tag; do
registry: ghcr.io [ -n "$tag" ] || continue
username: ${{ github.actor }} docker buildx imagetools create \
password: ${{ secrets.GITHUB_TOKEN }} --tag "$tag" \
"${tag}-amd64" \
- name: Login to Docker Hub "${tag}-arm64"
uses: docker/login-action@v4 done <<< "${{ steps.meta.outputs.tags }}"
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
1467078763/metapi
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag
type=raw,value=latest
- name: Create multi-arch manifests
shell: bash
run: |
set -euo pipefail
while IFS= read -r tag; do
[ -n "$tag" ] || continue
docker buildx imagetools create \
--tag "$tag" \
"${tag}-amd64" \
"${tag}-arm64"
done <<< "${{ steps.meta.outputs.tags }}"
+16 -16
View File
@@ -1,16 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
describe('release workflow', () => { describe('release workflow', () => {
it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => { it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => {
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(workflow).toContain('runner: macos-15-intel'); expect(workflow).toContain('runner: macos-15-intel');
expect(workflow).toContain('runner: macos-15'); expect(workflow).toContain('runner: macos-15');
expect(workflow).toContain('expectedMacArch: x64'); expect(workflow).toContain('expectedMacArch: x64');
expect(workflow).toContain('expectedMacArch: arm64'); expect(workflow).toContain('expectedMacArch: arm64');
expect(workflow).toContain('Verify packaged macOS architecture'); expect(workflow).toContain('Verify packaged macOS architecture');
expect(workflow).toContain('lipo -archs'); expect(workflow).toContain('node scripts/desktop/verifyMacArchitecture.mjs');
}); });
}); });
+122
View File
@@ -0,0 +1,122 @@
import { execFileSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const MAC_BINARY_SEGMENTS = ['Metapi.app', 'Contents', 'MacOS', 'Metapi'];
function walkDirectories(rootDir) {
const queue = [rootDir];
const files = [];
while (queue.length > 0) {
const currentDir = queue.shift();
if (!currentDir) {
continue;
}
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const entryPath = join(currentDir, entry.name);
if (entry.isDirectory()) {
queue.push(entryPath);
continue;
}
if (entry.isFile()) {
files.push(entryPath);
}
}
}
return files;
}
export function findPackagedMacBinaries(releaseDir) {
const normalizedSuffix = MAC_BINARY_SEGMENTS.join('/');
return walkDirectories(releaseDir).filter((filePath) =>
filePath.replaceAll('\\', '/').endsWith(normalizedSuffix),
);
}
export function normalizeExpectedMacArch(expectedArch) {
if (expectedArch === 'x64') {
return 'x86_64';
}
if (expectedArch === 'arm64') {
return 'arm64';
}
throw new Error(`Unsupported expected mac architecture: ${expectedArch}`);
}
export function inspectBinaryArchsWithLipo(binaryPath) {
try {
return execFileSync('lipo', ['-archs', binaryPath], { encoding: 'utf8' }).trim();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to detect architecture via lipo for ${binaryPath}: ${message}`);
}
}
export function verifyMacArchitecture({
releaseDir,
expectedArch,
inspectBinaryArchs = inspectBinaryArchsWithLipo,
}) {
const binaries = findPackagedMacBinaries(releaseDir);
if (binaries.length === 0) {
throw new Error(`No packaged macOS app binary found under ${releaseDir}`);
}
const expectedBinaryArch = normalizeExpectedMacArch(expectedArch);
return binaries.map((binaryPath) => {
const archs = inspectBinaryArchs(binaryPath).trim().replace(/\s+/g, ' ');
if (!archs) {
throw new Error(`Unable to detect architecture via lipo for ${binaryPath}`);
}
if (archs !== expectedBinaryArch) {
throw new Error(`Expected ${expectedBinaryArch}-only binary but got: ${archs}`);
}
return { binaryPath, archs };
});
}
function parseArgValue(args, name) {
const index = args.indexOf(name);
if (index === -1) {
return undefined;
}
return args[index + 1];
}
function main() {
const args = process.argv.slice(2);
const releaseDirArg = parseArgValue(args, '--release-dir');
const expectedArch = parseArgValue(args, '--expected-arch');
if (!releaseDirArg || !expectedArch) {
throw new Error('Usage: node scripts/desktop/verifyMacArchitecture.mjs --release-dir <dir> --expected-arch <x64|arm64>');
}
const releaseDir = resolve(releaseDirArg);
const verifiedBinaries = verifyMacArchitecture({ releaseDir, expectedArch });
for (const { binaryPath, archs } of verifiedBinaries) {
console.log(`${binaryPath} => ${archs}`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { verifyMacArchitecture } from './verifyMacArchitecture.mjs';
const tempDirs: string[] = [];
function createReleaseBinary() {
const releaseDir = mkdtempSync(join(tmpdir(), 'metapi-mac-arch-'));
tempDirs.push(releaseDir);
const binaryPath = join(releaseDir, 'mac-x64', 'Metapi.app', 'Contents', 'MacOS', 'Metapi');
mkdirSync(dirname(binaryPath), { recursive: true });
writeFileSync(binaryPath, 'binary');
return { releaseDir, binaryPath };
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe('verifyMacArchitecture', () => {
it('accepts x64 binaries when lipo reports x86_64', () => {
const { releaseDir, binaryPath } = createReleaseBinary();
expect(
verifyMacArchitecture({
releaseDir,
expectedArch: 'x64',
inspectBinaryArchs: () => 'x86_64',
}),
).toEqual([{ archs: 'x86_64', binaryPath }]);
});
it('rejects x64 binaries when lipo reports arm64', () => {
const { releaseDir } = createReleaseBinary();
expect(() =>
verifyMacArchitecture({
releaseDir,
expectedArch: 'x64',
inspectBinaryArchs: () => 'arm64',
}),
).toThrow(/Expected x86_64-only binary but got: arm64/);
});
});