diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md new file mode 100644 index 0000000000..d3f285e693 --- /dev/null +++ b/.github/DESKTOP_NIGHTLY.md @@ -0,0 +1,37 @@ + + +# Desktop Nightly + +Desktop Nightly is an ephemeral developer snapshot, not an Apache release. It builds the current `main` commit every day so contributors can try recent Desktop changes and report problems without waiting for an ASF source-release vote. + +The workflow gives each snapshot an immutable version such as `0.2.0-dev.20260829.42`. The download page records its exact source commit. A packaged Nightly accepts updates only from `https://nightlies.apache.org/maka/desktop/`, accepts only newer `dev` versions, and verifies that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build continues to use the GitHub Release feed and the formal product-release attestation identity. + +Nightly currently uses the same application identity as the formal Desktop. Installing it replaces the existing Maka installation rather than creating a second side-by-side app. Its user data remains in the same location. Testers who need the formal build should reinstall that build before returning to the formal channel. + +## One-time setup + +1. Ask Apache Infra to allow `apache/maka` to publish GitHub Actions output to `nightlies.apache.org`, provide the SSH `known_hosts` entry through an authenticated channel, and confirm whether retention is service-managed or requires a separate project cleanup job. Do not enable scheduled publication until that retention owner is explicit. +2. Create a GitHub Environment named `nightly` that permits only `main`. Store `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_PORT`, `NIGHTLIES_RSYNC_USER`, `NIGHTLIES_RSYNC_KEY`, and the Infra-verified `NIGHTLIES_RSYNC_KNOWN_HOSTS` value as Environment secrets. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to repository-wide or pull-request workflows. +3. Merge the Nightly workflow while it is disabled. After Infra publishing and the Environment secrets are ready, set the repository variable `DESKTOP_NIGHTLY_ENABLED` to `true` and run `Desktop Nightly` manually once. +4. Verify the download page, `latest-mac.yml`, and `latest.yml` under `https://nightlies.apache.org/maka/desktop/`, install both platform artifacts on clean machines, and confirm one automatic update before sharing the channel with testers. + +The scheduled run starts at 18:17 UTC. It audits the shipped dependency closure, builds and verifies macOS arm64 and Windows x64 artifacts, issues and locally verifies Sigstore provenance, appends a new immutable version directory, and advances the mutable update metadata last. A failure before publication leaves both existing platform feeds untouched. Each platform feed file is replaced independently after its complete payload exists, so an interrupted feed transfer may temporarily leave macOS and Windows on different valid Nightly versions. Do not rerun a failed workflow attempt in place; start a fresh manual run so it receives a new version. Historical payload cleanup is separate from publication, targets the Nightlies retention policy, and must never rewrite a published version or delete one referenced by a feed. Apache Nightlies storage is temporary; it must not be used as a formal release archive. + +Remote Runtime Host setup still follows the package identity embedded in the repository manifests. A Nightly does not publish a matching npm package, so clean remote setup is outside this channel until that dependency has its own reviewed snapshot distribution contract. diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml new file mode 100644 index 0000000000..728906c864 --- /dev/null +++ b/.github/workflows/desktop-nightly.yml @@ -0,0 +1,308 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Desktop Nightly + +on: + schedule: + - cron: '17 18 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: desktop-nightly + cancel-in-progress: false + +jobs: + identity: + if: vars.DESKTOP_NIGHTLY_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + source_commit: ${{ steps.identity.outputs.source_commit }} + version: ${{ steps.identity.outputs.version }} + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + + - name: Check out the scheduled main commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require the Apache main branch + run: | + test "$GITHUB_REPOSITORY" = apache/maka + test "$GITHUB_REF" = refs/heads/main + + - name: Resolve the immutable Nightly identity + id: identity + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + run: node scripts/desktop-nightly.mjs identity + + desktop: + needs: identity + strategy: + fail-fast: false + matrix: + include: + - platform: macos + runner: macos-15 + - platform: windows + runner: windows-2025 + runs-on: ${{ matrix.runner }} + environment: nightly + timeout-minutes: 75 + defaults: + run: + shell: bash + env: + MAKA_DESKTOP_NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + + - name: Check out the exact Nightly source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.source_commit }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Audit the shipped Desktop dependency closure + run: | + npm audit --omit=dev --audit-level=moderate + node scripts/audit-shipped-dependencies.mjs + + - name: Update stable Rust for native Desktop artifacts + run: rustup update stable --no-self-update + + - name: Write the App Store Connect API key + if: matrix.platform == 'macos' + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey_Maka.p8 + run: | + umask 077 + printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY_PATH" + + - name: Package the signed and notarized macOS Nightly + if: matrix.platform == 'macos' + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: npm run package:macos-arm64 + + - name: Notarize and verify the macOS Nightly + if: matrix.platform == 'macos' + env: + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + DMG_PATH: apps/desktop/release/Maka-${{ needs.identity.outputs.version }}-mac-arm64.dmg + run: | + xcrun notarytool submit "$DMG_PATH" \ + --key "$APPLE_API_KEY" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER" \ + --wait + xcrun stapler staple "$DMG_PATH" + npm run verify:macos-arm64 -- "$DMG_PATH" + + - name: Package the Windows Nightly + if: matrix.platform == 'windows' + run: npm run package:windows-x64 + + - name: Verify the Windows Nightly + if: matrix.platform == 'windows' + run: >- + npm run verify:windows-x64 -- + "apps/desktop/release/Maka-${{ needs.identity.outputs.version }}-win-x64.exe" + + - name: Stage the exact Nightly artifacts + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + STAGE_DIRECTORY: ${{ runner.temp }}/desktop-nightly + run: | + mkdir -p "$STAGE_DIRECTORY" + if [[ "${{ matrix.platform }}" = macos ]]; then + cp -- \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.dmg" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.zip" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.zip.blockmap" \ + apps/desktop/release/latest-mac.yml \ + "$STAGE_DIRECTORY/" + else + cp -- \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.exe" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.exe.blockmap" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.zip" \ + apps/desktop/release/latest.yml \ + "$STAGE_DIRECTORY/" + fi + + - name: Preserve the verified Nightly artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-nightly-${{ matrix.platform }} + path: ${{ runner.temp }}/desktop-nightly + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + publish: + needs: [identity, desktop] + runs-on: ubuntu-24.04 + environment: nightly + timeout-minutes: 20 + permissions: + artifact-metadata: write + attestations: write + contents: read + id-token: write + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + + - name: Check out the Nightly publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.source_commit }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install publisher dependencies + run: npm ci --ignore-scripts + + - name: Download both verified Desktop builds + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-nightly-* + path: ${{ github.workspace }}/.nightly-input + merge-multiple: true + + - name: Stage the versioned Nightly site + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }} + run: | + node scripts/desktop-nightly.mjs stage \ + "$GITHUB_WORKSPACE/.nightly-input" \ + "$GITHUB_WORKSPACE/.nightly-publish" \ + "$NIGHTLY_VERSION" \ + "$SOURCE_COMMIT" + + - name: Attest the exact Nightly payloads + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ github.workspace }}/.nightly-publish/versions/${{ needs.identity.outputs.version }}/* + + - name: Verify the issued Nightly provenance + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main + GH_TOKEN: ${{ github.token }} + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + run: | + verified=0 + while IFS= read -r -d '' artifact; do + gh attestation verify "$artifact" \ + --bundle "$ATTESTATION_BUNDLE" \ + --repo "$GITHUB_REPOSITORY" \ + --cert-identity "$CERTIFICATE_IDENTITY" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com + verified=$((verified + 1)) + done < <(find "$GITHUB_WORKSPACE/.nightly-publish/versions/$NIGHTLY_VERSION" -maxdepth 1 -type f -print0) + if (( verified == 0 )); then + echo "No Desktop Nightly artifacts were verified" >&2 + exit 1 + fi + + - name: Add the offline provenance bundle + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + run: >- + cp -- "$ATTESTATION_BUNDLE" + "$GITHUB_WORKSPACE/.nightly-publish/versions/$NIGHTLY_VERSION/Maka-$NIGHTLY_VERSION-attestation.sigstore.json" + + - name: Prepare authenticated Nightlies SSH transport + env: + NIGHTLIES_RSYNC_HOST: ${{ secrets.NIGHTLIES_RSYNC_HOST }} + NIGHTLIES_RSYNC_KEY: ${{ secrets.NIGHTLIES_RSYNC_KEY }} + NIGHTLIES_RSYNC_KNOWN_HOSTS: ${{ secrets.NIGHTLIES_RSYNC_KNOWN_HOSTS }} + NIGHTLIES_RSYNC_PATH: ${{ secrets.NIGHTLIES_RSYNC_PATH }} + NIGHTLIES_RSYNC_PORT: ${{ secrets.NIGHTLIES_RSYNC_PORT }} + NIGHTLIES_RSYNC_USER: ${{ secrets.NIGHTLIES_RSYNC_USER }} + run: | + test -n "$NIGHTLIES_RSYNC_HOST" + test -n "$NIGHTLIES_RSYNC_KEY" + test -n "$NIGHTLIES_RSYNC_KNOWN_HOSTS" + test -n "$NIGHTLIES_RSYNC_PATH" + test -n "$NIGHTLIES_RSYNC_USER" + [[ "$NIGHTLIES_RSYNC_PORT" =~ ^[0-9]{1,5}$ ]] + (( NIGHTLIES_RSYNC_PORT >= 1 && NIGHTLIES_RSYNC_PORT <= 65535 )) + ssh_directory="$RUNNER_TEMP/nightlies-ssh" + install -m 700 -d "$ssh_directory" + umask 077 + printf '%s\n' "$NIGHTLIES_RSYNC_KEY" > "$ssh_directory/key" + printf '%s\n' "$NIGHTLIES_RSYNC_KNOWN_HOSTS" > "$ssh_directory/known_hosts" + { + echo "NIGHTLIES_RSYNC_TARGET=$NIGHTLIES_RSYNC_USER@$NIGHTLIES_RSYNC_HOST:${NIGHTLIES_RSYNC_PATH%/}/maka/desktop" + echo "RSYNC_RSH=ssh -i $ssh_directory/key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$ssh_directory/known_hosts -p $NIGHTLIES_RSYNC_PORT" + } >> "$GITHUB_ENV" + + - name: Publish immutable Nightly payloads + run: rsync -rlptDvz --protect-args .nightly-publish/versions/ "$NIGHTLIES_RSYNC_TARGET/versions/" + + - name: Advance the Nightly update feed last + run: rsync -rlptDvz --protect-args .nightly-publish/feed/ "$NIGHTLIES_RSYNC_TARGET/" + + - name: Remove the temporary Nightlies credentials + if: always() + run: rm -f "$RUNNER_TEMP/nightlies-ssh/key" "$RUNNER_TEMP/nightlies-ssh/known_hosts" diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 3235bedac6..7893be54d9 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -18,24 +18,29 @@ */ import { readFileSync } from 'node:fs'; +import { + DESKTOP_NIGHTLY_FEED_URL, + resolveDesktopBuildVersion, +} from '../../scripts/desktop-nightly.mjs'; import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; function readManifest(relativePath) { return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')); } +const rootManifest = readManifest('../../package.json'); const { runtimeHostSetupPackage } = resolveProductManifestIdentity({ - rootManifest: readManifest('../../package.json'), + rootManifest, desktopManifest: readManifest('./package.json'), cliManifest: readManifest('../../packages/cli/package.json'), }); -export default { +const baseDesktopBuilderConfig = { appId: 'com.maka.desktop', productName: 'Maka', artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, - extraMetadata: { runtimeHostSetupPackage }, + extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' }, directories: { output: 'release', }, @@ -249,3 +254,20 @@ export default { }, ], }; + +export function resolveDesktopBuilderConfig(environment = process.env) { + const nightlyVersion = environment.MAKA_DESKTOP_NIGHTLY_VERSION?.trim(); + if (!nightlyVersion) return baseDesktopBuilderConfig; + const version = resolveDesktopBuildVersion(rootManifest.version, environment); + return { + ...baseDesktopBuilderConfig, + extraMetadata: { + ...baseDesktopBuilderConfig.extraMetadata, + version, + makaUpdateChannel: 'nightly', + }, + publish: [{ provider: 'generic', url: DESKTOP_NIGHTLY_FEED_URL }], + }; +} + +export default resolveDesktopBuilderConfig(); diff --git a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts index fae7d8d79a..0cf9e7c523 100644 --- a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts @@ -24,7 +24,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { verifyDownloadedUpdateAttestation } from '../app-update-attestation.js'; +import { + desktopUpdateChannelFromManifest, + verifyDownloadedUpdateAttestation, +} from '../app-update-attestation.js'; function provenanceBundle(name: string, sha256: string): Bundle { const statement = { @@ -104,3 +107,59 @@ test('download verification accepts only a trusted exact artifact subject', asyn /untrusted workflow identity/u, ); }); + +test('nightly verification fetches provenance from the versioned Nightlies path', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-nightly-attestation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const artifact = join(directory, 'cached-update.zip'); + const bytes = Buffer.from('nightly update bytes'); + await writeFile(artifact, bytes); + const version = '0.2.0-dev.20260829.42'; + const name = `Maka-${version}-mac-arm64.zip`; + const digest = createHash('sha256').update(bytes).digest('hex'); + const bundle = provenanceBundle(name, digest); + let fetchedUrl = ''; + + await verifyDownloadedUpdateAttestation({ + channel: 'nightly', + downloadedFile: artifact, + version, + platform: 'darwin', + arch: 'arm64', + trustRootCacheDirectory: join(directory, 'trust'), + fetchBundle: async (url) => { + fetchedUrl = url; + return Buffer.from(JSON.stringify({ + mediaType: bundle.mediaType, + verificationMaterial: { + certificate: { rawBytes: Buffer.from('fixture certificate').toString('base64') }, + tlogEntries: [], + }, + dsseEnvelope: { + payloadType: bundle.content.$case === 'dsseEnvelope' + ? bundle.content.dsseEnvelope.payloadType + : '', + payload: bundle.content.$case === 'dsseEnvelope' + ? Buffer.from(bundle.content.dsseEnvelope.payload).toString('base64') + : '', + signatures: [{ sig: Buffer.from('fixture signature').toString('base64') }], + }, + })); + }, + verifyBundle: async () => {}, + }); + + assert.equal( + fetchedUrl, + `https://nightlies.apache.org/maka/desktop/versions/${version}/Maka-${version}-attestation.sigstore.json`, + ); +}); + +test('packaged update trust accepts only an explicit release or nightly channel', () => { + assert.equal(desktopUpdateChannelFromManifest({ makaUpdateChannel: 'release' }), 'release'); + assert.equal(desktopUpdateChannelFromManifest({ makaUpdateChannel: 'nightly' }), 'nightly'); + assert.throws( + () => desktopUpdateChannelFromManifest({ makaUpdateChannel: 'preview' }), + /does not declare a trusted update channel/u, + ); +}); diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index 3206ca6608..bae3519c73 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -130,6 +130,7 @@ function createHarness(input: { mockLatestVersion?: string; mockState?: 'available' | 'downloading' | 'downloaded'; testFeedUrl?: string; + updateChannel?: 'release' | 'nightly'; verifyDownloadedUpdate?: DownloadedUpdateAttestationVerifier; } = {}) { const updater = input.updater ?? new FakeUpdater(); @@ -137,6 +138,7 @@ function createHarness(input: { const service = createAppUpdateService({ currentVersion: '1.0.0', isPackaged: input.isPackaged ?? true, + updateChannel: input.updateChannel ?? 'release', updater: updater as unknown as AppUpdater, clock, onStatusChange: input.onStatusChange, @@ -176,13 +178,15 @@ describe('AppUpdateService', () => { assert.equal(clock.pending().length, 0); }); - test('preserves electron-updater channel policy derived from the app version', () => { - for (const allowPrerelease of [false, true]) { - const updater = new FakeUpdater(); - updater.allowPrerelease = allowPrerelease; - createHarness({ updater }); - assert.equal(updater.allowPrerelease, allowPrerelease); - } + test('accepts dev updates only in packaged Nightly builds', () => { + const releaseUpdater = new FakeUpdater(); + const nightlyUpdater = new FakeUpdater(); + + createHarness({ updater: releaseUpdater, updateChannel: 'release' }); + createHarness({ updater: nightlyUpdater, updateChannel: 'nightly' }); + + assert.equal(releaseUpdater.allowPrerelease, false); + assert.equal(nightlyUpdater.allowPrerelease, true); }); test('routes the feed to a loopback generic provider when the test override is set', () => { diff --git a/apps/desktop/src/main/app-update-attestation.ts b/apps/desktop/src/main/app-update-attestation.ts index ea0e550dbc..d92c3f05de 100644 --- a/apps/desktop/src/main/app-update-attestation.ts +++ b/apps/desktop/src/main/app-update-attestation.ts @@ -25,11 +25,8 @@ import { createReadStream } from 'node:fs'; const PRODUCT_REPOSITORY = 'apache/maka'; const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml'; -const PRODUCT_RELEASE_SIGNER = new RegExp( - `^https://github\\.com/${PRODUCT_REPOSITORY.replace('/', '\\/')}/${PRODUCT_RELEASE_WORKFLOW.replaceAll('.', '\\.')}` + - '@refs/heads/main$', - 'u', -); +const PRODUCT_NIGHTLY_WORKFLOW = '.github/workflows/desktop-nightly.yml'; +const PRODUCT_NIGHTLY_BASE_URL = 'https://nightlies.apache.org/maka/desktop'; const GITHUB_ACTIONS_OIDC_ISSUER = 'https://token.actions.githubusercontent.com'; const IN_TOTO_STATEMENT_V1 = 'https://in-toto.io/Statement/v1'; const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1'; @@ -56,6 +53,7 @@ export type DownloadedUpdateAttestationVerifier = ( ) => Promise; type VerifyDownloadedUpdateAttestationOptions = DownloadedUpdateAttestationInput & { + readonly channel?: DesktopUpdateChannel; readonly trustRootCacheDirectory: string; readonly platform?: NodeJS.Platform; readonly arch?: string; @@ -63,6 +61,28 @@ type VerifyDownloadedUpdateAttestationOptions = DownloadedUpdateAttestationInput readonly verifyBundle?: (bundle: Bundle) => Promise; }; +export type DesktopUpdateChannel = 'release' | 'nightly'; + +export function desktopUpdateChannelFromManifest(manifest: unknown): DesktopUpdateChannel { + const channel = + manifest && typeof manifest === 'object' + ? (manifest as { makaUpdateChannel?: unknown }).makaUpdateChannel + : undefined; + if (channel !== 'release' && channel !== 'nightly') { + throw new Error('Packaged Desktop does not declare a trusted update channel'); + } + return channel; +} + +function productWorkflowSigner(channel: DesktopUpdateChannel): RegExp { + const workflow = channel === 'nightly' ? PRODUCT_NIGHTLY_WORKFLOW : PRODUCT_RELEASE_WORKFLOW; + return new RegExp( + `^https://github\\.com/${PRODUCT_REPOSITORY.replace('/', '\\/')}/${workflow.replaceAll('.', '\\.')}` + + '@refs/heads/main$', + 'u', + ); +} + function exactDesktopUpdateArtifactName( version: string, platform: NodeJS.Platform, @@ -80,9 +100,15 @@ function productReleaseAttestationName(version: string): string { return `Maka-${version}-attestation.sigstore.json`; } -function productReleaseAttestationUrl(version: string): string { - const tag = `v${version}`; +function productReleaseAttestationUrl( + version: string, + channel: DesktopUpdateChannel, +): string { const name = productReleaseAttestationName(version); + if (channel === 'nightly') { + return `${PRODUCT_NIGHTLY_BASE_URL}/versions/${encodeURIComponent(version)}/${encodeURIComponent(name)}`; + } + const tag = `v${version}`; return `https://github.com/${PRODUCT_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; } @@ -193,9 +219,10 @@ export async function verifyDownloadedUpdateAttestation( options.platform ?? process.platform, options.arch ?? process.arch, ); + const channel = options.channel ?? 'release'; const [artifactSha256, bundleBytes] = await Promise.all([ sha256File(options.downloadedFile), - (options.fetchBundle ?? fetchBytesCapped)(productReleaseAttestationUrl(version)), + (options.fetchBundle ?? fetchBytesCapped)(productReleaseAttestationUrl(version, channel)), ]); const bundle = parseBundle(bundleBytes); @@ -208,7 +235,7 @@ export async function verifyDownloadedUpdateAttestation( }); const verifier = new Verifier(toTrustMaterial(trustedRoot)); verifier.verify(toSignedEntity(bundle), { - subjectAlternativeName: PRODUCT_RELEASE_SIGNER, + subjectAlternativeName: productWorkflowSigner(channel), extensions: { issuer: GITHUB_ACTIONS_OIDC_ISSUER }, }); } diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index 2cf9c93743..2ae2e4fabc 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -21,6 +21,7 @@ import electronUpdater from 'electron-updater'; import type { AppUpdater, UpdateCheckResult } from 'electron-updater'; import type { ProgressInfo, UpdateInfo } from 'electron-updater'; import type { DownloadedUpdateAttestationVerifier } from './app-update-attestation.js'; +import type { DesktopUpdateChannel } from './app-update-attestation.js'; import { resolveUpdateFeedOverride } from './app-update-test-context.js'; export type AppUpdateProgress = { @@ -88,6 +89,7 @@ export interface AppUpdateService { interface AppUpdateServiceDeps { currentVersion: string; isPackaged: boolean; + updateChannel?: DesktopUpdateChannel; updater?: AppUpdater; /** * Harness-only feed override (`MAKA_UPDATE_TEST_FEED`); see @@ -290,6 +292,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer updater.autoDownload = true; updater.autoInstallOnAppQuit = false; + updater.allowPrerelease = deps.updateChannel === 'nightly'; updater.logger = null; const testFeed = resolveUpdateFeedOverride(deps.testFeedUrl); // Production reads electron-builder's packaged app-update.yml. Only the diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 3673f39b43..a90b5baf11 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -29,6 +29,7 @@ import { type MessageBoxReturnValue, } from "electron"; import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; import { basename, join } from "node:path"; import { type ConnectionEvent } from '@maka/core/connections'; import { type SessionChangedEvent, type SessionChangedReason } from '@maka/core/session'; @@ -66,7 +67,10 @@ import { resolveStorageRoot } from "@maka/storage/root-authority"; import { createMcpOAuthController } from "./mcp-oauth-controller.js"; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; -import { verifyDownloadedUpdateAttestation } from "./app-update-attestation.js"; +import { + desktopUpdateChannelFromManifest, + verifyDownloadedUpdateAttestation, +} from "./app-update-attestation.js"; import { createAppUpdateService } from "./app-update-service.js"; import { createAttachmentApprovalRegistry } from "./attachment-approval.js"; import { renderAttachmentPreview, resizeImageForAttachment } from "./attachment-resize-native.js"; @@ -710,9 +714,15 @@ const updateMockState = ? process.env.MAKA_UPDATE_MOCK_STATE : undefined; const updateTestFeed = process.env.MAKA_UPDATE_TEST_FEED; +const desktopUpdateChannel = app.isPackaged + ? desktopUpdateChannelFromManifest( + JSON.parse(readFileSync(join(app.getAppPath(), "package.json"), "utf8")), + ) + : "release"; const updateService = createAppUpdateService({ currentVersion: app.getVersion(), isPackaged: app.isPackaged, + updateChannel: desktopUpdateChannel, testFeedUrl: updateTestFeed, mockLatestVersion: process.env.MAKA_UPDATE_MOCK_VERSION, mockState: updateMockState, @@ -726,6 +736,7 @@ const updateService = createAppUpdateService({ ? async () => {} : ({ downloadedFile, version }) => verifyDownloadedUpdateAttestation({ + channel: desktopUpdateChannel, downloadedFile, version, trustRootCacheDirectory: join(userDataDir, "update-trust", "sigstore"), diff --git a/package.json b/package.json index 3d960adda0..4d37afc8cd 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:macos-autoupdate-next": "node scripts/package-macos-autoupdate-next.mjs", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 9e4f034f5b..a147af6210 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -43,6 +43,7 @@ const RELEASE_CONTRACT_FILES = new Set([ 'apps/desktop/electron-builder.config.mjs', 'apps/desktop/package.json', '.github/workflows/cli-package-validation.yml', + '.github/workflows/desktop-nightly.yml', '.github/workflows/release-cli-finalize.yml', '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', @@ -146,6 +147,7 @@ function isCliPackagePath(path) { function isReleaseContractPath(path) { return ( RELEASE_CONTRACT_FILES.has(path) || + path.startsWith('scripts/desktop-nightly') || path.startsWith('scripts/product-release-') || path.startsWith('scripts/release-cli-') ); diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 2ee7e3b524..1da1e0afd4 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -167,6 +167,18 @@ test('release authority changes select their dedicated contract gate', () => { assert.equal(planTests(['.github/RELEASE_CHECKLIST.md'], { graph }).releaseContract, false); }); +test('Desktop Nightly authority changes select the release contract gate', () => { + for (const path of [ + '.github/workflows/desktop-nightly.yml', + 'scripts/desktop-nightly.mjs', + 'scripts/desktop-nightly.test.mjs', + 'scripts/desktop-nightly-stage.test.mjs', + 'scripts/desktop-nightly-workflow-policy.test.mjs', + ]) { + assert.equal(planTests([path], { graph }).releaseContract, true, path); + } +}); + // Both notices are committed generator output. A hand edit or a merge-conflict // resolution can corrupt either one, and `check:release` is what regenerates // and diffs them, so both must reach that gate — the desktop notice lives diff --git a/scripts/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs new file mode 100644 index 0000000000..9871f17ca5 --- /dev/null +++ b/scripts/desktop-nightly-stage.test.mjs @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { stringify } from 'yaml'; +import { stageDesktopNightly } from './desktop-nightly.mjs'; +import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; + +async function writeUpdateSet(directory, version, platform) { + const isMac = platform === 'mac'; + const artifact = isMac ? `Maka-${version}-mac-arm64.zip` : `Maka-${version}-win-x64.exe`; + const metadata = isMac ? 'latest-mac.yml' : 'latest.yml'; + const bytes = Buffer.from(`${platform} nightly bytes`); + const sha512 = createHash('sha512').update(bytes).digest('base64'); + await writeFile(join(directory, artifact), bytes); + await writeFile(join(directory, `${artifact}.blockmap`), `${platform} blockmap`); + await writeFile( + join(directory, metadata), + stringify({ + version, + files: [{ url: artifact, sha512, size: bytes.byteLength }], + path: artifact, + sha512, + releaseDate: '2026-08-29T18:17:00.000Z', + }), + ); +} + +test('staging separates append-only payloads from the mutable Nightly feed', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-')); + t.after(() => rm(root, { recursive: true, force: true })); + const input = join(root, 'input'); + const output = join(root, 'output'); + const version = '0.2.0-dev.20260829.42'; + await mkdir(input); + await Promise.all([ + writeUpdateSet(input, version, 'mac'), + writeUpdateSet(input, version, 'win'), + writeFile(join(input, `Maka-${version}-mac-arm64.dmg`), 'dmg'), + writeFile(join(input, `Maka-${version}-win-x64.zip`), 'windows zip'), + ]); + + await stageDesktopNightly({ + inputDirectory: input, + outputDirectory: output, + version, + sourceCommit: 'a'.repeat(40), + }); + + const payloadNames = [ + `Maka-${version}-mac-arm64.dmg`, + `Maka-${version}-mac-arm64.zip`, + `Maka-${version}-mac-arm64.zip.blockmap`, + `Maka-${version}-win-x64.exe`, + `Maka-${version}-win-x64.exe.blockmap`, + `Maka-${version}-win-x64.zip`, + ]; + for (const name of payloadNames) { + assert.deepEqual( + await readFile(join(output, 'versions', version, name)), + await readFile(join(input, name)), + name, + ); + } + await Promise.all([ + verifyDesktopUpdateArtifacts({ + directory: output, + metadataName: 'feed/latest-mac.yml', + version, + artifactName: `versions/${version}/Maka-${version}-mac-arm64.zip`, + }), + verifyDesktopUpdateArtifacts({ + directory: output, + metadataName: 'feed/latest.yml', + version, + artifactName: `versions/${version}/Maka-${version}-win-x64.exe`, + }), + ]); + + const macMetadata = (await import('yaml')).parse( + await readFile(join(output, 'feed', 'latest-mac.yml'), 'utf8'), + ); + const windowsMetadata = (await import('yaml')).parse( + await readFile(join(output, 'feed', 'latest.yml'), 'utf8'), + ); + assert.equal(macMetadata.files[0].url, `versions/${version}/Maka-${version}-mac-arm64.zip`); + assert.equal(windowsMetadata.path, `versions/${version}/Maka-${version}-win-x64.exe`); + const index = await readFile(join(output, 'feed', 'index.html'), 'utf8'); + assert.match(index, /Desktop Nightly is a developer snapshot, not an Apache release/u); + assert.match(index, new RegExp(`source commit ${'a'.repeat(40)}`, 'u')); + assert.match(index, new RegExp(`versions/${version}/Maka-${version}-mac-arm64\.dmg`, 'u')); + assert.match(index, new RegExp(`versions/${version}/Maka-${version}-win-x64\.exe`, 'u')); + assert.deepEqual((await readdir(join(output, 'feed'))).sort(), [ + 'index.html', + 'latest-mac.yml', + 'latest.yml', + ]); + assert.deepEqual((await readdir(join(output, 'versions', version))).sort(), payloadNames); +}); diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs new file mode 100644 index 0000000000..cc6e183215 --- /dev/null +++ b/scripts/desktop-nightly-workflow-policy.test.mjs @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { parse } from 'yaml'; + +const workflowPath = new URL('../.github/workflows/desktop-nightly.yml', import.meta.url); + +async function readWorkflow() { + return parse(await readFile(workflowPath, 'utf8')); +} + +test('a failed Nightly is retried only as a fresh workflow run', async () => { + const workflow = await readWorkflow(); + assert.deepEqual(workflow.concurrency, { + group: 'desktop-nightly', + 'cancel-in-progress': false, + }); + assert.equal(workflow.jobs.identity.if, "vars.DESKTOP_NIGHTLY_ENABLED == 'true'"); + for (const jobName of ['identity', 'desktop', 'publish']) { + const rerunGuard = workflow.jobs[jobName].steps[0]; + assert.equal(rerunGuard.name, 'Reject in-place workflow reruns'); + assert.equal(rerunGuard.if, 'github.run_attempt != 1'); + assert.equal(spawnSync('bash', ['-c', rerunGuard.run]).status, 1); + } + assert.equal(workflow.jobs.desktop.if, undefined); + assert.equal(workflow.jobs.publish.if, undefined); + const upload = workflow.jobs.desktop.steps.find((step) => + step.uses?.startsWith('actions/upload-artifact@'), + ); + const download = workflow.jobs.publish.steps.find((step) => + step.uses?.startsWith('actions/download-artifact@'), + ); + assert.equal(upload.with.name, 'desktop-nightly-${{ matrix.platform }}'); + assert.equal(download.with.pattern, 'desktop-nightly-*'); +}); + +test('the protected publisher appends workspace-staged payloads before advancing the feed', async () => { + const workflow = await readWorkflow(); + const publish = workflow.jobs.publish; + assert.equal(publish.environment, 'nightly'); + assert.equal( + publish.steps.filter((step) => step.uses?.startsWith('burnett01/rsync-deployments@')).length, + 0, + ); + const transport = publish.steps.find( + (step) => step.name === 'Prepare authenticated Nightlies SSH transport', + ); + assert.equal(transport.env.NIGHTLIES_RSYNC_KEY, '${{ secrets.NIGHTLIES_RSYNC_KEY }}'); + assert.equal( + transport.env.NIGHTLIES_RSYNC_KNOWN_HOSTS, + '${{ secrets.NIGHTLIES_RSYNC_KNOWN_HOSTS }}', + ); + assert.match(transport.run, /StrictHostKeyChecking=yes/u); + assert.doesNotMatch(transport.run, /ssh-keyscan|StrictHostKeyChecking=no/u); + const transfers = [ + 'Publish immutable Nightly payloads', + 'Advance the Nightly update feed last', + ].map((name) => publish.steps.find((step) => step.name === name)); + assert.deepEqual( + transfers.map((step) => step.env?.NIGHTLIES_RSYNC_KEY), + [undefined, undefined], + ); + for (const step of transfers) { + assert.match(step.run, /^rsync -rlptDvz --protect-args /u); + assert.doesNotMatch(step.run, /--delete/u); + } +}); + +test('Nightly stays disabled until its external publishing authority is configured', async () => { + const workflow = await readWorkflow(); + assert.equal(workflow.permissions.contents, 'read'); + assert.equal(workflow.jobs.identity.if, "vars.DESKTOP_NIGHTLY_ENABLED == 'true'"); + const branchGate = workflow.jobs.identity.steps.find( + (step) => step.name === 'Require the Apache main branch', + ); + assert.match(branchGate.run, /test "\$GITHUB_REPOSITORY" = apache\/maka/u); + assert.match(branchGate.run, /test "\$GITHUB_REF" = refs\/heads\/main/u); + assert.equal(workflow.jobs.desktop.environment, 'nightly'); + assert.equal(workflow.jobs.publish.environment, 'nightly'); +}); + +test('Nightly verifies provenance and advances mutable feeds only after payload upload', async () => { + const workflow = await readWorkflow(); + const steps = workflow.jobs.publish.steps; + const positions = [ + 'Attest the exact Nightly payloads', + 'Verify the issued Nightly provenance', + 'Publish immutable Nightly payloads', + 'Advance the Nightly update feed last', + ].map((name) => steps.findIndex((step) => step.name === name)); + assert.deepEqual( + positions, + positions.toSorted((left, right) => left - right), + ); + assert.ok(positions.every((position) => position >= 0)); + const verify = steps[positions[1]]; + assert.equal( + verify.env.CERTIFICATE_IDENTITY, + 'https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main', + ); + assert.match(verify.run, /gh attestation verify/u); +}); diff --git a/scripts/desktop-nightly.mjs b/scripts/desktop-nightly.mjs new file mode 100644 index 0000000000..b8235449b1 --- /dev/null +++ b/scripts/desktop-nightly.mjs @@ -0,0 +1,238 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + appendFile, + copyFile, + mkdir, + readFile, + readdir, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; +import { parseProductReleaseVersion } from './release-version.mjs'; + +export const DESKTOP_NIGHTLY_FEED_URL = 'https://nightlies.apache.org/maka/desktop/'; +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export function assertDesktopNightlyVersion(version, productVersion) { + const product = parseProductReleaseVersion(productVersion); + const nightly = parseProductReleaseVersion(version); + if (product.prerelease.length > 0) { + throw new Error('Desktop Nightly requires a stable checked-in product version'); + } + if ( + nightly.core.some((identifier, index) => identifier !== product.core[index]) || + nightly.prerelease.length !== 3 || + nightly.prerelease[0] !== 'dev' || + !/^\d{8}$/u.test(nightly.prerelease[1]) || + !/^[1-9]\d*$/u.test(nightly.prerelease[2]) + ) { + throw new Error(`Desktop Nightly version ${version} must be a dev build of ${productVersion}`); + } + return version; +} + +export function resolveDesktopBuildVersion(productVersion, environment = process.env) { + const nightlyVersion = environment.MAKA_DESKTOP_NIGHTLY_VERSION?.trim(); + return nightlyVersion + ? assertDesktopNightlyVersion(nightlyVersion, productVersion) + : productVersion; +} + +function nightlyArtifactNames(version) { + return { + macZip: `Maka-${version}-mac-arm64.zip`, + macDmg: `Maka-${version}-mac-arm64.dmg`, + windowsExe: `Maka-${version}-win-x64.exe`, + windowsZip: `Maka-${version}-win-x64.zip`, + }; +} + +async function rewriteNightlyMetadata(source, destination, version) { + const { parse, stringify } = await import('yaml'); + const metadata = parse(await readFile(source, 'utf8')); + const prefix = `versions/${version}/`; + metadata.path = `${prefix}${metadata.path}`; + metadata.files = metadata.files.map((file) => ({ + ...file, + url: `${prefix}${file.url}`, + })); + await writeFile(destination, stringify(metadata), 'utf8'); +} + +function nightlyIndex(version, sourceCommit, names) { + return ` + +Maka Desktop Nightly + +
+

