mirror of
https://github.com/aipoch/open-science.git
synced 2026-10-02 02:14:40 +08:00
feat(linux): add native ARM64 distribution support (#3106)
* feat(linux): add native ARM64 distribution support * fix(linux): complete ARM64 feed promotion and web packaging * test(linux): cover ARM64 channel promotion fixtures * fix(release): bootstrap the first ARM64 update channel * test(release): include ARM64 bootstrap in workflow contract * ci(runtime): gate PRs on ARM64 bundle readiness
This commit is contained in:
@@ -38,7 +38,7 @@ on:
|
||||
default: false
|
||||
platform_name:
|
||||
description: >-
|
||||
If set, emit only this matrix name (macos-arm64, macos-x64, linux-x64, or windows-x64).
|
||||
If set, emit only this matrix name (macos-arm64, macos-x64, linux-x64, linux-arm64, or windows-x64).
|
||||
Used by the Nightly macos-x64 packaging dry-run; release/nightly leave it empty.
|
||||
type: string
|
||||
default: ''
|
||||
@@ -278,6 +278,7 @@ jobs:
|
||||
]'
|
||||
rest='[
|
||||
{"name":"linux-x64","os":"ubuntu-latest","platform":"linux","eb_args":"--linux","engine_keep":".so.node","subdir":"linux-64","bin_os":"linux","bin_arch":"x64"},
|
||||
{"name":"linux-arm64","os":"ubuntu-24.04-arm","platform":"linux","eb_args":"--linux --arm64","engine_keep":"linux-arm64-openssl-3.0.x.so.node","subdir":"linux-aarch64","bin_os":"linux","bin_arch":"arm64"},
|
||||
{"name":"windows-x64","os":"windows-latest","platform":"win","eb_args":"--win --x64","engine_keep":"windows.dll","subdir":"win-64","bin_os":"win","bin_arch":"x64"}
|
||||
]'
|
||||
if [ "${{ inputs.mac_only }}" = "true" ]; then
|
||||
@@ -369,8 +370,8 @@ jobs:
|
||||
OPEN_SCIENCE_ENV_CDN_BASE: https://statics.aipoch.com/open-science
|
||||
run: node scripts/verify-runtime-bundle.mjs "${{ matrix.subdir }}"
|
||||
|
||||
# Keep the engines this platform ships: Linux needs both Debian and RHEL OpenSSL 3 engines
|
||||
# for the portable AppImage; macOS/Windows keep one. Fail if no matching engine was generated.
|
||||
# Keep the engines this platform ships: Linux x64 needs Debian and RHEL OpenSSL 3 engines;
|
||||
# Linux ARM64 uses one generic OpenSSL 3 engine. macOS/Windows also keep one. Fail if no matching engine was generated.
|
||||
# The glob is `*query_engine-*` (not `libquery_engine-*`) so it also matches Windows' engine,
|
||||
# which is named `query_engine-windows.dll.node` without the `lib` prefix.
|
||||
- name: Prune foreign Prisma engines
|
||||
|
||||
@@ -16,6 +16,11 @@ on:
|
||||
type: choice
|
||||
options: [backfill, promote]
|
||||
default: backfill
|
||||
bootstrap_linux_arm64:
|
||||
description: 'First ARM64 promotion only: allow a confirmed missing ARM64 feed (requires promote)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
dry_run:
|
||||
description: 'Run local release transforms without AWS credentials or uploads'
|
||||
required: false
|
||||
@@ -79,7 +84,7 @@ jobs:
|
||||
--jq '.assets[] | [.name, (.size | tostring)] | @tsv' |
|
||||
while IFS=$'\t' read -r name size; do
|
||||
case "$name" in
|
||||
*-mac-arm64.dmg|*-mac-x64.dmg|*-win-x64-setup.exe|*-linux-x64.AppImage|*-linux-x86_64.AppImage|*_amd64.deb|*-mac-arm64.zip|*-mac-x64.zip)
|
||||
*-mac-arm64.dmg|*-mac-x64.dmg|*-win-x64-setup.exe|*-linux-x64.AppImage|*-linux-x86_64.AppImage|*-linux-arm64.AppImage|*_amd64.deb|*_arm64.deb|*-mac-arm64.zip|*-mac-x64.zip)
|
||||
[ "$name" = "$(basename "$name")" ] || { echo "Unsafe asset name: $name" >&2; exit 1; }
|
||||
truncate -s "$size" "dist-assets/$name"
|
||||
;;
|
||||
@@ -168,7 +173,7 @@ jobs:
|
||||
VERSION: ${{ steps.ref.outputs.version }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for yml in dist-assets/latest.yml dist-assets/latest-linux.yml dist-assets/*-mac.yml; do
|
||||
for yml in dist-assets/latest.yml dist-assets/latest-linux.yml dist-assets/latest-linux-arm64.yml dist-assets/*-mac.yml; do
|
||||
[ -f "$yml" ] || continue
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
@@ -193,7 +198,7 @@ jobs:
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
feeds=()
|
||||
for yml in dist-assets/latest.yml dist-assets/latest-linux.yml dist-assets/*-mac.yml; do
|
||||
for yml in dist-assets/latest.yml dist-assets/latest-linux.yml dist-assets/latest-linux-arm64.yml dist-assets/*-mac.yml; do
|
||||
[ -f "$yml" ] && feeds+=("$yml")
|
||||
done
|
||||
if [ ${#feeds[@]} -eq 0 ]; then
|
||||
@@ -246,4 +251,5 @@ jobs:
|
||||
S3_PREFIX: ${{ vars.S3_PREFIX }}
|
||||
VERSION: ${{ steps.ref.outputs.version }}
|
||||
MODE: ${{ inputs.mode }}
|
||||
BOOTSTRAP_LINUX_ARM64: ${{ inputs.bootstrap_linux_arm64 }}
|
||||
run: node scripts/publish-update-channel.mjs
|
||||
|
||||
@@ -20,6 +20,7 @@ on:
|
||||
- runtime-source
|
||||
- macos-x64
|
||||
- linux-cli
|
||||
- linux-arm64
|
||||
- windows-package
|
||||
|
||||
# Build and publication preparation run without write access. Only the separate workflow_run
|
||||
@@ -29,7 +30,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nightly-build-${{ github.event_name }}${{ inputs.dry_run == 'linux-cli' && '-linux-cli' || inputs.dry_run == 'windows-package' && '-windows-package' || '' }}
|
||||
group: nightly-build-${{ github.event_name }}${{ inputs.dry_run == 'linux-arm64' && '-linux-arm64' || inputs.dry_run == 'linux-cli' && '-linux-cli' || inputs.dry_run == 'windows-package' && '-windows-package' || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -71,8 +72,8 @@ jobs:
|
||||
# A manual `macos-x64` dispatch runs only the Intel packaging job (the heap-sensitive Vite
|
||||
# renderer build) and skips verify. Linux/Windows package dry-runs build and smoke-test
|
||||
# only their selected platform; certification and publication preparation stay skipped.
|
||||
skip_verify: ${{ inputs.dry_run == 'macos-x64' || inputs.dry_run == 'linux-cli' || inputs.dry_run == 'windows-package' }}
|
||||
platform_name: ${{ inputs.dry_run == 'macos-x64' && 'macos-x64' || inputs.dry_run == 'linux-cli' && 'linux-x64' || inputs.dry_run == 'windows-package' && 'windows-x64' || '' }}
|
||||
skip_verify: ${{ inputs.dry_run == 'macos-x64' || inputs.dry_run == 'linux-cli' || inputs.dry_run == 'linux-arm64' || inputs.dry_run == 'windows-package' }}
|
||||
platform_name: ${{ inputs.dry_run == 'macos-x64' && 'macos-x64' || inputs.dry_run == 'linux-arm64' && 'linux-arm64' || inputs.dry_run == 'linux-cli' && 'linux-x64' || inputs.dry_run == 'windows-package' && 'windows-x64' || '' }}
|
||||
|
||||
# Scheduled coverage is advisory for the build only: it stays absent from prepare.needs so artifacts
|
||||
# and evidence remain diagnosable, but nightly-publish.yml refuses to roll the tag unless every
|
||||
@@ -80,7 +81,7 @@ jobs:
|
||||
# job as a blocking focused dry-run while build/package/publish preparation stay skipped.
|
||||
runtime-certification:
|
||||
needs: plan
|
||||
if: needs.plan.outputs.should_build == 'true' && inputs.dry_run != 'macos-x64' && inputs.dry_run != 'linux-cli' && inputs.dry_run != 'windows-package'
|
||||
if: needs.plan.outputs.should_build == 'true' && inputs.dry_run != 'macos-x64' && inputs.dry_run != 'linux-cli' && inputs.dry_run != 'linux-arm64' && inputs.dry_run != 'windows-package'
|
||||
uses: ./.github/workflows/runtime-certification.yml
|
||||
with:
|
||||
# Scheduled failures keep the build green so prepare still runs; publication gates on them.
|
||||
@@ -91,14 +92,14 @@ jobs:
|
||||
if: inputs.dry_run != 'macos-x64'
|
||||
uses: ./.github/workflows/package-smoke.yml
|
||||
with:
|
||||
platform_name: ${{ inputs.dry_run == 'linux-cli' && 'linux-x64' || inputs.dry_run == 'windows-package' && 'windows-x64' || '' }}
|
||||
platform_name: ${{ inputs.dry_run == 'linux-arm64' && 'linux-arm64' || inputs.dry_run == 'linux-cli' && 'linux-x64' || inputs.dry_run == 'windows-package' && 'windows-x64' || '' }}
|
||||
|
||||
# Run desktop behavior checks inside the same read-only Nightly run. Failures keep the build green
|
||||
# and independently rerunnable so artifacts stay diagnosable, but nightly-publish.yml refuses to
|
||||
# roll the tag unless every regression job succeeded.
|
||||
regression:
|
||||
needs: [build, package-smoke]
|
||||
if: inputs.dry_run != 'linux-cli' && inputs.dry_run != 'windows-package'
|
||||
if: inputs.dry_run != 'linux-cli' && inputs.dry_run != 'linux-arm64' && inputs.dry_run != 'windows-package'
|
||||
uses: ./.github/workflows/desktop-regression.yml
|
||||
with:
|
||||
# Advisory for the build only; publication gates on the real job conclusions.
|
||||
@@ -110,7 +111,7 @@ jobs:
|
||||
prepare:
|
||||
name: Prepare nightly publish artifact
|
||||
needs: [plan, build, package-smoke]
|
||||
if: needs.build.result == 'success' && needs.package-smoke.result == 'success' && inputs.dry_run != 'linux-cli' && inputs.dry_run != 'windows-package'
|
||||
if: needs.build.result == 'success' && needs.package-smoke.result == 'success' && inputs.dry_run != 'linux-cli' && inputs.dry_run != 'linux-arm64' && inputs.dry_run != 'windows-package'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -47,6 +47,7 @@ on:
|
||||
- macos-x64
|
||||
- macos-arm64
|
||||
- linux-x64
|
||||
- linux-arm64
|
||||
- windows-x64
|
||||
- all
|
||||
|
||||
|
||||
@@ -566,7 +566,7 @@ jobs:
|
||||
# changes select the overlay lane; full plans keep fail-closed CDN coverage.
|
||||
if: ${{ contains(fromJSON(needs.preflight.outputs.plan).lanes, 'runtime_bundle') || fromJSON(needs.preflight.outputs.plan).mode == 'full' }}
|
||||
continue-on-error: true
|
||||
run: node scripts/verify-runtime-bundle.mjs linux-64 osx-arm64 osx-64 win-64
|
||||
run: node scripts/verify-runtime-bundle.mjs linux-64 linux-aarch64 osx-arm64 osx-64 win-64
|
||||
- name: Setup Node 22 for CLI compatibility
|
||||
if: ${{ contains(fromJSON(needs.preflight.outputs.plan).lanes, 'cli_sdk') }}
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
|
||||
@@ -200,6 +200,7 @@ jobs:
|
||||
artifacts/RELEASE-CERTIFICATION.json
|
||||
artifacts/latest.yml
|
||||
artifacts/latest-linux.yml
|
||||
artifacts/latest-linux-arm64.yml
|
||||
artifacts/*-mac.yml
|
||||
artifacts/*.blockmap
|
||||
|
||||
|
||||
@@ -23,9 +23,17 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
source:
|
||||
name: Source runtime chain (Linux)
|
||||
name: Source runtime chain (${{ matrix.subdir }})
|
||||
continue-on-error: ${{ inputs.allow_failure == true }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- subdir: linux-64
|
||||
os: ubuntu-latest
|
||||
- subdir: linux-aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -44,6 +52,31 @@ jobs:
|
||||
- name: Install test dependencies
|
||||
run: node scripts/ci/npm-ci.mjs
|
||||
|
||||
- name: Install Linux sandbox dependency
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes apparmor-profiles apparmor-utils bubblewrap
|
||||
|
||||
apparmor_userns_restriction="$(
|
||||
sysctl -n kernel.apparmor_restrict_unprivileged_userns 2>/dev/null || true
|
||||
)"
|
||||
if [[ "$apparmor_userns_restriction" == "1" ]]; then
|
||||
profile_source=/usr/share/apparmor/extra-profiles/bwrap-userns-restrict
|
||||
test -r "$profile_source"
|
||||
sudo install -m 0644 "$profile_source" /etc/apparmor.d/bwrap-userns-restrict
|
||||
sudo apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict
|
||||
fi
|
||||
|
||||
bwrap --unshare-all --ro-bind / / -- /bin/true
|
||||
- name: Test real Linux filesystem and network isolation
|
||||
run: >-
|
||||
npx vitest run --project process
|
||||
packages/notebook-network-sandbox/src/filesystem-enforcement.integration.test.ts
|
||||
packages/notebook-network-sandbox/src/network-enforcement.integration.test.ts
|
||||
src/main/session-plan/plan-context-file.shell.integration.test.ts
|
||||
|
||||
# Use the same pinned, digest-verified micromamba downloader as the packaged runtime. These are
|
||||
# ephemeral source-certification environments, not the immutable CDN packs certified later at
|
||||
# the packaged boundary.
|
||||
@@ -52,7 +85,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin="$RUNNER_TEMP/micromamba"
|
||||
node scripts/fetch-micromamba.mjs linux-64 "$bin"
|
||||
node scripts/fetch-micromamba.mjs "${{ matrix.subdir }}" "$bin"
|
||||
echo "MICROMAMBA_BIN=$bin" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create real Python and R environments
|
||||
|
||||
@@ -19,6 +19,15 @@ on:
|
||||
required: true
|
||||
default: '2'
|
||||
type: string
|
||||
subdir:
|
||||
description: Stage one platform, or all existing platforms
|
||||
type: choice
|
||||
default: all
|
||||
options: [all, osx-arm64, osx-64, linux-64, linux-aarch64, win-64]
|
||||
dry_run:
|
||||
description: Validate and upload workflow artifacts without writing to the CDN
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# Read-only on the repo; writes go to S3, not back to git.
|
||||
permissions:
|
||||
@@ -32,31 +41,37 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- id: matrix
|
||||
shell: bash
|
||||
env:
|
||||
SUBDIR: ${{ inputs.subdir }}
|
||||
run: |
|
||||
platforms='[
|
||||
{"subdir":"osx-arm64","os":"macos-14","runner_subdir":"osx-arm64"},
|
||||
{"subdir":"osx-64","os":"macos-14","runner_subdir":"osx-arm64"},
|
||||
{"subdir":"linux-64","os":"ubuntu-latest","runner_subdir":"linux-64"},
|
||||
{"subdir":"linux-aarch64","os":"ubuntu-24.04-arm","runner_subdir":"linux-aarch64"},
|
||||
{"subdir":"win-64","os":"windows-latest","runner_subdir":"win-64"}
|
||||
]'
|
||||
include=$(jq -c --arg subdir "$SUBDIR" '[.[] | select($subdir == "all" or .subdir == $subdir)]' <<<"$platforms")
|
||||
[ "$include" != '[]' ] || { echo '::error::unknown runtime subdir'; exit 1; }
|
||||
echo "matrix={\"include\":$include}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
stage:
|
||||
name: Stage ${{ matrix.subdir }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Observed 2–6 min per subdir; the ceiling leaves room for a slow conda solve or CDN upload.
|
||||
timeout-minutes: 60
|
||||
needs: setup
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# subdir = conda platform to SOLVE for; runner_subdir = the runner's native arch, whose
|
||||
# micromamba we run. They differ only for osx-64: Intel macOS runners (macos-13) are scarce, so
|
||||
# osx-64 is cross-solved on Apple-silicon (macos-14) — the dry-run solve never runs foreign-arch
|
||||
# binaries, so this is safe (see stage-default-envs.mjs).
|
||||
include:
|
||||
- subdir: osx-arm64
|
||||
os: macos-14
|
||||
runner_subdir: osx-arm64
|
||||
- subdir: osx-64
|
||||
os: macos-14
|
||||
runner_subdir: osx-arm64
|
||||
- subdir: linux-64
|
||||
os: ubuntu-latest
|
||||
runner_subdir: linux-64
|
||||
- subdir: win-64
|
||||
os: windows-latest
|
||||
runner_subdir: win-64
|
||||
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
@@ -131,7 +146,16 @@ jobs:
|
||||
echo "version=$requested" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle version = $requested"
|
||||
|
||||
- name: Preserve staged bundle for review
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: runtime-bundle-${{ matrix.subdir }}
|
||||
path: resources/default-envs
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Configure AWS credentials
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
|
||||
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
|
||||
@@ -150,6 +174,7 @@ jobs:
|
||||
# the manifest so consumers never observe a manifest that points to a missing object.
|
||||
# Bucket stays masked (secret); --only-show-errors avoids leaking the s3:// path.
|
||||
- name: Upload bundle to CDN
|
||||
if: ${{ !inputs.dry_run }}
|
||||
shell: bash
|
||||
env:
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { delimiter, dirname, join } from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const feeds = ['latest.yml', 'latest-linux.yml', 'latest-mac.yml', 'arm64-mac.yml', 'x64-mac.yml']
|
||||
const feeds = [
|
||||
'latest.yml',
|
||||
'latest-linux.yml',
|
||||
'latest-linux-arm64.yml',
|
||||
'latest-mac.yml',
|
||||
'arm64-mac.yml',
|
||||
'x64-mac.yml'
|
||||
]
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
@@ -20,7 +27,7 @@ function fixture(): {
|
||||
current: (version: string) => void
|
||||
stage: (version: string) => void
|
||||
snapshot: () => Record<string, string>
|
||||
run: (version: string, mode?: string, failKey?: string) => void
|
||||
run: (version: string, mode?: string, failKey?: string, bootstrap?: boolean) => void
|
||||
} {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mirror-channel-test-'))
|
||||
roots.push(root)
|
||||
@@ -41,6 +48,7 @@ const path = require('node:path');
|
||||
const [service, operation, source, destination] = process.argv.slice(2);
|
||||
if (service === 's3api' && operation === 'head-object') {
|
||||
const args = process.argv.slice(2);
|
||||
if (process.env.FAIL_KEY.startsWith('head:')) { process.stderr.write(process.env.FAIL_KEY.slice(5)); process.exit(1); }
|
||||
const file = path.join(process.env.FIXTURE_STORAGE, args[args.indexOf('--bucket') + 1], args[args.indexOf('--key') + 1]);
|
||||
if (!fs.existsSync(file)) { process.stderr.write('An error occurred (404): Not Found'); process.exit(1); }
|
||||
process.exit(0);
|
||||
@@ -77,7 +85,7 @@ else {
|
||||
Object.fromEntries(
|
||||
['version.json', ...feeds].map((name) => [name, readFileSync(join(channel, name), 'utf8')])
|
||||
)
|
||||
const run = (version: string, mode = 'backfill', failKey = ''): void => {
|
||||
const run = (version: string, mode = 'backfill', failKey = '', bootstrap = false): void => {
|
||||
const workflow = load(readFileSync('.github/workflows/mirror-to-website.yml', 'utf8')) as {
|
||||
jobs: { mirror: { steps: Array<{ name: string; run?: string }> } }
|
||||
}
|
||||
@@ -97,6 +105,7 @@ else {
|
||||
S3_PREFIX: 'stable',
|
||||
VERSION: version,
|
||||
MODE: mode,
|
||||
BOOTSTRAP_LINUX_ARM64: String(bootstrap),
|
||||
FAIL_KEY: failKey
|
||||
},
|
||||
// macOS Bash treats a piped stdin as a remote shell and can source .bashrc.
|
||||
@@ -184,6 +193,83 @@ describe.skipIf(process.platform === 'win32')('website channel publication', ()
|
||||
f.run('2.1.0', 'promote')
|
||||
expect(f.snapshot()).toEqual(before)
|
||||
})
|
||||
it('does not downgrade a newer ARM64 feed before the version manifest catches up', () => {
|
||||
const f = fixture()
|
||||
writeFileSync(join(f.channel, 'latest-linux-arm64.yml'), 'version: 3.0.0\n')
|
||||
const before = f.snapshot()
|
||||
f.stage('2.1.0')
|
||||
f.run('2.1.0', 'promote')
|
||||
expect(f.snapshot()).toEqual(before)
|
||||
})
|
||||
it('requires the new ARM64 channel to be initialized before first promotion', () => {
|
||||
const f = fixture()
|
||||
f.stage('2.1.0')
|
||||
rmSync(join(f.channel, 'latest-linux-arm64.yml'))
|
||||
const previousManifest = readFileSync(join(f.channel, 'version.json'), 'utf8')
|
||||
expect(() => f.run('2.1.0', 'promote')).toThrow()
|
||||
expect(readFileSync(join(f.channel, 'version.json'), 'utf8')).toBe(previousManifest)
|
||||
})
|
||||
it('explicitly bootstraps an absent ARM64 feed and can retry the same promotion', () => {
|
||||
const f = fixture()
|
||||
f.stage('2.1.0')
|
||||
rmSync(join(f.channel, 'latest-linux-arm64.yml'))
|
||||
f.run('2.1.0', 'promote', '', true)
|
||||
const promoted = f.snapshot()
|
||||
for (const name of feeds)
|
||||
expect((load(promoted[name]) as { version: string }).version).toBe('2.1.0')
|
||||
expect(JSON.parse(promoted['version.json']).version).toBe('2.1.0')
|
||||
f.run('2.1.0', 'promote', '', true)
|
||||
expect(f.snapshot()).toEqual(promoted)
|
||||
})
|
||||
it.each(['head:An error occurred (403): Forbidden', 'head:Connection timed out'])(
|
||||
'does not interpret %s as permission to bootstrap',
|
||||
(failure) => {
|
||||
const f = fixture(),
|
||||
before = f.snapshot()
|
||||
f.stage('2.1.0')
|
||||
expect(() => f.run('2.1.0', 'promote', failure, true)).toThrow()
|
||||
expect(f.snapshot()).toEqual(before)
|
||||
}
|
||||
)
|
||||
it('rejects malformed existing ARM64 metadata even with bootstrap enabled', () => {
|
||||
const f = fixture()
|
||||
f.stage('2.1.0')
|
||||
writeFileSync(join(f.channel, 'latest-linux-arm64.yml'), 'version: invalid\n')
|
||||
const before = f.snapshot()
|
||||
expect(() => f.run('2.1.0', 'promote', '', true)).toThrow()
|
||||
expect(f.snapshot()).toEqual(before)
|
||||
})
|
||||
it('does not bootstrap when another channel pointer is missing', () => {
|
||||
const f = fixture()
|
||||
f.stage('2.1.0')
|
||||
rmSync(join(f.channel, 'latest-linux-arm64.yml'))
|
||||
rmSync(join(f.channel, 'latest-linux.yml'))
|
||||
const before = readFileSync(join(f.channel, 'version.json'), 'utf8')
|
||||
expect(() => f.run('2.1.0', 'promote', '', true)).toThrow()
|
||||
expect(readFileSync(join(f.channel, 'version.json'), 'utf8')).toBe(before)
|
||||
expect(() => readFileSync(join(f.channel, 'latest-linux-arm64.yml'))).toThrow()
|
||||
})
|
||||
it('keeps bootstrap behind explicit promotion intent', () => {
|
||||
const f = fixture(),
|
||||
before = f.snapshot()
|
||||
f.stage('2.1.0')
|
||||
expect(() => f.run('2.1.0', 'backfill', '', true)).toThrow()
|
||||
expect(f.snapshot()).toEqual(before)
|
||||
})
|
||||
it('repairs an interrupted first ARM64 promotion without allowing a downgrade', () => {
|
||||
const f = fixture()
|
||||
f.stage('3.0.0')
|
||||
rmSync(join(f.channel, 'latest-linux-arm64.yml'))
|
||||
expect(() => f.run('3.0.0', 'promote', 'version.json', true)).toThrow()
|
||||
const partial = f.snapshot()
|
||||
expect((load(partial['latest-linux-arm64.yml']) as { version: string }).version).toBe('3.0.0')
|
||||
f.stage('2.1.0')
|
||||
f.run('2.1.0', 'promote', '', true)
|
||||
expect(f.snapshot()).toEqual(partial)
|
||||
f.stage('3.0.0')
|
||||
f.run('3.0.0', 'promote', '', true)
|
||||
expect(JSON.parse(f.snapshot()['version.json']).version).toBe('3.0.0')
|
||||
})
|
||||
it('rejects successful uploads whose channel readback differs', () => {
|
||||
const f = fixture()
|
||||
f.stage('2.1.0')
|
||||
@@ -191,11 +277,11 @@ describe.skipIf(process.platform === 'win32')('website channel publication', ()
|
||||
f.run('2.1.0', 'promote', 'corrupt:s3://fixture-bucket/stable/latest.yml')
|
||||
).toThrow(/Publication readback failed/)
|
||||
})
|
||||
it('rejects a promotion with a missing platform before channel writes', () => {
|
||||
it.each(feeds)('rejects a promotion missing %s before channel writes', (feed) => {
|
||||
const f = fixture(),
|
||||
before = f.snapshot()
|
||||
f.stage('2.1.0')
|
||||
rmSync(join(f.root, 'dist-assets', 'latest-mac.yml'))
|
||||
rmSync(join(f.root, 'dist-assets', feed))
|
||||
expect(() => f.run('2.1.0', 'promote')).toThrow()
|
||||
expect(f.snapshot()).toEqual(before)
|
||||
})
|
||||
@@ -209,18 +295,21 @@ describe.skipIf(process.platform === 'win32')('website channel publication', ()
|
||||
expect(() => f.run('2.1.0', 'promote')).toThrow()
|
||||
expect(readFileSync(join(f.channel, 'latest.yml'), 'utf8')).toBe(previousFeed)
|
||||
})
|
||||
it('repairs a partial upload by retrying the same version and prevents an intervening downgrade', () => {
|
||||
const f = fixture()
|
||||
f.stage('3.0.0')
|
||||
expect(() => f.run('3.0.0', 'promote', 'latest-linux.yml')).toThrow()
|
||||
const partial = f.snapshot()
|
||||
expect(JSON.parse(partial['version.json']).version).toBe('2.0.0')
|
||||
expect((load(partial['arm64-mac.yml']) as { version: string }).version).toBe('3.0.0')
|
||||
f.stage('2.1.0')
|
||||
f.run('2.1.0', 'promote')
|
||||
expect(f.snapshot()).toEqual(partial)
|
||||
f.stage('3.0.0')
|
||||
f.run('3.0.0', 'promote')
|
||||
expect(JSON.parse(f.snapshot()['version.json']).version).toBe('3.0.0')
|
||||
})
|
||||
it.each(['latest-linux.yml', 'latest-linux-arm64.yml'])(
|
||||
'repairs an interrupted %s upload and prevents an intervening downgrade',
|
||||
(feed) => {
|
||||
const f = fixture()
|
||||
f.stage('3.0.0')
|
||||
expect(() => f.run('3.0.0', 'promote', feed)).toThrow()
|
||||
const partial = f.snapshot()
|
||||
expect(JSON.parse(partial['version.json']).version).toBe('2.0.0')
|
||||
expect((load(partial['arm64-mac.yml']) as { version: string }).version).toBe('3.0.0')
|
||||
f.stage('2.1.0')
|
||||
f.run('2.1.0', 'promote')
|
||||
expect(f.snapshot()).toEqual(partial)
|
||||
f.stage('3.0.0')
|
||||
f.run('3.0.0', 'promote')
|
||||
expect(JSON.parse(f.snapshot()['version.json']).version).toBe('3.0.0')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1047,7 +1047,7 @@ describe('PR Gate workflow', () => {
|
||||
expect(runtimeBundle).toMatchObject({
|
||||
id: 'runtime_bundle',
|
||||
'continue-on-error': true,
|
||||
run: 'node scripts/verify-runtime-bundle.mjs linux-64 osx-arm64 osx-64 win-64'
|
||||
run: 'node scripts/verify-runtime-bundle.mjs linux-64 linux-aarch64 osx-arm64 osx-64 win-64'
|
||||
})
|
||||
expect(runtimeBundle?.if).toContain("'runtime_bundle'")
|
||||
expect(runtimeBundle?.if).toContain("fromJSON(needs.preflight.outputs.plan).mode == 'full'")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const PLATFORMS = ['linux-x64', 'macos-arm64', 'macos-x64', 'windows-x64']
|
||||
const PLATFORMS = ['linux-x64', 'linux-arm64', 'macos-arm64', 'macos-x64', 'windows-x64']
|
||||
const DISTRIBUTABLE = /\.(?:AppImage|deb|dmg|exe|zip)$/
|
||||
const CHECK_STATES = ['passed', 'not-applicable']
|
||||
const DATABASE_BASELINE_ID = '0001_runtime_schema_baseline'
|
||||
@@ -226,7 +226,7 @@ const aggregateEvidence = async ({ argv }) => {
|
||||
|
||||
const names = (await readdir(directory))
|
||||
.filter((name) =>
|
||||
/^certification-(?:linux-x64|macos-arm64|macos-x64|windows-x64)\.json$/.test(name)
|
||||
/^certification-(?:linux-x64|linux-arm64|macos-arm64|macos-x64|windows-x64)\.json$/.test(name)
|
||||
)
|
||||
.sort()
|
||||
const records = await Promise.all(
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
writeWindowsUpdateEvidence
|
||||
} from './release-certification-evidence.mjs'
|
||||
|
||||
const platforms = ['linux-x64', 'macos-arm64', 'macos-x64', 'windows-x64']
|
||||
const platforms = ['linux-x64', 'linux-arm64', 'macos-arm64', 'macos-x64', 'windows-x64']
|
||||
|
||||
describe('release certification evidence', () => {
|
||||
it('hashes only user-facing distributables in stable order', async () => {
|
||||
|
||||
@@ -306,7 +306,7 @@ describe('release and scheduled workflow topology', () => {
|
||||
expect(nightly.permissions).toEqual({ actions: 'read', contents: 'read' })
|
||||
expect(nightly.concurrency).toEqual({
|
||||
group:
|
||||
"nightly-build-${{ github.event_name }}${{ inputs.dry_run == 'linux-cli' && '-linux-cli' || inputs.dry_run == 'windows-package' && '-windows-package' || '' }}",
|
||||
"nightly-build-${{ github.event_name }}${{ inputs.dry_run == 'linux-arm64' && '-linux-arm64' || inputs.dry_run == 'linux-cli' && '-linux-cli' || inputs.dry_run == 'windows-package' && '-windows-package' || '' }}",
|
||||
'cancel-in-progress': true
|
||||
})
|
||||
expect(nightly.jobs.build).toMatchObject({
|
||||
@@ -316,9 +316,9 @@ describe('release and scheduled workflow topology', () => {
|
||||
with: {
|
||||
nightly: true,
|
||||
skip_verify:
|
||||
"${{ inputs.dry_run == 'macos-x64' || inputs.dry_run == 'linux-cli' || inputs.dry_run == 'windows-package' }}",
|
||||
"${{ inputs.dry_run == 'macos-x64' || inputs.dry_run == 'linux-cli' || inputs.dry_run == 'linux-arm64' || inputs.dry_run == 'windows-package' }}",
|
||||
platform_name:
|
||||
"${{ inputs.dry_run == 'macos-x64' && 'macos-x64' || inputs.dry_run == 'linux-cli' && 'linux-x64' || inputs.dry_run == 'windows-package' && 'windows-x64' || '' }}"
|
||||
"${{ inputs.dry_run == 'macos-x64' && 'macos-x64' || inputs.dry_run == 'linux-arm64' && 'linux-arm64' || inputs.dry_run == 'linux-cli' && 'linux-x64' || inputs.dry_run == 'windows-package' && 'windows-x64' || '' }}"
|
||||
}
|
||||
})
|
||||
expect(nightly.jobs.plan.outputs).toEqual({
|
||||
@@ -336,7 +336,14 @@ describe('release and scheduled workflow topology', () => {
|
||||
}
|
||||
expect(dispatch.inputs?.dry_run).toMatchObject({
|
||||
default: 'full',
|
||||
options: ['full', 'runtime-source', 'macos-x64', 'linux-cli', 'windows-package']
|
||||
options: [
|
||||
'full',
|
||||
'runtime-source',
|
||||
'macos-x64',
|
||||
'linux-cli',
|
||||
'linux-arm64',
|
||||
'windows-package'
|
||||
]
|
||||
})
|
||||
// Package dry-runs must exercise the produced installer without requesting signing or
|
||||
// falling back to the setup-only/install-only paths that never launch the package.
|
||||
@@ -351,15 +358,15 @@ describe('release and scheduled workflow topology', () => {
|
||||
expect(nightly.jobs['package-smoke'].if).toBe("inputs.dry_run != 'macos-x64'")
|
||||
expect(nightly.jobs['package-smoke'].with).toEqual({
|
||||
platform_name:
|
||||
"${{ inputs.dry_run == 'linux-cli' && 'linux-x64' || inputs.dry_run == 'windows-package' && 'windows-x64' || '' }}"
|
||||
"${{ inputs.dry_run == 'linux-arm64' && 'linux-arm64' || inputs.dry_run == 'linux-cli' && 'linux-x64' || inputs.dry_run == 'windows-package' && 'windows-x64' || '' }}"
|
||||
})
|
||||
expect(nightly.jobs.regression.if).toBe(
|
||||
"inputs.dry_run != 'linux-cli' && inputs.dry_run != 'windows-package'"
|
||||
"inputs.dry_run != 'linux-cli' && inputs.dry_run != 'linux-arm64' && inputs.dry_run != 'windows-package'"
|
||||
)
|
||||
expect(nightly.jobs['runtime-certification'].if).toContain("inputs.dry_run != 'macos-x64'")
|
||||
expect(prepare).toMatchObject({
|
||||
needs: ['plan', 'build', 'package-smoke'],
|
||||
if: "needs.build.result == 'success' && needs.package-smoke.result == 'success' && inputs.dry_run != 'linux-cli' && inputs.dry_run != 'windows-package'",
|
||||
if: "needs.build.result == 'success' && needs.package-smoke.result == 'success' && inputs.dry_run != 'linux-cli' && inputs.dry_run != 'linux-arm64' && inputs.dry_run != 'windows-package'",
|
||||
'runs-on': 'ubuntu-latest'
|
||||
})
|
||||
expect(step(prepare, 'Aggregate release certification evidence').run).toContain(
|
||||
@@ -752,13 +759,18 @@ describe('website mirror publication intent', () => {
|
||||
'cancel-in-progress': false
|
||||
})
|
||||
const dispatch = mirror.on?.workflow_dispatch as {
|
||||
inputs: { mode: { default: string; options: string[] } }
|
||||
inputs: {
|
||||
mode: { default: string; options: string[] }
|
||||
bootstrap_linux_arm64: { default: boolean; type: string }
|
||||
}
|
||||
}
|
||||
expect(dispatch.inputs.mode).toMatchObject({
|
||||
default: 'backfill',
|
||||
options: ['backfill', 'promote']
|
||||
})
|
||||
const publication = step(mirror.jobs.mirror, 'Sync installers to versioned path')
|
||||
expect(dispatch.inputs.bootstrap_linux_arm64).toMatchObject({ default: false, type: 'boolean' })
|
||||
expect(publication.env?.BOOTSTRAP_LINUX_ARM64).toBe('${{ inputs.bootstrap_linux_arm64 }}')
|
||||
expect(publication.env?.MODE).toBe('${{ inputs.mode }}')
|
||||
expect(publication.if).toBe('${{ !inputs.dry_run }}')
|
||||
expect(publication.run).toBe('node scripts/publish-update-channel.mjs')
|
||||
|
||||
@@ -7,6 +7,7 @@ export const PACKAGE_SMOKE_PLATFORMS = [
|
||||
{ name: 'macos-arm64', os: 'macos-26', platform: 'mac' },
|
||||
{ name: 'macos-x64', os: 'macos-26-intel', platform: 'mac' },
|
||||
{ name: 'linux-x64', os: 'ubuntu-latest', platform: 'linux' },
|
||||
{ name: 'linux-arm64', os: 'ubuntu-24.04-arm', platform: 'linux' },
|
||||
{ name: 'windows-x64', os: 'windows-latest', platform: 'win' }
|
||||
]
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
const scriptPath = fileURLToPath(new URL('./resolve-package-smoke-matrix.mjs', import.meta.url))
|
||||
|
||||
describe('package-smoke matrix resolution', () => {
|
||||
it('selects a native ARM64 Linux runner', () => {
|
||||
expect(JSON.parse(resolvePackageSmokeMatrix('linux-arm64'))).toEqual({
|
||||
include: [{ name: 'linux-arm64', os: 'ubuntu-24.04-arm', platform: 'linux' }]
|
||||
})
|
||||
})
|
||||
it('emits a single-line GitHub Actions matrix for the unfiltered Nightly path', () => {
|
||||
const encoded = resolvePackageSmokeMatrix('')
|
||||
expect(encoded).not.toMatch(/\n|\r/)
|
||||
|
||||
@@ -40,7 +40,7 @@ const step = (job: Job, name: string): Step => {
|
||||
}
|
||||
|
||||
describe('runtime certification workflow', () => {
|
||||
it('provides one reusable, manually dispatchable, read-only Linux source lane', () => {
|
||||
it('provides native reusable, manually dispatchable, read-only Linux source lanes', () => {
|
||||
const runtime = workflow('runtime-certification.yml')
|
||||
const source = runtime.jobs.source
|
||||
|
||||
@@ -51,10 +51,26 @@ describe('runtime certification workflow', () => {
|
||||
group: 'runtime-certification-${{ github.workflow }}-${{ github.ref }}',
|
||||
'cancel-in-progress': true
|
||||
})
|
||||
expect(source).toMatchObject({
|
||||
strategy: {
|
||||
matrix: {
|
||||
include: [
|
||||
{ subdir: 'linux-64', os: 'ubuntu-latest' },
|
||||
{ subdir: 'linux-aarch64', os: 'ubuntu-24.04-arm' }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(step(source, 'Test real Linux filesystem and network isolation').run).toContain(
|
||||
'network-enforcement.integration.test.ts'
|
||||
)
|
||||
expect(step(source, 'Install Linux sandbox dependency').run).not.toContain(
|
||||
'apparmor_restrict_unprivileged_userns=0'
|
||||
)
|
||||
expect(source).toMatchObject({
|
||||
// workflow_dispatch has no allow_failure input; an explicit comparison must yield false.
|
||||
'continue-on-error': '${{ inputs.allow_failure == true }}',
|
||||
'runs-on': 'ubuntu-latest',
|
||||
'runs-on': '${{ matrix.os }}',
|
||||
'timeout-minutes': 20
|
||||
})
|
||||
})
|
||||
@@ -67,7 +83,7 @@ describe('runtime certification workflow', () => {
|
||||
const verify = step(source, 'Verify runtime prerequisites')
|
||||
|
||||
expect(install.run).toBe('node scripts/ci/npm-ci.mjs')
|
||||
expect(fetch.run).toContain('scripts/fetch-micromamba.mjs linux-64')
|
||||
expect(fetch.run).toContain('scripts/fetch-micromamba.mjs "${{ matrix.subdir }}"')
|
||||
expect(create.run).toContain('python=3.12 matplotlib-base numpy pandas nomkl')
|
||||
expect(create.run).toContain('r-base=4.4 r-jsonlite r-ggplot2 r-renv r-mass')
|
||||
expect(create.run).toContain('OPEN_SCIENCE_TEST_PY_ENV=')
|
||||
@@ -156,12 +172,19 @@ describe('runtime certification workflow', () => {
|
||||
|
||||
expect(dispatch.inputs?.dry_run).toMatchObject({
|
||||
default: 'full',
|
||||
options: ['full', 'runtime-source', 'macos-x64', 'linux-cli', 'windows-package']
|
||||
options: [
|
||||
'full',
|
||||
'runtime-source',
|
||||
'macos-x64',
|
||||
'linux-cli',
|
||||
'linux-arm64',
|
||||
'windows-package'
|
||||
]
|
||||
})
|
||||
expect(nightly.jobs.build.if).toContain("inputs.dry_run != 'runtime-source'")
|
||||
expect(runtime).toMatchObject({
|
||||
needs: 'plan',
|
||||
if: "needs.plan.outputs.should_build == 'true' && inputs.dry_run != 'macos-x64' && inputs.dry_run != 'linux-cli' && inputs.dry_run != 'windows-package'",
|
||||
if: "needs.plan.outputs.should_build == 'true' && inputs.dry_run != 'macos-x64' && inputs.dry_run != 'linux-cli' && inputs.dry_run != 'linux-arm64' && inputs.dry_run != 'windows-package'",
|
||||
uses: './.github/workflows/runtime-certification.yml',
|
||||
with: { allow_failure: "${{ github.event_name != 'workflow_dispatch' }}" }
|
||||
})
|
||||
|
||||
@@ -33,7 +33,9 @@ const KEY_RULES = [
|
||||
{ key: 'mac-x64', pattern: /-mac-x64\.dmg$/ },
|
||||
{ key: 'win-x64', pattern: /-win-x64-setup\.exe$/ },
|
||||
{ key: 'linux-x64-appimage', pattern: /-linux-(?:x64|x86_64)\.AppImage$/ },
|
||||
{ key: 'linux-x64-deb', pattern: /_amd64\.deb$/ }
|
||||
{ key: 'linux-x64-deb', pattern: /_amd64\.deb$/ },
|
||||
{ key: 'linux-arm64-appimage', pattern: /-linux-arm64\.AppImage$/ },
|
||||
{ key: 'linux-arm64-deb', pattern: /_arm64\.deb$/ }
|
||||
]
|
||||
|
||||
// Non-installer files that legitimately live in the release dir; skipped without a warning.
|
||||
@@ -130,21 +132,32 @@ export function buildManifest({
|
||||
downloads[key] = { url: `${base}/${filename}`, size: stat.size, sha256 }
|
||||
}
|
||||
for (const filename of readdirSync(dir).filter((name) =>
|
||||
/^(latest(?:-linux)?|.*-mac)\.yml$/.test(name)
|
||||
/^(latest(?:-linux(?:-arm64)?)?|.*-mac)\.yml$/.test(name)
|
||||
)) {
|
||||
validateUpdateFeed(
|
||||
const feed = validateUpdateFeed(
|
||||
load(readFileSync(join(dir, filename), 'utf8')),
|
||||
dir,
|
||||
version,
|
||||
metadataOnly,
|
||||
allowLegacyNames
|
||||
)
|
||||
if (filename === 'latest-linux.yml' || filename === 'latest-linux-arm64.yml') {
|
||||
const arch = filename === 'latest-linux-arm64.yml' ? 'arm64' : 'x64'
|
||||
if (feed.files.some((file) => !keyForFile(file.url)?.startsWith(`linux-${arch}-`))) {
|
||||
throw new Error(`Wrong architecture in ${filename}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requireComplete) {
|
||||
for (const { key } of KEY_RULES) {
|
||||
if (!downloads[key]) throw new Error(`Missing stable release installer: ${key}`)
|
||||
}
|
||||
for (const name of ['latest.yml', 'latest-linux.yml', 'latest-mac.yml']) {
|
||||
for (const name of [
|
||||
'latest.yml',
|
||||
'latest-linux.yml',
|
||||
'latest-linux-arm64.yml',
|
||||
'latest-mac.yml'
|
||||
]) {
|
||||
const feed = validateUpdateFeed(
|
||||
load(readFileSync(join(dir, name), 'utf8')),
|
||||
dir,
|
||||
@@ -156,7 +169,9 @@ export function buildManifest({
|
||||
? ['-mac-arm64.zip', '-mac-x64.zip']
|
||||
: name === 'latest.yml'
|
||||
? ['-win-x64-setup.exe']
|
||||
: ['.AppImage', '.deb']
|
||||
: name === 'latest-linux-arm64.yml'
|
||||
? ['-linux-arm64.AppImage', '_arm64.deb']
|
||||
: ['.AppImage', '_amd64.deb']
|
||||
for (const suffix of required) {
|
||||
if (!feed.files.some((file) => file.url.endsWith(suffix)))
|
||||
throw new Error(`Missing ${suffix} in ${name}`)
|
||||
|
||||
@@ -23,7 +23,9 @@ const INSTALLERS = {
|
||||
'mac-x64': `aipoch-open-science-${VERSION}-mac-x64.dmg`,
|
||||
'win-x64': `aipoch-open-science-${VERSION}-win-x64-setup.exe`,
|
||||
'linux-x64-appimage': `aipoch-open-science-${VERSION}-linux-x64.AppImage`,
|
||||
'linux-x64-deb': `aipoch-open-science_${VERSION}_amd64.deb`
|
||||
'linux-x64-deb': `aipoch-open-science_${VERSION}_amd64.deb`,
|
||||
'linux-arm64-appimage': `aipoch-open-science-${VERSION}-linux-arm64.AppImage`,
|
||||
'linux-arm64-deb': `aipoch-open-science_${VERSION}_arm64.deb`
|
||||
}
|
||||
|
||||
// One line of SHA256SUMS.txt worth of file: content is hashed by `sha` (or omitted when sha is null).
|
||||
@@ -174,6 +176,46 @@ describe('buildManifest', () => {
|
||||
let dir: string | undefined
|
||||
afterEach(() => dir && rmSync(dir, { recursive: true, force: true }))
|
||||
|
||||
it.each(['x64', 'arm64'] as const)(
|
||||
'validates %s feed bytes and rejects a swapped architecture',
|
||||
(arch) => {
|
||||
const files = [entry(`linux-${arch}-appimage`), entry(`linux-${arch}-deb`)]
|
||||
dir = makeReleaseDir(files)
|
||||
const feed = JSON.stringify({
|
||||
version: VERSION,
|
||||
files: files.map((file) => ({
|
||||
url: file.name,
|
||||
size: file.content.length,
|
||||
sha512: createHash('sha512').update(file.content).digest('base64')
|
||||
}))
|
||||
})
|
||||
const options = { dir, version: VERSION, cdnBase: CDN, prefix: PREFIX }
|
||||
const name = arch === 'arm64' ? 'latest-linux-arm64.yml' : 'latest-linux.yml'
|
||||
writeFileSync(join(dir, name), feed)
|
||||
expect(Object.keys(buildManifest(options).downloads)).toHaveLength(2)
|
||||
writeFileSync(
|
||||
join(dir, arch === 'arm64' ? 'latest-linux.yml' : 'latest-linux-arm64.yml'),
|
||||
feed
|
||||
)
|
||||
expect(() => buildManifest(options)).toThrow(/Wrong architecture/)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps historical backfill partial while requiring ARM64 for a new promotion', () => {
|
||||
dir = makeReleaseDir(
|
||||
Object.keys(INSTALLERS)
|
||||
.filter((key) => !key.includes('linux-arm64'))
|
||||
.map((key) => entry(key))
|
||||
)
|
||||
const options = { dir, version: VERSION, cdnBase: CDN, prefix: PREFIX }
|
||||
expect(
|
||||
Object.keys(buildManifest({ ...options, allowLegacyNames: true }).downloads)
|
||||
).toHaveLength(5)
|
||||
expect(() => buildManifest({ ...options, requireComplete: true })).toThrow(
|
||||
/Missing stable release installer: linux-arm64/
|
||||
)
|
||||
})
|
||||
|
||||
it('maps every installer to its key with url, size and sha256', () => {
|
||||
const files = Object.keys(INSTALLERS).map((key, i) => entry(key, i + 1))
|
||||
dir = makeReleaseDir(files)
|
||||
@@ -194,7 +236,7 @@ describe('buildManifest', () => {
|
||||
expect(manifest.localizedNotes).toEqual({ 'zh-Hans': '版本说明' })
|
||||
expect(manifest.releaseDate).toBe('2026-07-12T00:00:00Z')
|
||||
|
||||
// All five platform keys present.
|
||||
// All platform keys present.
|
||||
expect(Object.keys(manifest.downloads).sort()).toEqual(Object.keys(INSTALLERS).sort())
|
||||
|
||||
// url construction: <cdn>/<prefix>/releases/<version>/<filename>.
|
||||
|
||||
@@ -15,13 +15,16 @@ import {
|
||||
} from './database-migration-ledger-smoke.mjs'
|
||||
import { authenticatePackagedAppEndpoint } from './packaged-web-service-auth.mjs'
|
||||
|
||||
const APPIMAGE_PATTERN = /^aipoch-open-science-(.+)-linux-x86_64\.AppImage$/
|
||||
const APPIMAGE_PATTERN = /^aipoch-open-science-(.+)-linux-(?:x64|x86_64|arm64)\.AppImage$/
|
||||
const SMOKE_ROOT_PREFIX = 'open-science-linux-package-smoke-'
|
||||
const STARTUP_TIMEOUT_MS = 60_000
|
||||
const REQUIRED_LINUX_PRISMA_ENGINES = [
|
||||
'libquery_engine-debian-openssl-3.0.x.so.node',
|
||||
'libquery_engine-rhel-openssl-3.0.x.so.node'
|
||||
]
|
||||
const REQUIRED_LINUX_PRISMA_ENGINES = {
|
||||
x64: [
|
||||
'libquery_engine-debian-openssl-3.0.x.so.node',
|
||||
'libquery_engine-rhel-openssl-3.0.x.so.node'
|
||||
],
|
||||
arm64: ['libquery_engine-linux-arm64-openssl-3.0.x.so.node']
|
||||
}
|
||||
|
||||
const delay = (milliseconds) =>
|
||||
new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds))
|
||||
@@ -118,8 +121,11 @@ const findResourceRoot = async (executable, resolvedExecutable = executable) =>
|
||||
|
||||
const assertPackagedResources = async (
|
||||
executable,
|
||||
resourceRoot = join(dirname(executable), 'resources')
|
||||
resourceRoot = join(dirname(executable), 'resources'),
|
||||
arch = process.arch
|
||||
) => {
|
||||
const requiredEngines = REQUIRED_LINUX_PRISMA_ENGINES[arch]
|
||||
if (!requiredEngines) throw new Error(`Unsupported Linux package architecture: ${arch}`)
|
||||
for (const path of packagedResourcePaths(executable, resourceRoot)) {
|
||||
if (!(await pathExists(path))) throw new Error(`Packaged Linux resource is missing: ${path}`)
|
||||
}
|
||||
@@ -128,15 +134,11 @@ const assertPackagedResources = async (
|
||||
const nativeEngines = engines.filter(
|
||||
(name) => name.includes('query_engine-') && name.endsWith('.node')
|
||||
)
|
||||
const missingEngines = REQUIRED_LINUX_PRISMA_ENGINES.filter(
|
||||
(name) => !nativeEngines.includes(name)
|
||||
)
|
||||
const unexpectedEngines = nativeEngines.filter(
|
||||
(name) => !REQUIRED_LINUX_PRISMA_ENGINES.includes(name)
|
||||
)
|
||||
const missingEngines = requiredEngines.filter((name) => !nativeEngines.includes(name))
|
||||
const unexpectedEngines = nativeEngines.filter((name) => !requiredEngines.includes(name))
|
||||
if (missingEngines.length > 0 || unexpectedEngines.length > 0) {
|
||||
throw new Error(
|
||||
`Packaged Linux must contain Prisma engines ${REQUIRED_LINUX_PRISMA_ENGINES.join(', ')}; ` +
|
||||
`Packaged Linux must contain Prisma engines ${requiredEngines.join(', ')}; ` +
|
||||
`found ${nativeEngines.join(', ') || 'none'} in ${prismaRoot}.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,34 @@ import {
|
||||
} from './linux-package-smoke.mjs'
|
||||
|
||||
describe('Linux package smoke', () => {
|
||||
it.each(['x64', 'x86_64', 'arm64'])('accepts the %s AppImage filename', (arch) => {
|
||||
expect(appImageVersion(`aipoch-open-science-0.33.3-linux-${arch}.AppImage`)).toBe('0.33.3')
|
||||
})
|
||||
|
||||
it('requires the ARM64 Prisma engine and rejects foreign engines', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'open-science-arm64-engine-'))
|
||||
const executable = join(root, 'open-science')
|
||||
const resources = join(root, 'resources')
|
||||
const prisma = join(resources, 'node_modules', '.prisma', 'client')
|
||||
await mkdir(prisma, { recursive: true })
|
||||
await Promise.all(
|
||||
[executable, join(resources, 'app.asar'), join(resources, 'micromamba')].map((file) =>
|
||||
writeFile(file, '')
|
||||
)
|
||||
)
|
||||
await expect(assertPackagedResources(executable, resources, 'arm64')).rejects.toThrow(
|
||||
/linux-arm64-openssl/
|
||||
)
|
||||
await writeFile(join(prisma, 'libquery_engine-linux-arm64-openssl-3.0.x.so.node'), '')
|
||||
await expect(assertPackagedResources(executable, resources, 'arm64')).resolves.toBeUndefined()
|
||||
await writeFile(join(prisma, 'libquery_engine-rhel-openssl-3.0.x.so.node'), '')
|
||||
await expect(assertPackagedResources(executable, resources, 'arm64')).rejects.toThrow(
|
||||
/Prisma engines/
|
||||
)
|
||||
await expect(assertPackagedResources(executable, resources, 'ia32')).rejects.toThrow(
|
||||
/Unsupported/
|
||||
)
|
||||
})
|
||||
it('discovers one AppImage and derives stable or nightly versions', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'open-science-linux-artifacts-'))
|
||||
const appImage = join(root, 'aipoch-open-science-0.11.0-nightly.abc1234-linux-x86_64.AppImage')
|
||||
@@ -55,7 +83,9 @@ describe('Linux package smoke', () => {
|
||||
await mkdir(join(appRoot, 'resources'), { recursive: true })
|
||||
await writeFile(join(appRoot, 'resources', 'app.asar'), '')
|
||||
|
||||
await expect(assertPackagedResources(executable)).rejects.toThrow(/micromamba/)
|
||||
await expect(assertPackagedResources(executable, undefined, 'x64')).rejects.toThrow(
|
||||
/micromamba/
|
||||
)
|
||||
})
|
||||
|
||||
it('requires Debian and RHEL native Linux Prisma engines', async () => {
|
||||
@@ -72,9 +102,11 @@ describe('Linux package smoke', () => {
|
||||
writeFile(join(prismaClient, 'libquery_engine-rhel-openssl-3.0.x.so.node'), '')
|
||||
])
|
||||
|
||||
await expect(assertPackagedResources(executable)).resolves.toBeUndefined()
|
||||
await expect(assertPackagedResources(executable, undefined, 'x64')).resolves.toBeUndefined()
|
||||
await writeFile(join(prismaClient, 'libquery_engine-darwin.dylib.node'), '')
|
||||
await expect(assertPackagedResources(executable)).rejects.toThrow(/Prisma engines/)
|
||||
await expect(assertPackagedResources(executable, undefined, 'x64')).rejects.toThrow(
|
||||
/Prisma engines/
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a Debian-only engine set because Fedora selects the RHEL runtime', async () => {
|
||||
@@ -90,6 +122,8 @@ describe('Linux package smoke', () => {
|
||||
writeFile(join(prismaClient, 'libquery_engine-debian-openssl-3.0.x.so.node'), '')
|
||||
])
|
||||
|
||||
await expect(assertPackagedResources(executable)).rejects.toThrow(/rhel-openssl-3\.0\.x/)
|
||||
await expect(assertPackagedResources(executable, undefined, 'x64')).rejects.toThrow(
|
||||
/rhel-openssl-3\.0\.x/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ try {
|
||||
if (mode === 'runtime') {
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(process.env.VERSION ?? '') ||
|
||||
!/^(osx-arm64|osx-64|linux-64|win-64)$/.test(argument)
|
||||
!/^(osx-arm64|osx-64|linux-64|linux-aarch64|win-64)$/.test(argument)
|
||||
) {
|
||||
throw new Error('Invalid runtime version or subdir')
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ const {
|
||||
VERSION: version,
|
||||
S3_BUCKET: bucket,
|
||||
S3_PREFIX: prefix = '',
|
||||
MODE: mode = 'backfill'
|
||||
MODE: mode = 'backfill',
|
||||
BOOTSTRAP_LINUX_ARM64: bootstrapLinuxArm64 = 'false'
|
||||
} = process.env
|
||||
const stable = (value) => {
|
||||
if (typeof value !== 'string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value)) {
|
||||
@@ -23,6 +24,11 @@ const stable = (value) => {
|
||||
const requested = stable(version)
|
||||
if (!bucket || !['backfill', 'promote'].includes(mode))
|
||||
throw new Error('Invalid mirror destination or mode')
|
||||
if (
|
||||
!['true', 'false'].includes(bootstrapLinuxArm64) ||
|
||||
(bootstrapLinuxArm64 === 'true' && mode !== 'promote')
|
||||
)
|
||||
throw new Error('ARM64 bootstrap requires explicit promotion')
|
||||
const root = `s3://${bucket}/${prefix.replace(/^\/+|\/+$/g, '')}`.replace(/\/$/, '')
|
||||
const aws = (...args) =>
|
||||
execFileSync('aws', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
@@ -42,11 +48,12 @@ const upload = (source, target, immutable, type) =>
|
||||
const manifest = JSON.parse(readFileSync('version.json', 'utf8'))
|
||||
if (manifest.version !== version) throw new Error('Manifest does not match the requested version')
|
||||
const feeds = readdirSync('dist-assets')
|
||||
.filter((name) => /^(latest(?:-linux)?|.*-mac)\.yml$/.test(name))
|
||||
.filter((name) => /^(latest(?:-linux(?:-arm64)?)?|.*-mac)\.yml$/.test(name))
|
||||
.sort()
|
||||
const required = [
|
||||
'latest.yml',
|
||||
'latest-linux.yml',
|
||||
'latest-linux-arm64.yml',
|
||||
'latest-mac.yml',
|
||||
'arm64-mac.yml',
|
||||
'x64-mac.yml'
|
||||
@@ -61,12 +68,28 @@ if (promote) {
|
||||
throw new Error(`Invalid promotion feed: ${name}`)
|
||||
}
|
||||
}
|
||||
// Deliberately fail closed on missing, unreadable or malformed current objects. Bootstrap is a
|
||||
// separate operation; an authorization/network error must never masquerade as an empty channel.
|
||||
const currentVersions = [
|
||||
JSON.parse(readRemote('version.json')).version,
|
||||
...required.map((name) => load(readRemote(name))?.version)
|
||||
]
|
||||
// Only the explicitly opted-in new platform may be absent. Confirm absence with HEAD; an
|
||||
// authorization/network error must never masquerade as a missing channel. Existing feeds still
|
||||
// participate in monotonic promotion, including ARM64 on a retry after a partial upload.
|
||||
const currentFeedVersions = required.flatMap((name) => {
|
||||
if (name === 'latest-linux-arm64.yml' && bootstrapLinuxArm64 === 'true') {
|
||||
try {
|
||||
aws(
|
||||
's3api',
|
||||
'head-object',
|
||||
'--bucket',
|
||||
bucket,
|
||||
'--key',
|
||||
`${prefix.replace(/^\/+|\/+$/g, '')}/${name}`.replace(/^\//, '')
|
||||
)
|
||||
} catch (error) {
|
||||
if (/\((?:404|NoSuchKey|NotFound)\)/.test(String(error.stderr))) return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return [load(readRemote(name))?.version]
|
||||
})
|
||||
const currentVersions = [JSON.parse(readRemote('version.json')).version, ...currentFeedVersions]
|
||||
for (const current of currentVersions) {
|
||||
const parts = stable(current)
|
||||
const difference = parts.findIndex((part, index) => part !== requested[index])
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
@@ -78,20 +79,23 @@ it('excludes local worktrees and tool state through the real packaging filter',
|
||||
}
|
||||
})
|
||||
|
||||
it('includes the AppImage filename produced by the installed builder', () => {
|
||||
const { Arch, getArtifactArchName } = appBuilderRequire('builder-util')
|
||||
const { expandMacro } = builderRequire('app-builder-lib/out/util/macroExpander')
|
||||
const name = expandMacro(
|
||||
config.appImage.artifactName,
|
||||
getArtifactArchName(Arch.x64, 'AppImage'),
|
||||
{ name: 'open-science', version: '0.27.0' },
|
||||
{ ext: 'AppImage', os: 'linux' }
|
||||
)
|
||||
expect(manifest(installer(name)).downloads['linux-x64-appimage']).toMatchObject({
|
||||
size: Buffer.byteLength('synthetic installer'),
|
||||
sha256: sha256('synthetic installer')
|
||||
})
|
||||
})
|
||||
it.each(['x64', 'arm64'] as const)(
|
||||
'includes the %s AppImage filename produced by the installed builder',
|
||||
(arch) => {
|
||||
const { Arch, getArtifactArchName } = appBuilderRequire('builder-util')
|
||||
const { expandMacro } = builderRequire('app-builder-lib/out/util/macroExpander')
|
||||
const name = expandMacro(
|
||||
config.appImage.artifactName,
|
||||
getArtifactArchName(Arch[arch], 'AppImage'),
|
||||
{ name: 'open-science', version: '0.27.0' },
|
||||
{ ext: 'AppImage', os: 'linux' }
|
||||
)
|
||||
expect(manifest(installer(name)).downloads[`linux-${arch}-appimage`]).toMatchObject({
|
||||
size: Buffer.byteLength('synthetic installer'),
|
||||
sha256: sha256('synthetic installer')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects an installer from a different release version', () => {
|
||||
const dir = installer('aipoch-open-science-0.26.0-win-x64-setup.exe')
|
||||
@@ -131,7 +135,63 @@ it('does not successfully publish a macOS feed with only one architecture', () =
|
||||
expect(merge(dir).status).not.toBe(0)
|
||||
})
|
||||
|
||||
type Workflow = { jobs: Record<string, { steps: Array<{ name: string; run?: string }> }> }
|
||||
type Workflow = {
|
||||
jobs: Record<string, { steps: Array<{ name?: string; id?: string; if?: string; run?: string }> }>
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'resolves native ARM64 build/staging matrices and prunes x64 engines',
|
||||
() => {
|
||||
const output = join(temporaryDirectory(), 'output')
|
||||
const build = load(readFileSync(join(repo, '.github/workflows/build.yml'), 'utf8')) as Workflow
|
||||
const staging = load(
|
||||
readFileSync(join(repo, '.github/workflows/stage-runtime-bundle.yml'), 'utf8')
|
||||
) as Workflow
|
||||
for (const [document, selection] of [
|
||||
[build, { PLATFORM_NAME: 'linux-arm64' }],
|
||||
[staging, { SUBDIR: 'linux-aarch64' }]
|
||||
] as const) {
|
||||
writeFileSync(output, '')
|
||||
const script = document.jobs.setup.steps[0].run!.replaceAll('${{ inputs.mac_only }}', 'false')
|
||||
const result = spawnSync('bash', ['-eu', '-c', script], {
|
||||
env: { ...process.env, ...selection, GITHUB_OUTPUT: output },
|
||||
encoding: 'utf8'
|
||||
})
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
const { include } = JSON.parse(
|
||||
readFileSync(output, 'utf8')
|
||||
.trim()
|
||||
.replace(/^matrix=/, '')
|
||||
)
|
||||
expect(include).toHaveLength(1)
|
||||
expect(include[0]).toMatchObject({ os: 'ubuntu-24.04-arm', subdir: 'linux-aarch64' })
|
||||
if (document === build)
|
||||
expect(include[0]).toMatchObject({ eb_args: '--linux --arm64', bin_arch: 'arm64' })
|
||||
}
|
||||
const cwd = temporaryDirectory()
|
||||
const engines = join(cwd, 'node_modules/.prisma/client')
|
||||
mkdirSync(engines, { recursive: true })
|
||||
const keep = 'libquery_engine-linux-arm64-openssl-3.0.x.so.node'
|
||||
for (const engine of [
|
||||
keep,
|
||||
'libquery_engine-rhel-openssl-3.0.x.so.node',
|
||||
'libquery_engine-darwin.dylib.node'
|
||||
])
|
||||
writeFileSync(join(engines, engine), '')
|
||||
const prune = workflowStep('build.yml', 'build', 'Prune foreign Prisma engines').replaceAll(
|
||||
'${{ matrix.engine_keep }}',
|
||||
'linux-arm64-openssl-3.0.x.so.node'
|
||||
)
|
||||
const result = spawnSync('bash', ['-eu', '-c', prune], { cwd, encoding: 'utf8' })
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(readdirSync(engines)).toEqual([keep])
|
||||
for (const name of ['Configure AWS credentials', 'Upload bundle to CDN']) {
|
||||
expect(staging.jobs.stage.steps.find((step) => step.name === name)?.if).toBe(
|
||||
'${{ !inputs.dry_run }}'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
const workflowStep = (filename: string, job: string, name: string): string => {
|
||||
const workflow = load(readFileSync(join(repo, '.github/workflows', filename), 'utf8')) as Workflow
|
||||
const script = workflow.jobs[job].steps.find((step) => step.name === name)?.run
|
||||
@@ -182,9 +242,9 @@ fs.mkdirSync(path.dirname(target), {recursive:true}); fs.copyFileSync(source, ta
|
||||
}
|
||||
}
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'preserves published runtime bytes when the same version is restaged',
|
||||
() => {
|
||||
it.skipIf(process.platform === 'win32').each(['linux-64', 'linux-aarch64'])(
|
||||
'preserves published %s runtime bytes when the same version is restaged',
|
||||
(subdir) => {
|
||||
const { cwd, remote, env } = objectStore()
|
||||
const workflow = load(
|
||||
readFileSync(join(repo, '.github/workflows/stage-runtime-bundle.yml'), 'utf8')
|
||||
@@ -194,7 +254,7 @@ it.skipIf(process.platform === 'win32')(
|
||||
)!
|
||||
const script = workflowStep('stage-runtime-bundle.yml', job, 'Upload bundle to CDN').replaceAll(
|
||||
'${{ matrix.subdir }}',
|
||||
'linux-64'
|
||||
subdir
|
||||
)
|
||||
const local = join(cwd, 'resources/default-envs')
|
||||
mkdirSync(local, { recursive: true })
|
||||
@@ -206,7 +266,7 @@ it.skipIf(process.platform === 'win32')(
|
||||
}
|
||||
expect(
|
||||
readFileSync(
|
||||
join(remote, 'test-bucket/open-science/runtime-bundle/1/linux-64/python-3.12.tar.zst'),
|
||||
join(remote, `test-bucket/open-science/runtime-bundle/1/${subdir}/python-3.12.tar.zst`),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('original archive')
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('post-merge Windows validation', () => {
|
||||
expect(dispatch?.inputs?.platform_name).toMatchObject({
|
||||
type: 'choice',
|
||||
default: 'macos-x64',
|
||||
options: ['macos-x64', 'macos-arm64', 'linux-x64', 'windows-x64', 'all']
|
||||
options: ['macos-x64', 'macos-arm64', 'linux-x64', 'linux-arm64', 'windows-x64', 'all']
|
||||
})
|
||||
expect(smokeWorkflow.permissions).toEqual({ contents: 'read' })
|
||||
expect(smokeWorkflow.concurrency).toEqual({
|
||||
@@ -848,6 +848,13 @@ if ($artifactSaveBase -eq $artifactSaveCommit) {
|
||||
options: ['backfill', 'promote'],
|
||||
default: 'backfill'
|
||||
},
|
||||
bootstrap_linux_arm64: {
|
||||
description:
|
||||
'First ARM64 promotion only: allow a confirmed missing ARM64 feed (requires promote)',
|
||||
required: false,
|
||||
type: 'boolean',
|
||||
default: false
|
||||
},
|
||||
dry_run: {
|
||||
description: 'Run local release transforms without AWS credentials or uploads',
|
||||
required: false,
|
||||
|
||||
@@ -191,13 +191,13 @@ describe('runtime CDN platform mapping', () => {
|
||||
['darwin', 'arm64', 'osx-arm64'],
|
||||
['darwin', 'x64', 'osx-64'],
|
||||
['linux', 'x64', 'linux-64'],
|
||||
['linux', 'arm64', 'linux-aarch64'],
|
||||
['win32', 'x64', 'win-64']
|
||||
] as const)('%s/%s maps to %s', (platform, arch, expected) => {
|
||||
expect(runtimeSubdir(platform, arch)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['linux', 'arm64'], // never published — must reject, not map to a 404-ing linux-aarch64
|
||||
['win32', 'arm64'],
|
||||
['freebsd', 'x64']
|
||||
] as const)('rejects the unpublished platform %s/%s', (platform, arch) => {
|
||||
|
||||
@@ -29,10 +29,10 @@ export const runtimeSubdir = (
|
||||
if (platform === 'darwin' && arch === 'arm64') return 'osx-arm64'
|
||||
if (platform === 'darwin' && arch === 'x64') return 'osx-64'
|
||||
if (platform === 'linux' && arch === 'x64') return 'linux-64'
|
||||
if (platform === 'linux' && arch === 'arm64') return 'linux-aarch64'
|
||||
if (platform === 'win32' && arch === 'x64') return 'win-64'
|
||||
// Only the four subdirs above are staged/published by stage-runtime-bundle.yml. linux-aarch64 (and
|
||||
// win32/arm64) are NOT — so reject them here with a clear "unsupported" error at resolution time,
|
||||
// rather than mapping to a subdir whose CDN fetch would 404 and look like a transient outage.
|
||||
// Keep unsupported targets out of CDN resolution; build.yml verifies publication of every
|
||||
// supported subdir before a release can ship.
|
||||
throw new Error(`Unsupported notebook runtime platform: ${platform}/${arch}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,22 @@ const offline = async (): Promise<never> => {
|
||||
throw new Error('No network in audit')
|
||||
}
|
||||
|
||||
it.each([
|
||||
['x64', 'latest-linux'],
|
||||
['arm64', 'latest-linux-arm64']
|
||||
])('uses the installed updater architecture channel for %s', (arch, channel) => {
|
||||
const { Provider } = requireRepo('electron-updater/out/providers/Provider')
|
||||
// Upstream Provider.getChannelFilePrefix reads process.env.TEST_UPDATER_ARCH || process.arch.
|
||||
// Exercise its own test seam; a custom app channel would receive the architecture suffix twice.
|
||||
vi.stubEnv('TEST_UPDATER_ARCH', arch)
|
||||
try {
|
||||
const provider = new Provider({ platform: 'linux', executor: {} })
|
||||
expect(provider.getDefaultChannelName()).toBe(channel)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'handles real updater cache completion with cancellation=%s',
|
||||
async (cancelled) => {
|
||||
|
||||
@@ -16,6 +16,17 @@ const valid = {
|
||||
}
|
||||
|
||||
describe('parseManifest', () => {
|
||||
it('retains both Linux ARM64 installer entries from the published manifest', () => {
|
||||
const downloads = {
|
||||
'linux-arm64-appimage': {
|
||||
url: 'https://cdn/app-linux-arm64.AppImage',
|
||||
size: 20,
|
||||
sha256: 'a'.repeat(64)
|
||||
},
|
||||
'linux-arm64-deb': { url: 'https://cdn/app_arm64.deb', size: 10, sha256: 'b'.repeat(64) }
|
||||
}
|
||||
expect(parseManifest({ ...valid, downloads }).downloads).toEqual(downloads)
|
||||
})
|
||||
it('accepts a well-formed manifest', () => {
|
||||
expect(parseManifest(valid)).toMatchObject({
|
||||
version: '0.3.0',
|
||||
@@ -31,7 +42,15 @@ describe('parseManifest', () => {
|
||||
const downloads = { ...valid.downloads, 'linux-arm64': null, solaris: { format: 2 } }
|
||||
expect(parseManifest({ ...valid, downloads }).downloads).toEqual(valid.downloads)
|
||||
expect(parseManifest({ ...valid, downloads: { solaris: null } }).downloads).toEqual({})
|
||||
for (const key of ['mac-arm64', 'mac-x64', 'win-x64', 'linux-x64-appimage', 'linux-x64-deb']) {
|
||||
for (const key of [
|
||||
'mac-arm64',
|
||||
'mac-x64',
|
||||
'win-x64',
|
||||
'linux-x64-appimage',
|
||||
'linux-x64-deb',
|
||||
'linux-arm64-appimage',
|
||||
'linux-arm64-deb'
|
||||
]) {
|
||||
for (const invalid of [{ url: 'http://cdn/a' }, { size: 0 }, { sha256: 'bad' }]) {
|
||||
expect(() =>
|
||||
parseManifest({
|
||||
|
||||
@@ -7,7 +7,9 @@ const DOWNLOAD_KEYS = new Set([
|
||||
'mac-x64',
|
||||
'win-x64',
|
||||
'linux-x64-appimage',
|
||||
'linux-x64-deb'
|
||||
'linux-x64-deb',
|
||||
'linux-arm64-appimage',
|
||||
'linux-arm64-deb'
|
||||
])
|
||||
const MANIFEST_TIMEOUT_MS = 15_000
|
||||
const MAX_MANIFEST_BYTES = 256 * 1024
|
||||
|
||||
@@ -60,12 +60,30 @@ describe('platformDownloadKey', () => {
|
||||
expect(platformDownloadKey('win32', 'x64')).toBe('win-x64')
|
||||
expect(platformDownloadKey('linux', 'x64')).toBe('linux-x64-deb')
|
||||
expect(platformDownloadKey('win32', 'arm64')).toBeNull()
|
||||
expect(platformDownloadKey('linux', 'arm64')).toBeNull()
|
||||
expect(platformDownloadKey('linux', 'arm64')).toBe('linux-arm64-deb')
|
||||
expect(platformDownloadKey('freebsd' as NodeJS.Platform, 'x64')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectDownload', () => {
|
||||
it('selects ARM64 deb or its AppImage without falling back to x64 artifacts', () => {
|
||||
const appimage = { url: 'https://cdn/arm64.AppImage', size: 30, sha256: 'c' }
|
||||
const deb = { url: 'https://cdn/arm64.deb', size: 20, sha256: 'd' }
|
||||
const armManifest = {
|
||||
...manifest,
|
||||
downloads: { ...manifest.downloads, 'linux-arm64-appimage': appimage }
|
||||
}
|
||||
expect(selectDownload(armManifest, 'linux', 'arm64')).toBe(appimage)
|
||||
expect(
|
||||
selectDownload(
|
||||
{ ...armManifest, downloads: { ...armManifest.downloads, 'linux-arm64-deb': deb } },
|
||||
'linux',
|
||||
'arm64'
|
||||
)
|
||||
).toBe(deb)
|
||||
expect(selectDownload(manifest, 'linux', 'arm64')).toBeNull()
|
||||
expect(selectDownload(armManifest, 'linux', 'ia32')).toBeNull()
|
||||
})
|
||||
it('returns the matching entry', () => {
|
||||
expect(selectDownload(manifest, 'darwin', 'arm64')?.url).toContain('mac-arm64')
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ export const platformDownloadKey = (platform: NodeJS.Platform, arch: string): st
|
||||
if (arch === 'x64') return 'mac-x64'
|
||||
}
|
||||
if (platform === 'win32' && arch === 'x64') return 'win-x64'
|
||||
if (platform === 'linux' && arch === 'x64') return 'linux-x64-deb'
|
||||
if (platform === 'linux' && (arch === 'x64' || arch === 'arm64')) return `linux-${arch}-deb`
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ export const selectDownload = (
|
||||
): PlatformDownload | null => {
|
||||
const key = platformDownloadKey(platform, arch)
|
||||
if (key && manifest.downloads[key]) return manifest.downloads[key]
|
||||
if (platform === 'linux' && arch === 'x64' && manifest.downloads['linux-x64-appimage']) {
|
||||
return manifest.downloads['linux-x64-appimage']
|
||||
if (platform === 'linux' && key) {
|
||||
return manifest.downloads[`linux-${arch}-appimage`] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
root: resolve('src/renderer/web'),
|
||||
// Match the desktop renderer: the shared journal import worker emits split modules.
|
||||
worker: { format: 'es' },
|
||||
resolve: {
|
||||
alias: {
|
||||
// The decoder's browser entry requires document; its default/worker entry is DOM-free.
|
||||
|
||||
Reference in New Issue
Block a user