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:
Ewen
2026-09-28 05:41:05 -07:00
committed by GitHub
parent bc39efff5a
commit 052d83d092
32 changed files with 573 additions and 135 deletions
+4 -3
View File
@@ -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
+9 -3
View File
@@ -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
+8 -7
View File
@@ -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
+1
View File
@@ -47,6 +47,7 @@ on:
- macos-x64
- macos-arm64
- linux-x64
- linux-arm64
- windows-x64
- all
+1 -1
View File
@@ -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
+1
View File
@@ -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
+36 -3
View File
@@ -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
+43 -18
View File
@@ -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 }}
+108 -19
View File
@@ -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')
}
)
})
+1 -1
View File
@@ -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 () => {
+20 -8
View File
@@ -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' }}" }
})
+20 -5
View File
@@ -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}`)
+44 -2
View File
@@ -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
View File
@@ -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}.`
)
}
+38 -4
View File
@@ -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/
)
})
})
+1 -1
View File
@@ -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')
}
+31 -8
View File
@@ -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])
+80 -20
View File
@@ -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')
+8 -1
View File
@@ -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,
+1 -1
View File
@@ -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) => {
+3 -3
View File
@@ -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) => {
+20 -1
View File
@@ -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({
+3 -1
View File
@@ -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
+19 -1
View File
@@ -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')
})
+3 -3
View File
@@ -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
}
+2
View File
@@ -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.