Maka Desktop Nightly

+

Desktop Nightly is a developer snapshot, not an Apache release. It may be unstable and its files are temporary.

+

Version ${version}, built from source commit ${sourceCommit}.

+ +

Installed Nightly builds update automatically from this channel.

+
+ + +`; +} + +export async function stageDesktopNightly({ + inputDirectory, + outputDirectory, + version, + sourceCommit, +}) { + const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + assertDesktopNightlyVersion(version, productManifest.version); + if (typeof sourceCommit !== 'string' || !/^[0-9a-f]{40}$/u.test(sourceCommit)) { + throw new Error('Desktop Nightly requires an exact source commit'); + } + const names = nightlyArtifactNames(version); + const payloads = [ + names.macDmg, + names.macZip, + `${names.macZip}.blockmap`, + names.windowsExe, + `${names.windowsExe}.blockmap`, + names.windowsZip, + ]; + const metadataNames = ['latest-mac.yml', 'latest.yml']; + const expected = [...payloads, ...metadataNames].sort(); + const actual = (await readdir(inputDirectory)).sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Desktop Nightly input is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, + ); + } + + await Promise.all([ + verifyDesktopUpdateArtifacts({ + directory: inputDirectory, + metadataName: 'latest-mac.yml', + version, + artifactName: names.macZip, + }), + verifyDesktopUpdateArtifacts({ + directory: inputDirectory, + metadataName: 'latest.yml', + version, + artifactName: names.windowsExe, + }), + ]); + + await rm(outputDirectory, { recursive: true, force: true }); + const versionDirectory = join(outputDirectory, 'versions', version); + const feedDirectory = join(outputDirectory, 'feed'); + await Promise.all([ + mkdir(versionDirectory, { recursive: true }), + mkdir(feedDirectory, { recursive: true }), + ]); + await Promise.all( + payloads.map(async (name) => { + const source = join(inputDirectory, name); + const info = await stat(source); + if (!info.isFile()) throw new Error(`Desktop Nightly payload is not a file: ${source}`); + await copyFile(source, join(versionDirectory, name)); + }), + ); + await Promise.all([ + rewriteNightlyMetadata( + join(inputDirectory, 'latest-mac.yml'), + join(feedDirectory, 'latest-mac.yml'), + version, + ), + rewriteNightlyMetadata( + join(inputDirectory, 'latest.yml'), + join(feedDirectory, 'latest.yml'), + version, + ), + ]); + await writeFile(join(feedDirectory, 'index.html'), nightlyIndex(version, sourceCommit, names)); +} + +async function main(args, environment = process.env) { + const [command, ...rest] = args; + if (command === 'identity' && rest.length === 0) { + const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + const identity = desktopNightlyIdentity({ + productVersion: productManifest.version, + date: new Date(environment.NIGHTLY_BUILD_DATE ?? Date.now()), + runNumber: environment.GITHUB_RUN_NUMBER, + sourceCommit: environment.GITHUB_SHA, + }); + if (environment.GITHUB_OUTPUT) { + await appendFile( + environment.GITHUB_OUTPUT, + `version=${identity.version}\nsource_commit=${identity.sourceCommit}\n`, + 'utf8', + ); + } + console.log(JSON.stringify(identity)); + return; + } + if (command === 'stage' && rest.length === 4) { + const [inputDirectory, outputDirectory, version, sourceCommit] = rest; + await stageDesktopNightly({ + inputDirectory, + outputDirectory, + version, + sourceCommit, + }); + return; + } + throw new Error( + 'usage: desktop-nightly.mjs identity | stage ', + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(process.argv.slice(2)); +} + +export function desktopNightlyIdentity({ productVersion, date, runNumber, sourceCommit }) { + if (parseProductReleaseVersion(productVersion).prerelease.length > 0) { + throw new Error('Desktop Nightly requires a stable checked-in product version'); + } + if (!(date instanceof Date) || !Number.isFinite(date.getTime())) { + throw new Error('Desktop Nightly requires a valid build date'); + } + if (typeof runNumber !== 'string' || !/^[1-9]\d*$/u.test(runNumber)) { + throw new Error('Desktop Nightly requires a positive run number'); + } + if (typeof sourceCommit !== 'string' || !/^[0-9a-f]{40}$/u.test(sourceCommit)) { + throw new Error('Desktop Nightly requires an exact source commit'); + } + + const day = date.toISOString().slice(0, 10).replaceAll('-', ''); + const version = `${productVersion}-dev.${day}.${runNumber}`; + assertDesktopNightlyVersion(version, productVersion); + return { + version, + sourceCommit, + }; +} diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs new file mode 100644 index 0000000000..76922eac7a --- /dev/null +++ b/scripts/desktop-nightly.test.mjs @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { resolveDesktopBuilderConfig } from '../apps/desktop/electron-builder.config.mjs'; +import { desktopNightlyIdentity, resolveDesktopBuildVersion } from './desktop-nightly.mjs'; + +const run = promisify(execFile); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +test('a nightly identity is a dev build of the checked-in product version', () => { + assert.deepEqual( + desktopNightlyIdentity({ + productVersion: '0.2.0', + date: new Date('2026-08-29T18:17:00Z'), + runNumber: '42', + sourceCommit: 'a'.repeat(40), + }), + { + version: '0.2.0-dev.20260829.42', + sourceCommit: 'a'.repeat(40), + }, + ); +}); + +test('the identity entrypoint runs before repository dependencies are installed', async (t) => { + const fixture = await mkdtemp(join(tmpdir(), 'maka-nightly-identity-')); + t.after(() => rm(fixture, { recursive: true, force: true })); + await mkdir(join(fixture, 'scripts')); + await Promise.all([ + copyFile(join(repoRoot, 'package.json'), join(fixture, 'package.json')), + copyFile( + join(repoRoot, 'scripts', 'desktop-nightly.mjs'), + join(fixture, 'scripts', 'desktop-nightly.mjs'), + ), + copyFile( + join(repoRoot, 'scripts', 'desktop-update-contract.mjs'), + join(fixture, 'scripts', 'desktop-update-contract.mjs'), + ), + copyFile( + join(repoRoot, 'scripts', 'release-version.mjs'), + join(fixture, 'scripts', 'release-version.mjs'), + ), + ]); + + const { stdout } = await run(process.execPath, ['scripts/desktop-nightly.mjs', 'identity'], { + cwd: fixture, + env: { + GITHUB_RUN_NUMBER: '42', + GITHUB_SHA: 'a'.repeat(40), + NIGHTLY_BUILD_DATE: '2026-08-29T18:17:00Z', + }, + }); + assert.deepEqual(JSON.parse(stdout), { + version: '0.2.0-dev.20260829.42', + sourceCommit: 'a'.repeat(40), + }); +}); + +test('a nightly package embeds only the Apache Nightlies update authority', () => { + const version = '0.2.0-dev.20260829.42'; + const config = resolveDesktopBuilderConfig({ + MAKA_DESKTOP_NIGHTLY_VERSION: version, + }); + + assert.equal(config.extraMetadata.version, version); + assert.equal(config.extraMetadata.makaUpdateChannel, 'nightly'); + assert.deepEqual(config.publish, [ + { + provider: 'generic', + url: 'https://nightlies.apache.org/maka/desktop/', + }, + ]); +}); + +test('formal release checks ignore the ambient Nightly packaging environment', async () => { + const { NODE_TEST_CONTEXT: _nodeTestContext, ...environment } = process.env; + await run( + process.execPath, + [ + '--test', + '--test-name-pattern=Desktop packaging derives|platform package verifiers', + 'scripts/product-release.test.mjs', + ], + { + cwd: repoRoot, + env: { + ...environment, + MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.20260829.42', + }, + }, + ); +}); + +test('packaging observes a valid nightly version without changing product manifests', () => { + assert.equal( + resolveDesktopBuildVersion('0.2.0', { + MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.20260829.42', + }), + '0.2.0-dev.20260829.42', + ); + assert.equal(resolveDesktopBuildVersion('0.2.0', {}), '0.2.0'); +}); diff --git a/scripts/desktop-update-contract.mjs b/scripts/desktop-update-contract.mjs index 35b5004732..6d2279815f 100644 --- a/scripts/desktop-update-contract.mjs +++ b/scripts/desktop-update-contract.mjs @@ -30,6 +30,11 @@ export const DESKTOP_UPDATE_PROVIDER = Object.freeze({ repo: 'maka', updaterCacheDirName: '@makadesktop-updater', }); +export const DESKTOP_NIGHTLY_UPDATE_PROVIDER = Object.freeze({ + provider: 'generic', + url: 'https://nightlies.apache.org/maka/desktop/', + updaterCacheDirName: '@makadesktop-updater', +}); /** A stable successor lets stable, alpha, and beta candidates use one feed contract. */ export function bumpedAutoupdateVersion(candidateVersion) { @@ -58,7 +63,10 @@ function requireExactObject(actual, expected, subject) { } /** Proves that a packaged client points at the one production release authority. */ -export async function assertPackagedUpdateConfiguration(resourcesPath, { read = readFile } = {}) { +export async function assertPackagedUpdateConfiguration( + resourcesPath, + { channel = 'release', read = readFile } = {}, +) { const path = join(resourcesPath, 'app-update.yml'); let configuration; try { @@ -66,7 +74,9 @@ export async function assertPackagedUpdateConfiguration(resourcesPath, { read = } catch (error) { throw new Error(`Packaged update configuration is unreadable: ${path}`, { cause: error }); } - requireExactObject(configuration, DESKTOP_UPDATE_PROVIDER, 'Packaged update configuration'); + const expected = + channel === 'nightly' ? DESKTOP_NIGHTLY_UPDATE_PROVIDER : DESKTOP_UPDATE_PROVIDER; + requireExactObject(configuration, expected, 'Packaged update configuration'); return configuration; } diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 078ba0c0bf..47df4b0b5b 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -21,6 +21,7 @@ import { spawn } from 'node:child_process'; import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolveDesktopBuildVersion } from './desktop-nightly.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); @@ -81,8 +82,9 @@ export async function packageMacosArm64({ } const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); - const dmgPath = join(releaseDirectory, `Maka-${manifest.version}-mac-arm64.dmg`); - const zipPath = join(releaseDirectory, `Maka-${manifest.version}-mac-arm64.zip`); + const buildVersion = resolveDesktopBuildVersion(manifest.version, env); + const dmgPath = join(releaseDirectory, `Maka-${buildVersion}-mac-arm64.dmg`); + const zipPath = join(releaseDirectory, `Maka-${buildVersion}-mac-arm64.zip`); const updateMetadataPath = join(releaseDirectory, 'latest-mac.yml'); for (const path of requiredElectronLicensePaths) { diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs index 515d95b870..6ad99bf0c3 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -21,6 +21,7 @@ import { spawn } from 'node:child_process'; import { access, copyFile, mkdir, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolveDesktopBuildVersion } from './desktop-nightly.mjs'; import { npmSpawnOptions } from './npm-spawn.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -84,6 +85,7 @@ export async function packageWindowsX64({ platform = process.platform, arch = process.arch, run = runCommand, + env = process.env, remove = rm, assertFile = access, } = {}) { @@ -92,8 +94,9 @@ export async function packageWindowsX64({ } const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); - const exePath = join(releaseDirectory, `Maka-${manifest.version}-win-x64.exe`); - const zipPath = join(releaseDirectory, `Maka-${manifest.version}-win-x64.zip`); + const buildVersion = resolveDesktopBuildVersion(manifest.version, env); + const exePath = join(releaseDirectory, `Maka-${buildVersion}-win-x64.exe`); + const zipPath = join(releaseDirectory, `Maka-${buildVersion}-win-x64.zip`); const updateMetadataPath = join(releaseDirectory, 'latest.yml'); const unpackedDirectory = join(releaseDirectory, 'win-unpacked'); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 5252bbca7a..aa9d02eb7c 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -25,7 +25,7 @@ import { join } from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; -import desktopBuilderConfig from '../apps/desktop/electron-builder.config.mjs'; +import { resolveDesktopBuilderConfig } from '../apps/desktop/electron-builder.config.mjs'; import { parseAsfSourceReferenceTag, resolveProductManifestIdentity, @@ -56,6 +56,7 @@ import { ensureProductTag } from './product-release-tag.mjs'; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, '..'); +const desktopBuilderConfig = resolveDesktopBuilderConfig({}); const rootManifest = { version: '1.2.3', @@ -192,6 +193,7 @@ test('Desktop packaging derives the Runtime Host setup package from product mani const checkedRootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); assert.deepEqual(desktopBuilderConfig.extraMetadata, { runtimeHostSetupPackage: `maka-agent@${checkedRootManifest.version}`, + makaUpdateChannel: 'release', }); assert.deepEqual(desktopBuilderConfig.publish, [ { provider: 'github', owner: 'apache', repo: 'maka' }, @@ -257,7 +259,7 @@ test('platform package verifiers keep Git checks out of current artifacts', asyn ); assert.match( windowsSource, - /if \(requiresCurrentContract\) \{\s*await assertPackagedUpdateConfiguration\(resources\);\s*await assertPackagedDependencyClosure\(resources\);\s*\}\s*else await requirePath\(join\(resources, ['"]git['"]/u, + /if \(requiresCurrentContract\) \{\s*await assertPackagedUpdateConfiguration\(resources, \{\s*channel: environment\.MAKA_DESKTOP_NIGHTLY_VERSION \? ['"]nightly['"] : ['"]release['"],\s*\}\);\s*await assertPackagedDependencyClosure\(resources\);\s*\}\s*else await requirePath\(join\(resources, ['"]git['"]/u, ); const macosSource = await readFile( diff --git a/scripts/verify-macos-arm64-dmg.mjs b/scripts/verify-macos-arm64-dmg.mjs index d3c9fa77f1..96f1a4ccea 100644 --- a/scripts/verify-macos-arm64-dmg.mjs +++ b/scripts/verify-macos-arm64-dmg.mjs @@ -33,6 +33,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION } from '../packages/runtime/dist/filesystem-worker/protocol.js'; import { readProductManifestIdentity } from './product-release-identity.mjs'; import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; +import { resolveDesktopBuildVersion } from './desktop-nightly.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -126,6 +127,7 @@ export async function verifyPackagedMacApp( smokeRenderer = smokePackagedRenderer, smokeFilesystemWorker = smokePackagedFilesystemWorker, workingDirectory = dirname(appPath), + environment = process.env, } = {}, ) { const product = await readProductManifestIdentity(); @@ -138,8 +140,9 @@ export async function verifyPackagedMacApp( throw new Error(`Expected app id ${expectedAppId}, found ${appId}.`); } const version = await readPlistValue(run, infoPlist, 'CFBundleShortVersionString'); - if (version !== product.version) { - throw new Error(`Expected app version ${product.version}, found ${version}.`); + const expectedVersion = resolveDesktopBuildVersion(product.version, environment); + if (version !== expectedVersion) { + throw new Error(`Expected app version ${expectedVersion}, found ${version}.`); } const executableName = await readPlistValue(run, infoPlist, 'CFBundleExecutable'); const executable = join(contents, 'MacOS', executableName); @@ -148,7 +151,9 @@ export async function verifyPackagedMacApp( await requirePath(executable); await assertPackagedResources(resources, { requirePath, forbidPath }); - await assertPackagedUpdateConfiguration(resources); + await assertPackagedUpdateConfiguration(resources, { + channel: environment.MAKA_DESKTOP_NIGHTLY_VERSION ? 'nightly' : 'release', + }); await assertPackagedDependencyClosure(resources); const executableArchitectures = await run('lipo', ['-archs', executable]); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index e46cf03d8a..e420dddc7b 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -24,6 +24,7 @@ import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { readProductManifestIdentity } from './product-release-identity.mjs'; import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; +import { resolveDesktopBuildVersion } from './desktop-nightly.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -125,6 +126,7 @@ export async function verifyPackagedWindowsApp( workingDirectory = appDirectory, expectedVersion, artifactContract = 'current', + environment = process.env, } = {}, ) { if (artifactContract !== 'current' && artifactContract !== 'legacy-baseline') { @@ -150,7 +152,9 @@ export async function verifyPackagedWindowsApp( requireDirectPeerArtifact: requiresCurrentContract, }); if (requiresCurrentContract) { - await assertPackagedUpdateConfiguration(resources); + await assertPackagedUpdateConfiguration(resources, { + channel: environment.MAKA_DESKTOP_NIGHTLY_VERSION ? 'nightly' : 'release', + }); await assertPackagedDependencyClosure(resources); } else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); @@ -240,7 +244,10 @@ export async function verifyPackagedWindowsApp( run, `(Get-Item -LiteralPath ${powerShellLiteral(executable)}).VersionInfo.ProductVersion`, ); - assertWindowsProductVersion(stdout, expectedVersion ?? product.version); + assertWindowsProductVersion( + stdout, + expectedVersion ?? resolveDesktopBuildVersion(product.version, environment), + ); step('smoking node-pty through conpty'); const ptyProbe = makePtyProbe(