From 3b19c8b32d7d1bd43ca136f64e876d3c96eeb6de Mon Sep 17 00:00:00 2001
From: AstroHan
Date: Mon, 31 Aug 2026 14:33:04 +0800
Subject: [PATCH 1/5] feat(desktop): migrate Nightly to GitHub Releases
Generated-by: Codex
---
.github/DESKTOP_NIGHTLY.md | 14 +-
.github/workflows/desktop-nightly.yml | 144 +++++++--
apps/desktop/electron-builder.config.mjs | 3 +-
.../__tests__/app-update-attestation.test.ts | 4 +-
.../main/__tests__/app-update-service.test.ts | 1 +
.../src/main/app-update-attestation.ts | 6 +-
package.json | 2 +-
scripts/desktop-nightly-release.mjs | 294 +++++++++++++++++
scripts/desktop-nightly-release.test.mjs | 234 ++++++++++++++
scripts/desktop-nightly-stage.test.mjs | 301 ++++++++++++++++--
.../desktop-nightly-workflow-policy.test.mjs | 102 +++---
scripts/desktop-nightly.mjs | 268 ++++++++++++++--
scripts/desktop-nightly.test.mjs | 83 ++++-
scripts/desktop-update-contract.mjs | 7 +-
scripts/product-release-artifacts.mjs | 6 +-
15 files changed, 1311 insertions(+), 158 deletions(-)
create mode 100644 scripts/desktop-nightly-release.mjs
create mode 100644 scripts/desktop-nightly-release.test.mjs
diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md
index 91d2fd5d1f..c1d3221039 100644
--- a/.github/DESKTOP_NIGHTLY.md
+++ b/.github/DESKTOP_NIGHTLY.md
@@ -21,19 +21,23 @@
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 npm publication workflow gives each snapshot an immutable version such as `0.2.0-dev.42.20260829`. The run number is the sole ordering authority. After that exact npm version is public, it triggers Desktop Nightly with a version-only artifact; the authenticated workflow event supplies the exact source commit and upstream run. A packaged Nightly accepts updates only from `https://nightlies.apache.org/maka/desktop/`, advances only to a higher run number, 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.
+The npm publication workflow gives each snapshot an immutable version such as `0.2.0-dev.42.20260829`. The run number is the sole ordering authority. After that exact npm version is public, it triggers Desktop Nightly with a version-only artifact; the authenticated workflow event supplies the exact source commit and upstream run. Each fresh Desktop Nightly creates an immutable `v` tag and one GitHub draft prerelease containing the macOS and Windows packages, blockmaps, `dev-mac.yml`, `dev.yml`, and one offline Sigstore bundle. The workflow verifies every remote asset before it publishes the prerelease as non-Latest. Packaged Nightlies use the GitHub `dev` channel and verify that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build uses the separate stable GitHub Release channel and 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` and whitelist the repository for the standard `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_KEY`, `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_PORT`, and `NIGHTLIES_RSYNC_USER` secrets.
+1. Ask Apache Infra to allow `apache/maka` to publish the one-time compatibility bridge to `nightlies.apache.org` and whitelist the repository for the standard `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_KEY`, `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_PORT`, and `NIGHTLIES_RSYNC_USER` secrets.
2. After the checked-in `.asf.yaml` reaches `main`, verify that ASF reconciliation created the `nightly` GitHub Environment with only `main` permitted and no approval gate. Do not maintain that policy manually in GitHub. Verify that its jobs can read the five Infra-provided Nightlies 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. Configure npm Trusted Publishing for `apache/maka` and `.github/workflows/npm-publication.yml`, restricted to the `npm-publication` Environment and with both `npm publish` and `npm stage publish` allowed. Do not create or store a long-lived npm token.
4. After npm Trusted Publishing is ready, set `NPM_NIGHTLY_ENABLED` to `true`, run `npm publication` from `main` with `channel=nightly`, and verify the exact npm version and `nightly` dist-tag. This does not depend on Desktop Infra.
-5. After Infra publishing and the `nightly` Environment secrets are ready, set `DESKTOP_NIGHTLY_ENABLED` to `true` and start a fresh npm Nightly. Confirm that its successful run triggers `Desktop Nightly`.
-6. 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.
+5. For the one-time Nightlies-to-GitHub cutover, choose the exact `main` commit that the next manually dispatched npm Nightly will build. Set the repository variable `DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA` to that full 40-character commit SHA before dispatching. The Desktop workflow fails closed unless its authenticated source SHA matches this value while the public Nightlies feed still points at Nightlies payloads.
+6. Set `DESKTOP_NIGHTLY_ENABLED` to `true` and manually dispatch a fresh npm Nightly from that exact `main` commit. Confirm that its successful run triggers `Desktop Nightly`. Do not rerun a failed attempt in place.
+7. Verify that `v` points to the exact source SHA and that its GitHub Release is published with Draft off, Prerelease on, Latest off, and exactly the nine expected assets. Verify that the public Nightlies `latest-mac.yml` and `latest.yml` now contain absolute URLs for those GitHub assets, that `versions//Maka--attestation.sigstore.json` serves the same bundle, that the Nightlies index links to GitHub downloads, and that `github-cutover.json` records the same version. This final small file is the bridge completion marker and is written only after every preceding cutover operation succeeds. Test an update from a pre-cutover Nightly on both platforms.
+8. Remove `DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA` after the public Nightlies feed has advanced. Install the cutover build, publish one later fresh Nightly, and confirm a GitHub-to-GitHub differential update on both platforms before sharing the channel with testers.
-The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop applies the same check against both remote feed files before uploading anything. It then appends a new immutable Desktop version directory and advances the mutable update metadata last. 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 npm Nightly so it receives a new version. The Maka project owns historical payload cleanup separately from publication. Cleanup 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.
+The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop separately requires its version to advance the highest valid GitHub `dev` prerelease across retained product versions. It assembles and verifies a draft before one publish mutation; a packaging, attestation, tag, upload, or digest failure leaves no partially published GitHub Release. During the one-time bridge it stages only the compatibility Sigstore bundle on Nightlies, publishes the complete GitHub prerelease, advances the legacy feed and index, and writes the completion marker last. Until that marker exists, any legacy, partial, or otherwise interrupted bridge state remains recoverable by updating the cutover variable to the next fresh source SHA and dispatching a new npm Nightly. Never rerun a failed workflow attempt in place. Once the marker exists, steady-state runs do not use Nightlies SSH or write packages, blockmaps, metadata, attestations, or bridge files there.
+
+GitHub Release retention is intentionally outside this workflow. Do not delete an old Nightly prerelease or its tag while any installed client may need its payload or blockmap; in particular, retain the successful bridge release. Disabling `DESKTOP_NIGHTLY_ENABLED` stops new Desktop publication without mutating tags, releases, or the legacy bridge.
Remote Runtime Host setup uses the exact `maka-agent@` package embedded in the Desktop manifest. The npm package is verified before Desktop artifacts become visible, so clean remote setup never depends on an unpublished Runtime Host version.
diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml
index 8e9fc57db4..034818059b 100644
--- a/.github/workflows/desktop-nightly.yml
+++ b/.github/workflows/desktop-nightly.yml
@@ -184,14 +184,14 @@ jobs:
"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 \
+ apps/desktop/release/dev-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 \
+ apps/desktop/release/dev.yml \
"$STAGE_DIRECTORY/"
fi
@@ -212,7 +212,7 @@ jobs:
permissions:
artifact-metadata: write
attestations: write
- contents: read
+ contents: write
id-token: write
steps:
- name: Reject in-place workflow reruns
@@ -225,6 +225,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.identity.outputs.source_commit }}
+ fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
@@ -243,29 +244,69 @@ jobs:
path: ${{ github.workspace }}/.nightly-input
merge-multiple: true
- - name: Stage the versioned Nightly site
+ - name: Stage the GitHub Release and compatibility bridge
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" \
+ "$GITHUB_WORKSPACE/.nightly-stage" \
"$NIGHTLY_VERSION" \
"$SOURCE_COMMIT"
- - name: Attest the exact Nightly payloads
+ - name: Read the public legacy Nightlies feed
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/.nightly-current-feed"
+ curl --fail --location --retry 3 \
+ --output "$GITHUB_WORKSPACE/.nightly-current-feed/latest-mac.yml" \
+ https://nightlies.apache.org/maka/desktop/latest-mac.yml
+ curl --fail --location --retry 3 \
+ --output "$GITHUB_WORKSPACE/.nightly-current-feed/latest.yml" \
+ https://nightlies.apache.org/maka/desktop/latest.yml
+ marker_status=$(curl --silent --show-error --location --retry 3 \
+ --output "$GITHUB_WORKSPACE/.nightly-current-feed/github-cutover.json" \
+ --write-out '%{http_code}' \
+ https://nightlies.apache.org/maka/desktop/github-cutover.json)
+ if [[ "$marker_status" == 404 ]]; then
+ rm -f "$GITHUB_WORKSPACE/.nightly-current-feed/github-cutover.json"
+ elif [[ "$marker_status" != 200 ]]; then
+ echo "Unexpected Nightlies cutover marker status: $marker_status" >&2
+ exit 1
+ fi
+
+ - name: Read the live GitHub Release history
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: >-
+ gh api --paginate --slurp
+ "repos/$GITHUB_REPOSITORY/releases?per_page=100"
+ > "$RUNNER_TEMP/desktop-nightly-releases.json"
+
+ - name: Resolve the one-time Nightlies cutover
+ id: cutover
+ env:
+ DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA: ${{ vars.DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA }}
+ NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
+ SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
+ run: |
+ node scripts/desktop-nightly.mjs resolve-cutover \
+ "$GITHUB_WORKSPACE/.nightly-current-feed" \
+ "$RUNNER_TEMP/desktop-nightly-releases.json" \
+ "$NIGHTLY_VERSION" \
+ "$SOURCE_COMMIT"
+
+ - name: Attest every GitHub Nightly asset subject
id: attest
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
- subject-path: ${{ github.workspace }}/.nightly-publish/versions/${{ needs.identity.outputs.version }}/*
+ subject-path: ${{ github.workspace }}/.nightly-stage/release/*
- 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
@@ -275,21 +316,45 @@ jobs:
--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
+ done < <(find "$GITHUB_WORKSPACE/.nightly-stage/release" -maxdepth 1 -type f -print0)
+ if (( verified != 8 )); then
+ echo "Expected 8 verified Desktop Nightly subjects, found $verified" >&2
exit 1
fi
- - name: Add the offline provenance bundle
+ - name: Add the one 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"
+ run: |
+ node scripts/desktop-nightly.mjs add-attestation \
+ "$GITHUB_WORKSPACE/.nightly-stage" \
+ "$NIGHTLY_VERSION" \
+ "$ATTESTATION_BUNDLE"
+
+ - name: Ensure the exact immutable Nightly tag
+ env:
+ GH_TOKEN: ${{ github.token }}
+ NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
+ SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
+ run: |
+ gh auth setup-git
+ node scripts/product-release-tag.mjs ensure "v$NIGHTLY_VERSION" "$SOURCE_COMMIT"
+
+ - name: Prepare and verify the draft GitHub Prerelease
+ env:
+ GH_TOKEN: ${{ github.token }}
+ NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
+ SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
+ run: |
+ node scripts/desktop-nightly-release.mjs prepare \
+ "$GITHUB_WORKSPACE/.nightly-stage/release" \
+ "$NIGHTLY_VERSION" \
+ "$SOURCE_COMMIT" \
+ "$GITHUB_REPOSITORY"
- name: Prepare authenticated Nightlies SSH transport
+ if: steps.cutover.outputs.bridge == 'true'
env:
NIGHTLIES_RSYNC_HOST: ${{ secrets.NIGHTLIES_RSYNC_HOST }}
NIGHTLIES_RSYNC_KEY: ${{ secrets.NIGHTLIES_RSYNC_KEY }}
@@ -314,31 +379,44 @@ jobs:
} >> "$GITHUB_ENV"
- name: Ensure the Nightly destination exists
+ if: steps.cutover.outputs.bridge == 'true'
run: |
mkdir -p .nightly-empty/maka/desktop
rsync -rlptDz --protect-args .nightly-empty/maka/ "$NIGHTLIES_RSYNC_BASE/maka/"
- - name: Require the Desktop Nightly feed to advance
+ - name: Stage the legacy cutover attestation
+ if: steps.cutover.outputs.bridge == 'true'
+ run: >-
+ rsync -rlptDvz --protect-args
+ .nightly-stage/bridge/versions/
+ "$NIGHTLIES_RSYNC_TARGET/versions/"
+
+ - name: Publish the complete GitHub Prerelease
env:
+ GH_TOKEN: ${{ github.token }}
NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
+ SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
run: |
- mkdir -p .nightly-current-feed
- rsync -rlptDz --protect-args \
- --include='/latest-mac.yml' \
- --include='/latest.yml' \
- --exclude='*' \
- "$NIGHTLIES_RSYNC_TARGET/" \
- .nightly-current-feed/
- node scripts/desktop-nightly.mjs assert-feed-advance \
- .nightly-current-feed \
- "$NIGHTLY_VERSION"
-
- - 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/"
+ node scripts/desktop-nightly-release.mjs publish \
+ "$GITHUB_WORKSPACE/.nightly-stage/release" \
+ "$NIGHTLY_VERSION" \
+ "$SOURCE_COMMIT" \
+ "$GITHUB_REPOSITORY"
+
+ - name: Advance the legacy Nightlies feed to GitHub
+ if: steps.cutover.outputs.bridge == 'true'
+ run: >-
+ rsync -rlptDvz --protect-args
+ .nightly-stage/bridge/feed/
+ "$NIGHTLIES_RSYNC_TARGET/"
+
+ - name: Commit the completed legacy bridge
+ if: steps.cutover.outputs.bridge == 'true'
+ run: >-
+ rsync -rlptDvz --protect-args
+ .nightly-stage/bridge/completion/github-cutover.json
+ "$NIGHTLIES_RSYNC_TARGET/github-cutover.json"
- name: Remove the temporary Nightlies credentials
- if: always()
+ if: always() && steps.cutover.outputs.bridge == 'true'
run: rm -f "$RUNNER_TEMP/nightlies-ssh/key"
diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs
index 42ac6f2ebf..269d57267a 100644
--- a/apps/desktop/electron-builder.config.mjs
+++ b/apps/desktop/electron-builder.config.mjs
@@ -20,7 +20,6 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
- DESKTOP_NIGHTLY_FEED_URL,
resolveDesktopBuildVersion,
resolveRuntimeHostSetupPackage,
} from '../../scripts/desktop-nightly.mjs';
@@ -287,7 +286,7 @@ export function resolveDesktopBuilderConfig(environment = process.env) {
runtimeHostSetupPackage: resolveRuntimeHostSetupPackage(rootManifest.version, environment),
makaUpdateChannel: 'nightly',
},
- publish: [{ provider: 'generic', url: DESKTOP_NIGHTLY_FEED_URL, channel: 'latest' }],
+ publish: [{ provider: 'github', owner: 'apache', repo: 'maka', channel: 'dev' }],
};
}
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 0cf9e7c523..713d48137f 100644
--- a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts
+++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts
@@ -108,7 +108,7 @@ test('download verification accepts only a trusted exact artifact subject', asyn
);
});
-test('nightly verification fetches provenance from the versioned Nightlies path', async (t) => {
+test('nightly verification fetches provenance from the immutable GitHub Release asset', 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');
@@ -151,7 +151,7 @@ test('nightly verification fetches provenance from the versioned Nightlies path'
assert.equal(
fetchedUrl,
- `https://nightlies.apache.org/maka/desktop/versions/${version}/Maka-${version}-attestation.sigstore.json`,
+ `https://github.com/apache/maka/releases/download/v${version}/Maka-${version}-attestation.sigstore.json`,
);
});
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 bae3519c73..5f1d3f1686 100644
--- a/apps/desktop/src/main/__tests__/app-update-service.test.ts
+++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts
@@ -187,6 +187,7 @@ describe('AppUpdateService', () => {
assert.equal(releaseUpdater.allowPrerelease, false);
assert.equal(nightlyUpdater.allowPrerelease, true);
+ assert.equal(Object.hasOwn(nightlyUpdater, 'channel'), false);
});
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 d92c3f05de..1a7aa9e67a 100644
--- a/apps/desktop/src/main/app-update-attestation.ts
+++ b/apps/desktop/src/main/app-update-attestation.ts
@@ -26,7 +26,6 @@ import { createReadStream } from 'node:fs';
const PRODUCT_REPOSITORY = 'apache/maka';
const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml';
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';
@@ -102,12 +101,9 @@ function productReleaseAttestationName(version: string): string {
function productReleaseAttestationUrl(
version: string,
- channel: DesktopUpdateChannel,
+ _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)}`;
}
diff --git a/package.json b/package.json
index 762424ce31..f16041e72c 100644
--- a/package.json
+++ b/package.json
@@ -74,7 +74,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-nightly.test.mjs 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",
+ "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-nightly.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.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/desktop-nightly-release.mjs b/scripts/desktop-nightly-release.mjs
new file mode 100644
index 0000000000..a343781803
--- /dev/null
+++ b/scripts/desktop-nightly-release.mjs
@@ -0,0 +1,294 @@
+/*
+ * 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 { execFile } from 'node:child_process';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { promisify } from 'node:util';
+import {
+ compareProductReleaseNames,
+ productReleaseArtifactRecords,
+ verifyProductReleaseArtifactDirectory,
+} from './product-release-artifacts.mjs';
+import { remoteProductTagCommit } from './product-release-tag.mjs';
+import { desktopNightlyReleaseAssetNames } from './desktop-nightly.mjs';
+import { parseProductNightlyVersion } from './release-version.mjs';
+
+const execFileAsync = promisify(execFile);
+
+function validateIdentity(version, sourceCommit, repository) {
+ parseProductNightlyVersion(version);
+ if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) {
+ throw new Error('Desktop Nightly source must be an exact commit SHA');
+ }
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
+ throw new Error('Desktop Nightly repository must be an exact owner/name');
+ }
+ return { tag: `v${version}`, version };
+}
+
+function notes(version, sourceCommit) {
+ return `Developer Snapshot ${version}
+
+This Desktop Nightly was built from ${sourceCommit} for development and testing. It is not an Apache Release and has not been approved by an ASF release vote.
+
+The packaged applications carry the repository DISCLAIMER-WIP and Apache License 2.0 materials. They may be unstable and are not intended as a stable release for general users.`;
+}
+
+function snapshotFromView(value) {
+ return {
+ id: value?.databaseId,
+ tag: value?.tagName,
+ draft: value?.isDraft,
+ prerelease: value?.isPrerelease,
+ assets: (value?.assets ?? [])
+ .map(({ name, size, digest }) => ({ name, size, digest }))
+ .sort((left, right) => compareProductReleaseNames(left.name, right.name)),
+ };
+}
+
+function snapshotFromRest(value) {
+ return {
+ id: value?.id,
+ tag: value?.tag_name,
+ draft: value?.draft,
+ prerelease: value?.prerelease,
+ assets: (value?.assets ?? [])
+ .map(({ name, size, digest }) => ({ name, size, digest }))
+ .sort((left, right) => compareProductReleaseNames(left.name, right.name)),
+ };
+}
+
+function assertReleaseState(release, { tag, draft }) {
+ if (!Number.isSafeInteger(release?.id) || release.id < 1 || release.tag !== tag) {
+ throw new Error(`GitHub Release does not identify Desktop Nightly ${tag}`);
+ }
+ if (release.draft !== draft || release.prerelease !== true) {
+ throw new Error(`GitHub Release ${tag} must be a ${draft ? 'Draft ' : ''}Prerelease`);
+ }
+ return release;
+}
+
+function assertExactAssets(release, expected) {
+ if (JSON.stringify(release.assets) !== JSON.stringify(expected)) {
+ throw new Error(`GitHub Desktop Nightly ${release.tag} assets do not match local bytes`);
+ }
+ return release;
+}
+
+function assertAssetSubset(release, expected) {
+ const records = new Map(expected.map((record) => [record.name, record]));
+ for (const asset of release.assets) {
+ if (JSON.stringify(records.get(asset.name)) !== JSON.stringify(asset)) {
+ throw new Error(
+ `Draft GitHub Desktop Nightly contains unexpected or changed asset ${asset.name}`,
+ );
+ }
+ }
+}
+
+async function viewRelease({ repository, tag, cwd, run }) {
+ const result = await run(
+ 'gh',
+ [
+ 'release',
+ 'view',
+ tag,
+ '--repo',
+ repository,
+ '--json',
+ 'databaseId,tagName,isDraft,isPrerelease,assets',
+ ],
+ { cwd },
+ );
+ try {
+ return snapshotFromView(JSON.parse(result.stdout));
+ } catch (error) {
+ throw new Error(`GitHub returned an invalid Desktop Nightly record for ${tag}`, {
+ cause: error,
+ });
+ }
+}
+
+function isMissingRelease(error) {
+ return /release not found|HTTP 404/u.test(`${error?.stderr ?? ''}\n${error?.message ?? ''}`);
+}
+
+async function localRelease({ artifactDirectory, version }) {
+ const names = desktopNightlyReleaseAssetNames(version);
+ await verifyProductReleaseArtifactDirectory(artifactDirectory, names);
+ return productReleaseArtifactRecords(artifactDirectory, names);
+}
+
+async function verifyTag({ cwd, repository, run, sourceCommit, tag }) {
+ const remote = await remoteProductTagCommit({ cwd, remote: 'origin', tag, run });
+ if (remote !== sourceCommit) {
+ throw new Error(
+ `Desktop Nightly tag ${tag} points to ${remote ?? 'nothing'} instead of ${sourceCommit}`,
+ );
+ }
+ return repository;
+}
+
+export async function prepareDesktopNightlyRelease({
+ artifactDirectory,
+ repository,
+ sourceCommit,
+ version,
+ cwd = process.cwd(),
+ run = execFileAsync,
+}) {
+ const { tag } = validateIdentity(version, sourceCommit, repository);
+ const expected = await localRelease({ artifactDirectory, version });
+ await verifyTag({ cwd, repository, run, sourceCommit, tag });
+ let release;
+ try {
+ release = await viewRelease({ repository, tag, cwd, run });
+ } catch (error) {
+ if (!isMissingRelease(error)) throw error;
+ await run(
+ 'gh',
+ [
+ 'release',
+ 'create',
+ tag,
+ '--repo',
+ repository,
+ '--draft',
+ '--verify-tag',
+ '--prerelease',
+ '--latest=false',
+ '--title',
+ `Maka Desktop Nightly ${version}`,
+ '--notes',
+ notes(version, sourceCommit),
+ ],
+ { cwd },
+ );
+ release = await viewRelease({ repository, tag, cwd, run });
+ }
+ assertReleaseState(release, { tag, draft: true });
+ assertAssetSubset(release, expected);
+ await run(
+ 'gh',
+ [
+ 'release',
+ 'edit',
+ tag,
+ '--repo',
+ repository,
+ '--draft',
+ '--prerelease',
+ '--latest=false',
+ '--title',
+ `Maka Desktop Nightly ${version}`,
+ '--notes',
+ notes(version, sourceCommit),
+ ],
+ { cwd },
+ );
+ const existing = new Set(release.assets.map(({ name }) => name));
+ const missing = expected.filter(({ name }) => !existing.has(name));
+ if (missing.length > 0) {
+ await run(
+ 'gh',
+ [
+ 'release',
+ 'upload',
+ tag,
+ ...missing.map(({ name }) => join(artifactDirectory, name)),
+ '--repo',
+ repository,
+ ],
+ { cwd },
+ );
+ }
+ const complete = await viewRelease({ repository, tag, cwd, run });
+ assertReleaseState(complete, { tag, draft: true });
+ return assertExactAssets(complete, expected);
+}
+
+export async function publishDesktopNightlyRelease({
+ artifactDirectory,
+ repository,
+ sourceCommit,
+ version,
+ cwd = process.cwd(),
+ run = execFileAsync,
+}) {
+ const { tag } = validateIdentity(version, sourceCommit, repository);
+ const expected = await localRelease({ artifactDirectory, version });
+ await verifyTag({ cwd, repository, run, sourceCommit, tag });
+ const draft = assertExactAssets(
+ assertReleaseState(await viewRelease({ repository, tag, cwd, run }), { tag, draft: true }),
+ expected,
+ );
+ const response = await run(
+ 'gh',
+ [
+ 'api',
+ '--method',
+ 'PATCH',
+ `repos/${repository}/releases/${draft.id}`,
+ '-F',
+ 'draft=false',
+ '-F',
+ 'prerelease=true',
+ '-f',
+ 'make_latest=false',
+ ],
+ { cwd },
+ );
+ let published;
+ try {
+ published = snapshotFromRest(JSON.parse(response.stdout));
+ } catch (error) {
+ throw new Error(`GitHub returned an invalid Desktop Nightly publication for ${tag}`, {
+ cause: error,
+ });
+ }
+ assertExactAssets(assertReleaseState(published, { tag, draft: false }), expected);
+ assertExactAssets(
+ assertReleaseState(await viewRelease({ repository, tag, cwd, run }), { tag, draft: false }),
+ expected,
+ );
+ try {
+ const latest = await run('gh', ['api', `repos/${repository}/releases/latest`], { cwd });
+ if (JSON.parse(latest.stdout)?.tag_name === tag) {
+ throw new Error(`Desktop Nightly ${tag} must not become Latest`);
+ }
+ } catch (error) {
+ if (!/HTTP 404/u.test(`${error?.stderr ?? ''}\n${error?.message ?? ''}`)) throw error;
+ }
+ return published;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const [command, artifactDirectory, version, sourceCommit, repository] = process.argv.slice(2);
+ const input = { artifactDirectory, version, sourceCommit, repository };
+ if (command === 'prepare' && artifactDirectory && version && sourceCommit && repository) {
+ await prepareDesktopNightlyRelease(input);
+ } else if (command === 'publish' && artifactDirectory && version && sourceCommit && repository) {
+ await publishDesktopNightlyRelease(input);
+ } else {
+ throw new Error(
+ 'usage: desktop-nightly-release.mjs ',
+ );
+ }
+}
diff --git a/scripts/desktop-nightly-release.test.mjs b/scripts/desktop-nightly-release.test.mjs
new file mode 100644
index 0000000000..aca196c9ac
--- /dev/null
+++ b/scripts/desktop-nightly-release.test.mjs
@@ -0,0 +1,234 @@
+/*
+ * 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, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import test from 'node:test';
+import {
+ prepareDesktopNightlyRelease,
+ publishDesktopNightlyRelease,
+} from './desktop-nightly-release.mjs';
+import {
+ addDesktopNightlyAttestation,
+ desktopNightlyReleaseAssetNames,
+ stageDesktopNightly,
+} from './desktop-nightly.mjs';
+import { productReleaseArtifactRecords } from './product-release-artifacts.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 ? 'dev-mac.yml' : 'dev.yml';
+ const bytes = Buffer.from(`${platform} nightly bytes`);
+ const sha512 = createHash('sha512').update(bytes).digest('base64');
+ await Promise.all([
+ writeFile(join(directory, artifact), bytes),
+ writeFile(join(directory, `${artifact}.blockmap`), `${platform} blockmap`),
+ writeFile(
+ join(directory, metadata),
+ `version: ${version}\nfiles:\n - url: ${artifact}\n sha512: ${sha512}\n size: ${bytes.length}\npath: ${artifact}\nsha512: ${sha512}\n`,
+ ),
+ ]);
+}
+
+async function stageRelease(root, version, sourceCommit) {
+ const input = join(root, 'input');
+ const output = join(root, 'output');
+ 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,
+ });
+ const bundle = join(root, 'bundle.json');
+ await writeFile(bundle, 'sigstore bundle');
+ await addDesktopNightlyAttestation({ outputDirectory: output, version, bundlePath: bundle });
+ return join(output, 'release');
+}
+
+test('Nightly publication verifies the exact draft before one Prerelease/non-Latest mutation', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-nightly-release-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const version = '0.2.0-dev.42.20260829';
+ const tag = `v${version}`;
+ const sourceCommit = 'a'.repeat(40);
+ const directory = await stageRelease(root, version, sourceCommit);
+ const records = await productReleaseArtifactRecords(
+ directory,
+ desktopNightlyReleaseAssetNames(version),
+ );
+ const calls = [];
+ let uploaded = false;
+ let draft = true;
+ let tampered = false;
+ const release = () => ({
+ databaseId: 42,
+ tagName: tag,
+ isDraft: draft,
+ isPrerelease: true,
+ assets: uploaded
+ ? records.map((record, index) =>
+ tampered && index === 0 ? { ...record, digest: `sha256:${'0'.repeat(64)}` } : record,
+ )
+ : [],
+ });
+ const run = async (command, args) => {
+ calls.push([command, args]);
+ if (command === 'git') return { stdout: `${sourceCommit}\trefs/tags/${tag}\n` };
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'upload') {
+ uploaded = true;
+ return { stdout: '' };
+ }
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'edit') return { stdout: '' };
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'view') {
+ return { stdout: JSON.stringify(release()) };
+ }
+ if (command === 'gh' && args.includes('PATCH')) {
+ draft = false;
+ return {
+ stdout: JSON.stringify({
+ id: 42,
+ tag_name: tag,
+ draft: false,
+ prerelease: true,
+ assets: records,
+ }),
+ };
+ }
+ if (command === 'gh' && args.includes('repos/apache/maka/releases/latest')) {
+ return { stdout: JSON.stringify({ tag_name: 'v0.1.11' }) };
+ }
+ throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
+ };
+
+ await prepareDesktopNightlyRelease({
+ artifactDirectory: directory,
+ repository: 'apache/maka',
+ run,
+ sourceCommit,
+ version,
+ });
+ tampered = true;
+ await assert.rejects(
+ publishDesktopNightlyRelease({
+ artifactDirectory: directory,
+ repository: 'apache/maka',
+ run,
+ sourceCommit,
+ version,
+ }),
+ /assets do not match local bytes/u,
+ );
+ assert.equal(draft, true);
+ assert.equal(
+ calls.some(([, args]) => args.includes('PATCH')),
+ false,
+ );
+ tampered = false;
+ await publishDesktopNightlyRelease({
+ artifactDirectory: directory,
+ repository: 'apache/maka',
+ run,
+ sourceCommit,
+ version,
+ });
+
+ const upload = calls.find(([, args]) => args[0] === 'release' && args[1] === 'upload');
+ const patchCall = calls.find(([, args]) => args.includes('PATCH'));
+ assert.ok(upload);
+ assert.ok(patchCall);
+ assert.ok(calls.indexOf(upload) < calls.indexOf(patchCall));
+ assert.ok(patchCall[1].includes('draft=false'));
+ assert.ok(patchCall[1].includes('prerelease=true'));
+ assert.ok(patchCall[1].includes('make_latest=false'));
+ const edit = calls.find(([, args]) => args[0] === 'release' && args[1] === 'edit');
+ assert.match(edit[1].at(-1), /Developer Snapshot/u);
+ assert.match(edit[1].at(-1), /not an Apache Release/u);
+ assert.match(edit[1].at(-1), /DISCLAIMER-WIP/u);
+ assert.match(edit[1].at(-1), /Apache License 2\.0/u);
+});
+
+test('a missing Nightly release is created only as a draft prerelease with Latest disabled', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-nightly-create-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const version = '0.2.0-dev.42.20260829';
+ const tag = `v${version}`;
+ const sourceCommit = 'a'.repeat(40);
+ const directory = await stageRelease(root, version, sourceCommit);
+ const records = await productReleaseArtifactRecords(
+ directory,
+ desktopNightlyReleaseAssetNames(version),
+ );
+ const calls = [];
+ let created = false;
+ let uploaded = false;
+ const run = async (command, args) => {
+ calls.push([command, args]);
+ if (command === 'git') return { stdout: `${sourceCommit}\trefs/tags/${tag}\n` };
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'view') {
+ if (!created) {
+ const error = new Error('release not found');
+ error.stderr = 'release not found';
+ throw error;
+ }
+ return {
+ stdout: JSON.stringify({
+ databaseId: 42,
+ tagName: tag,
+ isDraft: true,
+ isPrerelease: true,
+ assets: uploaded ? records : [],
+ }),
+ };
+ }
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'create') {
+ created = true;
+ return { stdout: '' };
+ }
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'edit') return { stdout: '' };
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'upload') {
+ uploaded = true;
+ return { stdout: '' };
+ }
+ throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
+ };
+
+ await prepareDesktopNightlyRelease({
+ artifactDirectory: directory,
+ repository: 'apache/maka',
+ run,
+ sourceCommit,
+ version,
+ });
+
+ const create = calls.find(([, args]) => args[0] === 'release' && args[1] === 'create');
+ for (const flag of ['--draft', '--verify-tag', '--prerelease', '--latest=false']) {
+ assert.ok(create[1].includes(flag), flag);
+ }
+});
diff --git a/scripts/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs
index 879e34a5e3..89689ef12b 100644
--- a/scripts/desktop-nightly-stage.test.mjs
+++ b/scripts/desktop-nightly-stage.test.mjs
@@ -24,13 +24,18 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { stringify } from 'yaml';
-import { assertDesktopNightlyFeedAdvance, stageDesktopNightly } from './desktop-nightly.mjs';
+import {
+ addDesktopNightlyAttestation,
+ assertDesktopNightlyFeedAdvance,
+ resolveDesktopNightlyCutover,
+ 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 metadata = isMac ? 'dev-mac.yml' : 'dev.yml';
const bytes = Buffer.from(`${platform} nightly bytes`);
const sha512 = createHash('sha512').update(bytes).digest('base64');
await writeFile(join(directory, artifact), bytes);
@@ -47,7 +52,33 @@ async function writeUpdateSet(directory, version, platform) {
);
}
-test('staging separates append-only payloads from the mutable Nightly feed', async (t) => {
+function publishedNightly(version) {
+ return {
+ tag_name: `v${version}`,
+ draft: false,
+ prerelease: true,
+ assets: [
+ `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`,
+ `Maka-${version}-attestation.sigstore.json`,
+ 'dev-mac.yml',
+ 'dev.yml',
+ ].map((name) => ({ name })),
+ };
+}
+
+async function writeCutoverMarker(directory, version) {
+ await writeFile(
+ join(directory, 'github-cutover.json'),
+ `${JSON.stringify({ schemaVersion: 1, authority: 'github-releases', version })}\n`,
+ );
+}
+
+test('staging creates the exact GitHub assets and a payload-free legacy bridge', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-'));
t.after(() => rm(root, { recursive: true, force: true }));
const input = join(root, 'input');
@@ -76,47 +107,81 @@ test('staging separates append-only payloads from the mutable Nightly feed', asy
`Maka-${version}-win-x64.exe.blockmap`,
`Maka-${version}-win-x64.zip`,
];
+ const release = join(output, 'release');
for (const name of payloadNames) {
- assert.deepEqual(
- await readFile(join(output, 'versions', version, name)),
- await readFile(join(input, name)),
- name,
- );
+ assert.deepEqual(await readFile(join(release, name)), await readFile(join(input, name)), name);
}
await Promise.all([
verifyDesktopUpdateArtifacts({
- directory: output,
- metadataName: 'feed/latest-mac.yml',
+ directory: release,
+ metadataName: 'dev-mac.yml',
version,
- artifactName: `versions/${version}/Maka-${version}-mac-arm64.zip`,
+ artifactName: `Maka-${version}-mac-arm64.zip`,
}),
verifyDesktopUpdateArtifacts({
- directory: output,
- metadataName: 'feed/latest.yml',
+ directory: release,
+ metadataName: 'dev.yml',
version,
- artifactName: `versions/${version}/Maka-${version}-win-x64.exe`,
+ artifactName: `Maka-${version}-win-x64.exe`,
}),
]);
const macMetadata = (await import('yaml')).parse(
- await readFile(join(output, 'feed', 'latest-mac.yml'), 'utf8'),
+ await readFile(join(output, 'bridge', 'feed', 'latest-mac.yml'), 'utf8'),
);
const windowsMetadata = (await import('yaml')).parse(
- await readFile(join(output, 'feed', 'latest.yml'), 'utf8'),
+ await readFile(join(output, 'bridge', 'feed', 'latest.yml'), 'utf8'),
+ );
+ assert.equal(
+ macMetadata.files[0].url,
+ `https://github.com/apache/maka/releases/download/v${version}/Maka-${version}-mac-arm64.zip`,
+ );
+ assert.equal(
+ windowsMetadata.path,
+ `https://github.com/apache/maka/releases/download/v${version}/Maka-${version}-win-x64.exe`,
);
- 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');
+ const index = await readFile(join(output, 'bridge', '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(), [
+ assert.match(
+ index,
+ new RegExp(`releases/download/v${version}/Maka-${version}-mac-arm64\\.dmg`, 'u'),
+ );
+ assert.match(
+ index,
+ new RegExp(`releases/download/v${version}/Maka-${version}-win-x64\\.exe`, 'u'),
+ );
+ assert.deepEqual((await readdir(join(output, 'bridge', 'feed'))).sort(), [
'index.html',
'latest-mac.yml',
'latest.yml',
]);
- assert.deepEqual((await readdir(join(output, 'versions', version))).sort(), payloadNames);
+ assert.deepEqual(
+ JSON.parse(await readFile(join(output, 'bridge', 'completion', 'github-cutover.json'), 'utf8')),
+ { schemaVersion: 1, authority: 'github-releases', version },
+ );
+ assert.deepEqual(
+ (await readdir(release)).sort(),
+ [...payloadNames, 'dev-mac.yml', 'dev.yml'].sort(),
+ );
+ assert.deepEqual((await readdir(join(output, 'bridge'))).sort(), ['completion', 'feed']);
+});
+
+test('one attestation bundle is staged as a GitHub asset and at the legacy fixed path', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-attestation-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const output = join(root, 'output');
+ const release = join(output, 'release');
+ const version = '0.2.0-dev.42.20260829';
+ const bundle = join(root, 'bundle.json');
+ const bytes = Buffer.from('one offline Sigstore bundle');
+ await Promise.all([mkdir(release, { recursive: true }), writeFile(bundle, bytes)]);
+
+ await addDesktopNightlyAttestation({ outputDirectory: output, version, bundlePath: bundle });
+
+ const name = `Maka-${version}-attestation.sigstore.json`;
+ assert.deepEqual(await readFile(join(release, name)), bytes);
+ assert.deepEqual(await readFile(join(output, 'bridge', 'versions', version, name)), bytes);
});
test('the Desktop feed advances only to a newer npm run number', async (t) => {
@@ -142,3 +207,193 @@ test('the Desktop feed advances only to a newer npm run number', async (t) => {
/does not advance current run/u,
);
});
+
+test('the pending legacy feed bridges only for its explicitly authorized source SHA', async (t) => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-cutover-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const previous = '0.2.0-dev.41.20260828';
+ const candidate = '0.2.0-dev.42.20260829';
+ const sourceCommit = 'a'.repeat(40);
+ await Promise.all([
+ writeFile(
+ join(directory, 'latest-mac.yml'),
+ `version: ${previous}\npath: versions/${previous}/Maka-${previous}-mac-arm64.zip\n`,
+ ),
+ writeFile(
+ join(directory, 'latest.yml'),
+ `version: ${previous}\npath: versions/${previous}/Maka-${previous}-win-x64.exe\n`,
+ ),
+ ]);
+
+ assert.deepEqual(
+ await resolveDesktopNightlyCutover({
+ candidateVersion: candidate,
+ cutoverSourceCommit: sourceCommit,
+ feedDirectory: directory,
+ productVersion: '0.2.0',
+ releases: [],
+ sourceCommit,
+ }),
+ { bridge: true, previousVersion: undefined },
+ );
+ await assert.rejects(
+ resolveDesktopNightlyCutover({
+ candidateVersion: candidate,
+ cutoverSourceCommit: 'b'.repeat(40),
+ feedDirectory: directory,
+ productVersion: '0.2.0',
+ releases: [],
+ sourceCommit,
+ }),
+ /cutover source SHA/u,
+ );
+ await writeFile(
+ join(directory, 'latest.yml'),
+ `version: ${previous}\npath: https://example.invalid/Maka-${previous}-win-x64.exe\n`,
+ );
+ await assert.rejects(
+ resolveDesktopNightlyCutover({
+ candidateVersion: candidate,
+ cutoverSourceCommit: sourceCommit,
+ feedDirectory: directory,
+ productVersion: '0.2.0',
+ releases: [],
+ sourceCommit,
+ }),
+ /legacy Nightlies asset/u,
+ );
+});
+
+test('the completed bridge advances from the latest valid GitHub dev prerelease without writing Nightlies', async (t) => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-steady-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const previous = '0.2.0-dev.41.20260828';
+ const candidate = '0.2.0-dev.42.20260829';
+ const sourceCommit = 'a'.repeat(40);
+ const base = `https://github.com/apache/maka/releases/download/v${previous}`;
+ await Promise.all([
+ writeFile(
+ join(directory, 'latest-mac.yml'),
+ `version: ${previous}\npath: ${base}/Maka-${previous}-mac-arm64.zip\n`,
+ ),
+ writeFile(
+ join(directory, 'latest.yml'),
+ `version: ${previous}\npath: ${base}/Maka-${previous}-win-x64.exe\n`,
+ ),
+ ]);
+ const input = {
+ candidateVersion: candidate,
+ cutoverSourceCommit: sourceCommit,
+ feedDirectory: directory,
+ productVersion: '0.2.0',
+ releases: [publishedNightly(previous)],
+ sourceCommit,
+ };
+
+ assert.deepEqual(await resolveDesktopNightlyCutover(input), {
+ bridge: true,
+ previousVersion: previous,
+ });
+ await writeCutoverMarker(directory, previous);
+ assert.deepEqual(await resolveDesktopNightlyCutover(input), {
+ bridge: false,
+ previousVersion: previous,
+ });
+ await assert.rejects(
+ resolveDesktopNightlyCutover({ ...input, candidateVersion: previous }),
+ /does not advance current run/u,
+ );
+ await assert.rejects(
+ resolveDesktopNightlyCutover({
+ ...input,
+ releases: [{ ...publishedNightly(previous), assets: [] }],
+ }),
+ /GitHub dev prerelease assets/u,
+ );
+ await Promise.all([
+ writeFile(
+ join(directory, 'latest-mac.yml'),
+ `version: ${previous}\npath: https://github.com/apache/maka/releases/download/v9.9.9/Maka-${previous}-mac-arm64.zip\n`,
+ ),
+ writeFile(
+ join(directory, 'latest.yml'),
+ `version: ${previous}\npath: https://github.com/apache/maka/releases/download/v9.9.9/Maka-${previous}-win-x64.exe\n`,
+ ),
+ ]);
+ await assert.rejects(resolveDesktopNightlyCutover(input), /exact GitHub Release/u);
+ const missing = '0.2.0-dev.40.20260827';
+ const missingBase = `https://github.com/apache/maka/releases/download/v${missing}`;
+ await Promise.all([
+ writeFile(
+ join(directory, 'latest-mac.yml'),
+ `version: ${missing}\npath: ${missingBase}/Maka-${missing}-mac-arm64.zip\n`,
+ ),
+ writeFile(
+ join(directory, 'latest.yml'),
+ `version: ${missing}\npath: ${missingBase}/Maka-${missing}-win-x64.exe\n`,
+ ),
+ ]);
+ await assert.rejects(resolveDesktopNightlyCutover(input), /completed bridge/u);
+});
+
+test('a partially published legacy bridge converges on the next authorized fresh run', async (t) => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-partial-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const legacy = '0.2.0-dev.40.20260827';
+ const published = '0.2.0-dev.41.20260828';
+ const candidate = '0.2.0-dev.42.20260829';
+ const sourceCommit = 'a'.repeat(40);
+ await Promise.all([
+ writeFile(
+ join(directory, 'latest-mac.yml'),
+ `version: ${published}\npath: https://github.com/apache/maka/releases/download/v${published}/Maka-${published}-mac-arm64.zip\n`,
+ ),
+ writeFile(
+ join(directory, 'latest.yml'),
+ `version: ${legacy}\npath: versions/${legacy}/Maka-${legacy}-win-x64.exe\n`,
+ ),
+ ]);
+
+ assert.deepEqual(
+ await resolveDesktopNightlyCutover({
+ candidateVersion: candidate,
+ cutoverSourceCommit: sourceCommit,
+ feedDirectory: directory,
+ productVersion: '0.2.0',
+ releases: [publishedNightly(published)],
+ sourceCommit,
+ }),
+ { bridge: true, previousVersion: published },
+ );
+});
+
+test('retained Nightlies from an older product line remain the cross-version ordering authority', async (t) => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-retained-'));
+ t.after(() => rm(directory, { recursive: true, force: true }));
+ const previous = '0.2.0-dev.42.20260829';
+ const candidate = '0.3.0-dev.43.20260830';
+ const base = `https://github.com/apache/maka/releases/download/v${previous}`;
+ await Promise.all([
+ writeFile(
+ join(directory, 'latest-mac.yml'),
+ `version: ${previous}\npath: ${base}/Maka-${previous}-mac-arm64.zip\n`,
+ ),
+ writeFile(
+ join(directory, 'latest.yml'),
+ `version: ${previous}\npath: ${base}/Maka-${previous}-win-x64.exe\n`,
+ ),
+ ]);
+ await writeCutoverMarker(directory, previous);
+
+ assert.deepEqual(
+ await resolveDesktopNightlyCutover({
+ candidateVersion: candidate,
+ cutoverSourceCommit: '',
+ feedDirectory: directory,
+ productVersion: '0.3.0',
+ releases: [publishedNightly(previous)],
+ sourceCommit: 'a'.repeat(40),
+ }),
+ { bridge: false, previousVersion: previous },
+ );
+});
diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs
index 5dac5578c0..a9a880643f 100644
--- a/scripts/desktop-nightly-workflow-policy.test.mjs
+++ b/scripts/desktop-nightly-workflow-policy.test.mjs
@@ -107,28 +107,53 @@ test('a failed Desktop Nightly is retried through a fresh npm Nightly', async ()
assert.equal(download.with.pattern, 'desktop-nightly-*');
});
-test('the first Desktop Nightly creates its destination with rsync 3.1-compatible flags', async () => {
+test('Desktop Nightly packages the GitHub dev feeds and grants write only to its publisher', async () => {
+ const workflow = await readWorkflow('desktop-nightly.yml');
+ assert.deepEqual(workflow.permissions, { actions: 'read', contents: 'read' });
+ assert.equal(workflow.jobs.publish.permissions.contents, 'write');
+ assert.equal(workflow.jobs.desktop.permissions, undefined);
+ const stage = workflow.jobs.desktop.steps.find(
+ (step) => step.name === 'Stage the exact Nightly artifacts',
+ );
+ assert.match(stage.run, /apps\/desktop\/release\/dev-mac\.yml/u);
+ assert.match(stage.run, /apps\/desktop\/release\/dev\.yml/u);
+ assert.doesNotMatch(stage.run, /latest-mac\.yml|latest\.yml/u);
+});
+
+test('the publisher verifies exact GitHub identity and assets before publishing last', async () => {
const workflow = await readWorkflow('desktop-nightly.yml');
const steps = workflow.jobs.publish.steps;
- const transport = steps.find(
- (step) => step.name === 'Prepare authenticated Nightlies SSH transport',
+ const positions = [
+ 'Resolve the one-time Nightlies cutover',
+ 'Attest every GitHub Nightly asset subject',
+ 'Verify the issued Nightly provenance',
+ 'Add the one offline provenance bundle',
+ 'Ensure the exact immutable Nightly tag',
+ 'Prepare and verify the draft GitHub Prerelease',
+ 'Publish the complete GitHub Prerelease',
+ ].map((name) => steps.findIndex((step) => step.name === name));
+ assert.ok(positions.every((position) => position >= 0));
+ assert.deepEqual(
+ positions,
+ positions.toSorted((left, right) => left - right),
);
- const bootstrap = steps.find((step) => step.name === 'Ensure the Nightly destination exists');
- const feedGuardPosition = steps.findIndex(
- (step) => step.name === 'Require the Desktop Nightly feed to advance',
+ const cutover = steps[positions[0]];
+ assert.equal(
+ cutover.env.DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA,
+ '${{ vars.DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA }}',
+ );
+ assert.match(cutover.run, /desktop-nightly\.mjs resolve-cutover/u);
+ assert.match(steps[positions[1]].with['subject-path'], /\.nightly-stage\/release\/\*/u);
+ assert.match(steps[positions[4]].run, /product-release-tag\.mjs ensure/u);
+ assert.match(steps[positions[5]].run, /desktop-nightly-release\.mjs prepare/u);
+ assert.match(steps[positions[6]].run, /desktop-nightly-release\.mjs publish/u);
+ assert.equal(
+ steps[positions[2]].env.CERTIFICATE_IDENTITY,
+ 'https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main',
);
-
- assert.ok(bootstrap);
- assert.match(transport.run, /NIGHTLIES_RSYNC_BASE=/u);
- assert.match(bootstrap.run, /mkdir -p \.nightly-empty\/maka\/desktop/u);
- assert.match(bootstrap.run, /^rsync -rlptDz --protect-args /mu);
- assert.doesNotMatch(bootstrap.run, /--mkpath/u);
- assert.match(bootstrap.run, /\.nightly-empty\/maka\/ "\$NIGHTLIES_RSYNC_BASE\/maka\/"/u);
- assert.doesNotMatch(bootstrap.run, /\.nightly-publish/u);
- assert.ok(steps.indexOf(bootstrap) < feedGuardPosition);
});
-test('the protected Desktop publisher appends payloads before advancing the feed', async () => {
+test('only the explicit cutover writes the legacy bundle and feed, never large payloads', async () => {
const workflow = await readWorkflow('desktop-nightly.yml');
const publish = workflow.jobs.publish;
assert.equal(workflow.jobs.desktop.environment, 'nightly');
@@ -137,9 +162,11 @@ test('the protected Desktop publisher appends payloads before advancing the feed
publish.steps.filter((step) => step.uses?.startsWith('burnett01/rsync-deployments@')).length,
0,
);
+ const steps = publish.steps;
const transport = publish.steps.find(
(step) => step.name === 'Prepare authenticated Nightlies SSH transport',
);
+ assert.equal(transport.if, "steps.cutover.outputs.bridge == 'true'");
assert.equal(transport.env.NIGHTLIES_RSYNC_KEY, '${{ secrets.NIGHTLIES_RSYNC_KEY }}');
assert.deepEqual(Object.keys(transport.env).toSorted(), [
'NIGHTLIES_RSYNC_HOST',
@@ -151,36 +178,19 @@ test('the protected Desktop publisher appends payloads before advancing the feed
assert.match(transport.run, /StrictHostKeyChecking=no/u);
assert.match(transport.run, /UserKnownHostsFile=\/dev\/null/u);
assert.doesNotMatch(transport.run, /ssh-keyscan|KNOWN_HOSTS/u);
- const steps = publish.steps;
- const positions = [
- 'Attest the exact Nightly payloads',
- 'Verify the issued Nightly provenance',
- 'Require the Desktop Nightly feed to advance',
- '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));
- assert.equal(
- steps[positions[1]].env.CERTIFICATE_IDENTITY,
- 'https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main',
- );
- for (const name of [
- 'Ensure the Nightly destination exists',
- 'Publish immutable Nightly payloads',
- 'Advance the Nightly update feed last',
- ]) {
- const step = steps.find((candidate) => candidate.name === name);
+ const bundle = steps.find((step) => step.name === 'Stage the legacy cutover attestation');
+ const release = steps.find((step) => step.name === 'Publish the complete GitHub Prerelease');
+ const feed = steps.find((step) => step.name === 'Advance the legacy Nightlies feed to GitHub');
+ const completion = steps.find((step) => step.name === 'Commit the completed legacy bridge');
+ for (const step of [bundle, feed, completion]) {
+ assert.equal(step.if, "steps.cutover.outputs.bridge == 'true'");
assert.doesNotMatch(step.run, /--delete/u);
+ assert.doesNotMatch(step.run, /mac-arm64\.(?:dmg|zip)|win-x64\.(?:exe|zip)|blockmap/u);
}
- for (const name of [
- 'Publish immutable Nightly payloads',
- 'Advance the Nightly update feed last',
- ]) {
- const step = steps.find((candidate) => candidate.name === name);
- assert.match(step.run, /^rsync -rlptDvz --protect-args /u);
- }
+ assert.match(bundle.run, /\.nightly-stage\/bridge\/versions\//u);
+ assert.match(feed.run, /\.nightly-stage\/bridge\/feed\//u);
+ assert.match(completion.run, /bridge\/completion\/github-cutover\.json/u);
+ assert.ok(steps.indexOf(bundle) < steps.indexOf(release));
+ assert.ok(steps.indexOf(release) < steps.indexOf(feed));
+ assert.ok(steps.indexOf(feed) < steps.indexOf(completion));
});
diff --git a/scripts/desktop-nightly.mjs b/scripts/desktop-nightly.mjs
index 927f7fe1f1..7ac3dfb6cc 100644
--- a/scripts/desktop-nightly.mjs
+++ b/scripts/desktop-nightly.mjs
@@ -17,13 +17,26 @@
* under the License.
*/
-import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
+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 { assertProductNightlyAdvances, assertProductNightlyVersion } from './release-version.mjs';
+import {
+ assertProductNightlyAdvances,
+ assertProductNightlyVersion,
+ parseProductNightlyVersion,
+ productNightlyRunNumber,
+} 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) {
@@ -64,6 +77,153 @@ export async function assertDesktopNightlyFeedAdvance({
return candidateVersion;
}
+async function readLegacyNightlyFeed(directory) {
+ const { parse } = await import('yaml');
+ const entries = [];
+ for (const name of ['latest-mac.yml', 'latest.yml']) {
+ const metadata = parse(await readFile(join(directory, name), 'utf8'));
+ if (typeof metadata?.version !== 'string' || typeof metadata?.path !== 'string') {
+ throw new Error(`Desktop Nightly feed ${name} has no valid versioned payload`);
+ }
+ entries.push({ name, metadata });
+ }
+ return entries;
+}
+
+async function readCutoverMarker(directory) {
+ let source;
+ try {
+ source = await readFile(join(directory, 'github-cutover.json'), 'utf8');
+ } catch (error) {
+ if (error?.code === 'ENOENT') return undefined;
+ throw error;
+ }
+ let marker;
+ try {
+ marker = JSON.parse(source);
+ } catch (error) {
+ throw new Error('Desktop Nightly cutover marker is invalid', { cause: error });
+ }
+ if (
+ marker?.schemaVersion !== 1 ||
+ marker?.authority !== 'github-releases' ||
+ typeof marker?.version !== 'string'
+ ) {
+ throw new Error('Desktop Nightly cutover marker is invalid');
+ }
+ parseProductNightlyVersion(marker.version);
+ return marker;
+}
+
+export function desktopNightlyReleaseAssetNames(version) {
+ const names = nightlyArtifactNames(version);
+ return [
+ names.macDmg,
+ names.macZip,
+ `${names.macZip}.blockmap`,
+ names.windowsExe,
+ `${names.windowsExe}.blockmap`,
+ names.windowsZip,
+ `Maka-${version}-attestation.sigstore.json`,
+ 'dev-mac.yml',
+ 'dev.yml',
+ ].sort();
+}
+
+function latestPublishedDesktopNightly(releases) {
+ let latest;
+ const versions = new Set();
+ for (const release of releases.flat()) {
+ if (release?.draft === true || release?.prerelease !== true) continue;
+ const match = /^v(.+-dev\..+)$/u.exec(release?.tag_name ?? '');
+ if (!match) continue;
+ const version = match[1];
+ parseProductNightlyVersion(version);
+ const actual = (release.assets ?? []).map(({ name }) => name).sort();
+ const expected = desktopNightlyReleaseAssetNames(version);
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ throw new Error(`GitHub dev prerelease assets are invalid for v${version}`);
+ }
+ versions.add(version);
+ if (!latest || productNightlyRunNumber(version) > productNightlyRunNumber(latest)) {
+ latest = version;
+ }
+ }
+ return { latest, versions };
+}
+
+export async function resolveDesktopNightlyCutover({
+ candidateVersion,
+ cutoverSourceCommit,
+ feedDirectory,
+ productVersion,
+ releases,
+ sourceCommit,
+}) {
+ assertDesktopNightlyVersion(candidateVersion, productVersion);
+ if (!Array.isArray(releases)) throw new Error('GitHub Releases must be an array');
+ const published = latestPublishedDesktopNightly(releases);
+ const previousVersion = published.latest;
+ assertProductNightlyAdvances(candidateVersion, previousVersion, productVersion);
+ const feeds = await readLegacyNightlyFeed(feedDirectory);
+ const marker = await readCutoverMarker(feedDirectory);
+ const bridgeStates = feeds.map(({ name, metadata }) => {
+ const artifact =
+ name === 'latest-mac.yml'
+ ? `Maka-${metadata.version}-mac-arm64.zip`
+ : `Maka-${metadata.version}-win-x64.exe`;
+ const expected = githubNightlyAssetUrl(metadata.version, artifact);
+ const githubPath = metadata.path.startsWith(
+ 'https://github.com/apache/maka/releases/download/',
+ );
+ if (githubPath && metadata.path !== expected) {
+ throw new Error(`${name} does not point to the exact GitHub Release asset`);
+ }
+ if (
+ githubPath &&
+ Array.isArray(metadata.files) &&
+ (metadata.files.length !== 1 || metadata.files[0]?.url !== expected)
+ ) {
+ throw new Error(`${name} does not point to the exact GitHub Release asset`);
+ }
+ const legacy = `versions/${metadata.version}/${artifact}`;
+ if (
+ !githubPath &&
+ (metadata.path !== legacy ||
+ (Array.isArray(metadata.files) &&
+ (metadata.files.length !== 1 || metadata.files[0]?.url !== legacy)))
+ ) {
+ throw new Error(`${name} does not point to the exact legacy Nightlies asset`);
+ }
+ return githubPath;
+ });
+ const bridged = bridgeStates.every(Boolean);
+ if (marker) {
+ if (
+ !bridged ||
+ feeds.some(({ metadata }) => metadata.version !== marker.version) ||
+ !published.versions.has(marker.version)
+ ) {
+ throw new Error('Desktop Nightly completed bridge does not match a GitHub dev prerelease');
+ }
+ return { bridge: false, previousVersion };
+ }
+ for (const [index, state] of bridgeStates.entries()) {
+ if (state && !published.versions.has(feeds[index].metadata.version)) {
+ throw new Error('Desktop Nightly legacy feed points to a missing GitHub dev prerelease');
+ }
+ }
+ await assertDesktopNightlyFeedAdvance({
+ directory: feedDirectory,
+ candidateVersion,
+ productVersion,
+ });
+ if (cutoverSourceCommit !== sourceCommit || !/^[0-9a-f]{40}$/u.test(sourceCommit)) {
+ throw new Error('Desktop Nightly cutover source SHA does not match the exact build source');
+ }
+ return { bridge: true, previousVersion };
+}
+
function nightlyArtifactNames(version) {
return {
macZip: `Maka-${version}-mac-arm64.zip`,
@@ -73,14 +233,17 @@ function nightlyArtifactNames(version) {
};
}
-async function rewriteNightlyMetadata(source, destination, version) {
+function githubNightlyAssetUrl(version, name) {
+ return `https://github.com/apache/maka/releases/download/v${encodeURIComponent(version)}/${encodeURIComponent(name)}`;
+}
+
+async function writeLegacyBridgeMetadata(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.path = githubNightlyAssetUrl(version, metadata.path);
metadata.files = metadata.files.map((file) => ({
...file,
- url: `${prefix}${file.url}`,
+ url: githubNightlyAssetUrl(version, file.url),
}));
await writeFile(destination, stringify(metadata), 'utf8');
}
@@ -95,8 +258,8 @@ function nightlyIndex(version, sourceCommit, names) {
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.
@@ -125,7 +288,7 @@ export async function stageDesktopNightly({
`${names.windowsExe}.blockmap`,
names.windowsZip,
];
- const metadataNames = ['latest-mac.yml', 'latest.yml'];
+ const metadataNames = ['dev-mac.yml', 'dev.yml'];
const expected = [...payloads, ...metadataNames].sort();
const actual = (await readdir(inputDirectory)).sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
@@ -137,46 +300,72 @@ export async function stageDesktopNightly({
await Promise.all([
verifyDesktopUpdateArtifacts({
directory: inputDirectory,
- metadataName: 'latest-mac.yml',
+ metadataName: 'dev-mac.yml',
version,
artifactName: names.macZip,
}),
verifyDesktopUpdateArtifacts({
directory: inputDirectory,
- metadataName: 'latest.yml',
+ metadataName: 'dev.yml',
version,
artifactName: names.windowsExe,
}),
]);
await rm(outputDirectory, { recursive: true, force: true });
- const versionDirectory = join(outputDirectory, 'versions', version);
- const feedDirectory = join(outputDirectory, 'feed');
+ const releaseDirectory = join(outputDirectory, 'release');
+ const bridgeFeedDirectory = join(outputDirectory, 'bridge', 'feed');
+ const bridgeCompletionDirectory = join(outputDirectory, 'bridge', 'completion');
await Promise.all([
- mkdir(versionDirectory, { recursive: true }),
- mkdir(feedDirectory, { recursive: true }),
+ mkdir(releaseDirectory, { recursive: true }),
+ mkdir(bridgeFeedDirectory, { recursive: true }),
+ mkdir(bridgeCompletionDirectory, { recursive: true }),
]);
await Promise.all(
- payloads.map(async (name) => {
+ [...payloads, ...metadataNames].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 copyFile(source, join(releaseDirectory, name));
}),
);
await Promise.all([
- rewriteNightlyMetadata(
- join(inputDirectory, 'latest-mac.yml'),
- join(feedDirectory, 'latest-mac.yml'),
+ writeLegacyBridgeMetadata(
+ join(inputDirectory, 'dev-mac.yml'),
+ join(bridgeFeedDirectory, 'latest-mac.yml'),
version,
),
- rewriteNightlyMetadata(
- join(inputDirectory, 'latest.yml'),
- join(feedDirectory, 'latest.yml'),
+ writeLegacyBridgeMetadata(
+ join(inputDirectory, 'dev.yml'),
+ join(bridgeFeedDirectory, 'latest.yml'),
version,
),
]);
- await writeFile(join(feedDirectory, 'index.html'), nightlyIndex(version, sourceCommit, names));
+ await writeFile(
+ join(bridgeFeedDirectory, 'index.html'),
+ nightlyIndex(version, sourceCommit, names),
+ );
+ await writeFile(
+ join(bridgeCompletionDirectory, 'github-cutover.json'),
+ `${JSON.stringify({ schemaVersion: 1, authority: 'github-releases', version })}\n`,
+ );
+}
+
+export async function addDesktopNightlyAttestation({ outputDirectory, version, bundlePath }) {
+ const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
+ assertDesktopNightlyVersion(version, productManifest.version);
+ const details = await stat(bundlePath);
+ if (!details.isFile() || details.size === 0) {
+ throw new Error('Desktop Nightly attestation must be a non-empty regular file');
+ }
+ const name = `Maka-${version}-attestation.sigstore.json`;
+ const legacyDirectory = join(outputDirectory, 'bridge', 'versions', version);
+ await mkdir(legacyDirectory, { recursive: true });
+ await Promise.all([
+ copyFile(bundlePath, join(outputDirectory, 'release', name)),
+ copyFile(bundlePath, join(legacyDirectory, name)),
+ ]);
+ return name;
}
async function main(args) {
@@ -191,6 +380,33 @@ async function main(args) {
});
return;
}
+ if (command === 'add-attestation' && rest.length === 3) {
+ const [outputDirectory, version, bundlePath] = rest;
+ await addDesktopNightlyAttestation({ outputDirectory, version, bundlePath });
+ return;
+ }
+ if (command === 'resolve-cutover' && rest.length === 4) {
+ const [feedDirectory, releasesPath, candidateVersion, sourceCommit] = rest;
+ const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
+ const releases = JSON.parse(await readFile(releasesPath, 'utf8'));
+ const result = await resolveDesktopNightlyCutover({
+ candidateVersion,
+ cutoverSourceCommit: process.env.DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA ?? '',
+ feedDirectory,
+ productVersion: productManifest.version,
+ releases,
+ sourceCommit,
+ });
+ if (!process.env.GITHUB_OUTPUT) {
+ throw new Error('resolve-cutover requires GITHUB_OUTPUT');
+ }
+ await appendFile(
+ process.env.GITHUB_OUTPUT,
+ `bridge=${String(result.bridge)}\nprevious_version=${result.previousVersion ?? ''}\n`,
+ 'utf8',
+ );
+ return;
+ }
if (command === 'assert-feed-advance' && rest.length === 2) {
const [directory, candidateVersion] = rest;
const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
@@ -202,7 +418,7 @@ async function main(args) {
return;
}
throw new Error(
- 'usage: desktop-nightly.mjs stage | assert-feed-advance ',
+ 'usage: desktop-nightly.mjs stage | add-attestation | resolve-cutover | assert-feed-advance ',
);
}
diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs
index 52f437fbfc..5382234ac5 100644
--- a/scripts/desktop-nightly.test.mjs
+++ b/scripts/desktop-nightly.test.mjs
@@ -19,6 +19,7 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
+import { createRequire } from 'node:module';
import { promisify } from 'node:util';
import { dirname, join } from 'node:path';
import { test } from 'node:test';
@@ -29,8 +30,10 @@ import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs
const run = promisify(execFile);
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
+const require = createRequire(import.meta.url);
+const { GitHubProvider } = require('electron-updater/out/providers/GitHubProvider.js');
-test('a nightly package embeds only the Apache Nightlies update authority', () => {
+test('a nightly package embeds only the Apache GitHub dev update authority', () => {
const version = '0.2.0-dev.42.20260829';
const config = resolveDesktopBuilderConfig({
MAKA_DESKTOP_NIGHTLY_VERSION: version,
@@ -40,22 +43,27 @@ test('a nightly package embeds only the Apache Nightlies update authority', () =
assert.equal(config.extraMetadata.runtimeHostSetupPackage, `maka-agent@${version}`);
assert.equal(config.extraMetadata.makaUpdateChannel, 'nightly');
assert.equal(config.publish.length, 1);
- assert.equal(config.publish[0].provider, 'generic');
- assert.equal(config.publish[0].url, 'https://nightlies.apache.org/maka/desktop/');
+ assert.deepEqual(config.publish[0], {
+ provider: 'github',
+ owner: 'apache',
+ repo: 'maka',
+ channel: 'dev',
+ });
});
-test('a dev Nightly identity still advances the latest Desktop feed', () => {
+test('a dev Nightly identity writes the GitHub dev feed', () => {
const config = resolveDesktopBuilderConfig({
MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.42.20260829',
});
- assert.equal(config.publish[0].channel, 'latest');
+ assert.equal(config.publish[0].channel, 'dev');
});
-test('a packaged Nightly accepts the pinned latest update channel', async () => {
- const packagedConfiguration = `provider: generic
-url: https://nightlies.apache.org/maka/desktop/
-channel: latest
+test('a packaged Nightly accepts the pinned GitHub dev update channel', async () => {
+ const packagedConfiguration = `provider: github
+owner: apache
+repo: maka
+channel: dev
updaterCacheDirName: '@makadesktop-updater'
`;
@@ -65,6 +73,63 @@ updaterCacheDirName: '@makadesktop-updater'
});
});
+test('the GitHub dev provider resolves each platform payload to its absolute Release asset URL', () => {
+ const version = '0.2.0-dev.42.20260829';
+ for (const { platform, channel, name } of [
+ {
+ platform: 'darwin',
+ channel: 'dev-mac',
+ name: `Maka-${version}-mac-arm64.zip`,
+ },
+ { platform: 'win32', channel: 'dev', name: `Maka-${version}-win-x64.exe` },
+ ]) {
+ const provider = new GitHubProvider(
+ { provider: 'github', owner: 'apache', repo: 'maka', channel: 'dev' },
+ {
+ allowPrerelease: true,
+ channel: undefined,
+ currentVersion: { raw: version },
+ },
+ { executor: {}, platform },
+ );
+ const [resolved] = provider.resolveFiles({
+ tag: `v${version}`,
+ files: [{ url: name, sha512: 'fixture' }],
+ });
+
+ assert.equal(provider.channel, channel);
+ assert.equal(
+ resolved.url.href,
+ `https://github.com/apache/maka/releases/download/v${version}/${name}`,
+ );
+ }
+});
+
+test('GitHub differential updates derive the previous blockmap from the previous immutable tag', () => {
+ const previous = '0.2.0-dev.41.20260828';
+ const current = '0.2.0-dev.42.20260829';
+ const provider = new GitHubProvider(
+ { provider: 'github', owner: 'apache', repo: 'maka', channel: 'dev' },
+ {
+ allowPrerelease: true,
+ channel: undefined,
+ currentVersion: { raw: previous },
+ },
+ { executor: {}, platform: 'win32' },
+ );
+ const currentAsset = new URL(
+ `https://github.com/apache/maka/releases/download/v${current}/Maka-${current}-win-x64.exe`,
+ );
+
+ const [oldBlockmap, newBlockmap] = provider.getBlockMapFiles(currentAsset, previous, current);
+
+ assert.equal(
+ oldBlockmap.href,
+ `https://github.com/apache/maka/releases/download/v${previous}/Maka-${previous}-win-x64.exe.blockmap`,
+ );
+ assert.equal(newBlockmap.href, `${currentAsset.href}.blockmap`);
+});
+
test('formal release checks ignore the ambient Nightly packaging environment', async () => {
const { NODE_TEST_CONTEXT: _nodeTestContext, ...environment } = process.env;
await run(
diff --git a/scripts/desktop-update-contract.mjs b/scripts/desktop-update-contract.mjs
index 13c64c2e95..d058dcc3a1 100644
--- a/scripts/desktop-update-contract.mjs
+++ b/scripts/desktop-update-contract.mjs
@@ -31,9 +31,10 @@ export const DESKTOP_UPDATE_PROVIDER = Object.freeze({
updaterCacheDirName: '@makadesktop-updater',
});
export const DESKTOP_NIGHTLY_UPDATE_PROVIDER = Object.freeze({
- provider: 'generic',
- url: 'https://nightlies.apache.org/maka/desktop/',
- channel: 'latest',
+ provider: 'github',
+ owner: 'apache',
+ repo: 'maka',
+ channel: 'dev',
updaterCacheDirName: '@makadesktop-updater',
});
diff --git a/scripts/product-release-artifacts.mjs b/scripts/product-release-artifacts.mjs
index c4767a49cc..ae2596dfe5 100644
--- a/scripts/product-release-artifacts.mjs
+++ b/scripts/product-release-artifacts.mjs
@@ -106,7 +106,7 @@ function digestFile(path, algorithm = 'sha256') {
});
}
-async function artifactRecords(directory, names) {
+export async function productReleaseArtifactRecords(directory, names) {
return Promise.all(
[...names].sort(compareProductReleaseNames).map(async (name) => {
const path = join(directory, name);
@@ -259,7 +259,7 @@ export async function createProductReleasePublicationRecord({
sourceCommit: identity.sourceCommit,
tag: identity.tag,
version: identity.version,
- assets: await artifactRecords(artifactDirectory, allArtifactNames(identity)),
+ assets: await productReleaseArtifactRecords(artifactDirectory, allArtifactNames(identity)),
});
}
@@ -271,7 +271,7 @@ export async function verifyProductReleasePublicationRecord({
assertProductReleasePublicationRecord(record, expected);
const names = record.assets.map(({ name }) => name);
await verifyProductReleaseArtifactDirectory(artifactDirectory, names);
- const actual = await artifactRecords(artifactDirectory, names);
+ const actual = await productReleaseArtifactRecords(artifactDirectory, names);
if (JSON.stringify(actual) !== JSON.stringify(record.assets)) {
throw new Error('Product release artifacts do not match the immutable publication record');
}
From 08f202e343cbd3147cac36bca45e54c19dd872a6 Mon Sep 17 00:00:00 2001
From: AstroHan
Date: Mon, 31 Aug 2026 16:06:04 +0800
Subject: [PATCH 2/5] refactor(desktop): remove Nightlies compatibility bridge
Generated-by: Codex
---
.github/DESKTOP_NIGHTLY.md | 23 +-
.github/workflows/desktop-nightly.yml | 106 +------
README.md | 4 +-
README.zh-CN.md | 4 +-
.../__tests__/app-update-attestation.test.ts | 2 +-
docs/cli-npm-release.md | 11 +-
docs/cli-npm-release.zh-CN.md | 10 +-
scripts/desktop-nightly-release.test.mjs | 25 +-
scripts/desktop-nightly-stage.test.mjs | 290 +----------------
.../desktop-nightly-workflow-policy.test.mjs | 62 +---
scripts/desktop-nightly.mjs | 292 +-----------------
scripts/desktop-nightly.test.mjs | 10 +-
12 files changed, 67 insertions(+), 772 deletions(-)
diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md
index c1d3221039..f6120f2136 100644
--- a/.github/DESKTOP_NIGHTLY.md
+++ b/.github/DESKTOP_NIGHTLY.md
@@ -21,23 +21,22 @@
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 npm publication workflow gives each snapshot an immutable version such as `0.2.0-dev.42.20260829`. The run number is the sole ordering authority. After that exact npm version is public, it triggers Desktop Nightly with a version-only artifact; the authenticated workflow event supplies the exact source commit and upstream run. Each fresh Desktop Nightly creates an immutable `v` tag and one GitHub draft prerelease containing the macOS and Windows packages, blockmaps, `dev-mac.yml`, `dev.yml`, and one offline Sigstore bundle. The workflow verifies every remote asset before it publishes the prerelease as non-Latest. Packaged Nightlies use the GitHub `dev` channel and verify that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build uses the separate stable GitHub Release channel and formal product-release attestation identity.
+The npm publication workflow gives each snapshot an immutable version such as `0.2.0-dev.42.20260829`. The run number is the sole ordering authority. After that exact npm version is public, it triggers Desktop Nightly with a version-only artifact; the authenticated workflow event supplies the exact source commit and upstream run. Each fresh Desktop Nightly creates a protected `v` tag and one GitHub draft prerelease containing the macOS and Windows packages, blockmaps, `dev-mac.yml`, `dev.yml`, and one offline Sigstore bundle. The workflow verifies every remote asset before it publishes the prerelease as non-Latest. Packaged Nightlies use the GitHub `dev` channel and verify that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build uses the separate stable GitHub Release channel and 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.
+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. Builds previously downloaded from `nightlies.apache.org` do not migrate automatically; testers must install the newest GitHub prerelease once, after which GitHub Nightlies update automatically.
## One-time setup
-1. Ask Apache Infra to allow `apache/maka` to publish the one-time compatibility bridge to `nightlies.apache.org` and whitelist the repository for the standard `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_KEY`, `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_PORT`, and `NIGHTLIES_RSYNC_USER` secrets.
-2. After the checked-in `.asf.yaml` reaches `main`, verify that ASF reconciliation created the `nightly` GitHub Environment with only `main` permitted and no approval gate. Do not maintain that policy manually in GitHub. Verify that its jobs can read the five Infra-provided Nightlies 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. Configure npm Trusted Publishing for `apache/maka` and `.github/workflows/npm-publication.yml`, restricted to the `npm-publication` Environment and with both `npm publish` and `npm stage publish` allowed. Do not create or store a long-lived npm token.
-4. After npm Trusted Publishing is ready, set `NPM_NIGHTLY_ENABLED` to `true`, run `npm publication` from `main` with `channel=nightly`, and verify the exact npm version and `nightly` dist-tag. This does not depend on Desktop Infra.
-5. For the one-time Nightlies-to-GitHub cutover, choose the exact `main` commit that the next manually dispatched npm Nightly will build. Set the repository variable `DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA` to that full 40-character commit SHA before dispatching. The Desktop workflow fails closed unless its authenticated source SHA matches this value while the public Nightlies feed still points at Nightlies payloads.
-6. Set `DESKTOP_NIGHTLY_ENABLED` to `true` and manually dispatch a fresh npm Nightly from that exact `main` commit. Confirm that its successful run triggers `Desktop Nightly`. Do not rerun a failed attempt in place.
-7. Verify that `v` points to the exact source SHA and that its GitHub Release is published with Draft off, Prerelease on, Latest off, and exactly the nine expected assets. Verify that the public Nightlies `latest-mac.yml` and `latest.yml` now contain absolute URLs for those GitHub assets, that `versions//Maka--attestation.sigstore.json` serves the same bundle, that the Nightlies index links to GitHub downloads, and that `github-cutover.json` records the same version. This final small file is the bridge completion marker and is written only after every preceding cutover operation succeeds. Test an update from a pre-cutover Nightly on both platforms.
-8. Remove `DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA` after the public Nightlies feed has advanced. Install the cutover build, publish one later fresh Nightly, and confirm a GitHub-to-GitHub differential update on both platforms before sharing the channel with testers.
+1. After the checked-in `.asf.yaml` reaches `main`, verify that ASF reconciliation created the `nightly` GitHub Environment with only `main` permitted and no approval gate. Do not maintain that policy manually in GitHub. 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.
+2. Configure npm Trusted Publishing for `apache/maka` and `.github/workflows/npm-publication.yml`, restricted to the `npm-publication` Environment and with both `npm publish` and `npm stage publish` allowed. Do not create or store a long-lived npm token.
+3. Enable GitHub Immutable Releases for `apache/maka` before distributing Desktop Nightlies so published tags and assets cannot be replaced or deleted.
+4. After npm Trusted Publishing is ready, set `NPM_NIGHTLY_ENABLED` to `true`, run `npm publication` from `main` with `channel=nightly`, and verify the exact npm version and `nightly` dist-tag.
+5. Set `DESKTOP_NIGHTLY_ENABLED` to `true` and manually dispatch a fresh npm Nightly. Confirm that its successful run triggers `Desktop Nightly`. Do not rerun a failed attempt in place.
+6. Verify that `v` points to the exact source SHA and that its GitHub Release is published with Draft off, Prerelease on, Latest off, Immutable on, and exactly the nine expected assets. Install that prerelease on both platforms.
+7. Publish one later fresh Nightly and confirm a GitHub-to-GitHub automatic and differential update on both platforms before sharing the channel with testers.
-The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop separately requires its version to advance the highest valid GitHub `dev` prerelease across retained product versions. It assembles and verifies a draft before one publish mutation; a packaging, attestation, tag, upload, or digest failure leaves no partially published GitHub Release. During the one-time bridge it stages only the compatibility Sigstore bundle on Nightlies, publishes the complete GitHub prerelease, advances the legacy feed and index, and writes the completion marker last. Until that marker exists, any legacy, partial, or otherwise interrupted bridge state remains recoverable by updating the cutover variable to the next fresh source SHA and dispatching a new npm Nightly. Never rerun a failed workflow attempt in place. Once the marker exists, steady-state runs do not use Nightlies SSH or write packages, blockmaps, metadata, attestations, or bridge files there.
+The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop assembles and verifies a draft before one publish mutation; a packaging, attestation, tag, upload, or digest failure leaves no partially published GitHub Release. Never rerun a failed workflow attempt in place; dispatch a fresh npm Nightly with a newer version.
-GitHub Release retention is intentionally outside this workflow. Do not delete an old Nightly prerelease or its tag while any installed client may need its payload or blockmap; in particular, retain the successful bridge release. Disabling `DESKTOP_NIGHTLY_ENABLED` stops new Desktop publication without mutating tags, releases, or the legacy bridge.
+GitHub Release retention is intentionally outside this workflow. Do not delete an old Nightly prerelease or its tag while any installed client may need its payload or blockmap. Disabling `DESKTOP_NIGHTLY_ENABLED` stops new Desktop publication without mutating tags or releases.
Remote Runtime Host setup uses the exact `maka-agent@` package embedded in the Desktop manifest. The npm package is verified before Desktop artifacts become visible, so clean remote setup never depends on an unpublished Runtime Host version.
diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml
index 034818059b..fb03a3b485 100644
--- a/.github/workflows/desktop-nightly.yml
+++ b/.github/workflows/desktop-nightly.yml
@@ -225,7 +225,6 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.identity.outputs.source_commit }}
- fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
@@ -244,57 +243,14 @@ jobs:
path: ${{ github.workspace }}/.nightly-input
merge-multiple: true
- - name: Stage the GitHub Release and compatibility bridge
+ - name: Stage the exact GitHub Release assets
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-stage" \
- "$NIGHTLY_VERSION" \
- "$SOURCE_COMMIT"
-
- - name: Read the public legacy Nightlies feed
- run: |
- mkdir -p "$GITHUB_WORKSPACE/.nightly-current-feed"
- curl --fail --location --retry 3 \
- --output "$GITHUB_WORKSPACE/.nightly-current-feed/latest-mac.yml" \
- https://nightlies.apache.org/maka/desktop/latest-mac.yml
- curl --fail --location --retry 3 \
- --output "$GITHUB_WORKSPACE/.nightly-current-feed/latest.yml" \
- https://nightlies.apache.org/maka/desktop/latest.yml
- marker_status=$(curl --silent --show-error --location --retry 3 \
- --output "$GITHUB_WORKSPACE/.nightly-current-feed/github-cutover.json" \
- --write-out '%{http_code}' \
- https://nightlies.apache.org/maka/desktop/github-cutover.json)
- if [[ "$marker_status" == 404 ]]; then
- rm -f "$GITHUB_WORKSPACE/.nightly-current-feed/github-cutover.json"
- elif [[ "$marker_status" != 200 ]]; then
- echo "Unexpected Nightlies cutover marker status: $marker_status" >&2
- exit 1
- fi
-
- - name: Read the live GitHub Release history
- env:
- GH_TOKEN: ${{ github.token }}
- run: >-
- gh api --paginate --slurp
- "repos/$GITHUB_REPOSITORY/releases?per_page=100"
- > "$RUNNER_TEMP/desktop-nightly-releases.json"
-
- - name: Resolve the one-time Nightlies cutover
- id: cutover
- env:
- DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA: ${{ vars.DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA }}
- NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
- SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
- run: |
- node scripts/desktop-nightly.mjs resolve-cutover \
- "$GITHUB_WORKSPACE/.nightly-current-feed" \
- "$RUNNER_TEMP/desktop-nightly-releases.json" \
- "$NIGHTLY_VERSION" \
- "$SOURCE_COMMIT"
+ "$NIGHTLY_VERSION"
- name: Attest every GitHub Nightly asset subject
id: attest
@@ -332,7 +288,7 @@ jobs:
"$NIGHTLY_VERSION" \
"$ATTESTATION_BUNDLE"
- - name: Ensure the exact immutable Nightly tag
+ - name: Ensure the exact versioned Nightly tag
env:
GH_TOKEN: ${{ github.token }}
NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
@@ -353,44 +309,6 @@ jobs:
"$SOURCE_COMMIT" \
"$GITHUB_REPOSITORY"
- - name: Prepare authenticated Nightlies SSH transport
- if: steps.cutover.outputs.bridge == 'true'
- env:
- NIGHTLIES_RSYNC_HOST: ${{ secrets.NIGHTLIES_RSYNC_HOST }}
- NIGHTLIES_RSYNC_KEY: ${{ secrets.NIGHTLIES_RSYNC_KEY }}
- 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_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"
- {
- echo "NIGHTLIES_RSYNC_BASE=$NIGHTLIES_RSYNC_USER@$NIGHTLIES_RSYNC_HOST:${NIGHTLIES_RSYNC_PATH%/}"
- 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=no -o UserKnownHostsFile=/dev/null -p $NIGHTLIES_RSYNC_PORT"
- } >> "$GITHUB_ENV"
-
- - name: Ensure the Nightly destination exists
- if: steps.cutover.outputs.bridge == 'true'
- run: |
- mkdir -p .nightly-empty/maka/desktop
- rsync -rlptDz --protect-args .nightly-empty/maka/ "$NIGHTLIES_RSYNC_BASE/maka/"
-
- - name: Stage the legacy cutover attestation
- if: steps.cutover.outputs.bridge == 'true'
- run: >-
- rsync -rlptDvz --protect-args
- .nightly-stage/bridge/versions/
- "$NIGHTLIES_RSYNC_TARGET/versions/"
-
- name: Publish the complete GitHub Prerelease
env:
GH_TOKEN: ${{ github.token }}
@@ -402,21 +320,3 @@ jobs:
"$NIGHTLY_VERSION" \
"$SOURCE_COMMIT" \
"$GITHUB_REPOSITORY"
-
- - name: Advance the legacy Nightlies feed to GitHub
- if: steps.cutover.outputs.bridge == 'true'
- run: >-
- rsync -rlptDvz --protect-args
- .nightly-stage/bridge/feed/
- "$NIGHTLIES_RSYNC_TARGET/"
-
- - name: Commit the completed legacy bridge
- if: steps.cutover.outputs.bridge == 'true'
- run: >-
- rsync -rlptDvz --protect-args
- .nightly-stage/bridge/completion/github-cutover.json
- "$NIGHTLIES_RSYNC_TARGET/github-cutover.json"
-
- - name: Remove the temporary Nightlies credentials
- if: always() && steps.cutover.outputs.bridge == 'true'
- run: rm -f "$RUNNER_TEMP/nightlies-ssh/key"
diff --git a/README.md b/README.md
index 4f6cc46034..d8533b7b17 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@
- 
+ 
Daily builds from main for developers and testers. Not an ASF release, not intended for production use.
@@ -101,7 +101,7 @@ Apache Maka has not made an Apache release yet. Everything currently published f
Once Apache releases exist, the official release is the source release published by the ASF and approved by the podling PPMC and the Incubator PMC. A package built from that source and distributed elsewhere, for example through a package registry or as a Desktop installer, is a convenience artifact rather than the release itself, and it is valid only when it is built from an approved source release. [`.github/ASF_SOURCE_RELEASE.md`](./.github/ASF_SOURCE_RELEASE.md) holds the candidate contract, signing path, and verification steps.
-[Desktop Nightly](https://nightlies.apache.org/maka/desktop/) is built daily from `main` for developers and testers. It is not an ASF release and is not intended for production use. Desktop currently targets Apple Silicon Macs (`arm64`). Intel Macs and Linux are not supported yet. [Windows](docs/windows-support.md) is an unsigned preview, not a supported release tier.
+[Desktop Nightly](https://github.com/apache/maka/releases) is built daily from `main` for developers and testers. Choose the newest **Maka Desktop Nightly** prerelease; after installation, the app updates automatically on the Nightly channel. It is not an ASF release and is not intended for production use. Desktop currently targets Apple Silicon Macs (`arm64`). Intel Macs and Linux are not supported yet. [Windows](docs/windows-support.md) is an unsigned preview, not a supported release tier.
### Requirements
diff --git a/README.zh-CN.md b/README.zh-CN.md
index d0a050ebe1..ce2643c0d2 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -39,7 +39,7 @@
- 
+ 
每天从 main 构建,面向开发者和测试者。不是 ASF release,也不适合生产使用。
@@ -99,7 +99,7 @@ Apache Maka 目前还没有发布过 Apache release。当前从本仓库或包
在 Apache release 出现之后,官方 release 指的是由 ASF 发布、并经 podling PPMC 和 Incubator PMC 批准的源码 release。由该源码构建并通过其他渠道分发的包,例如包管理器中的包或 Desktop 安装程序,属于 convenience artifact,本身不是 release,并且只有在由获批源码 release 构建时才有效。候选契约、签名路径和验包步骤见 [`.github/ASF_SOURCE_RELEASE.md`](./.github/ASF_SOURCE_RELEASE.md)。
-[Desktop Nightly](https://nightlies.apache.org/maka/desktop/) 面向开发者和测试者,每天从 `main` 构建。它不是 ASF release,不适合生产使用。Desktop 目前面向 Apple Silicon Mac(`arm64`)。暂不支持 Intel Mac 和 Linux。[Windows](docs/windows-support.md) 是未签名预览,不是正式支持的发布层级。
+[Desktop Nightly](https://github.com/apache/maka/releases) 面向开发者和测试者,每天从 `main` 构建。请选择最新的 **Maka Desktop Nightly** prerelease;安装后,应用会在 Nightly 渠道自动更新。它不是 ASF release,不适合生产使用。Desktop 目前面向 Apple Silicon Mac(`arm64`)。暂不支持 Intel Mac 和 Linux。[Windows](docs/windows-support.md) 是未签名预览,不是正式支持的发布层级。
### 环境要求
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 713d48137f..c4b69c7753 100644
--- a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts
+++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts
@@ -108,7 +108,7 @@ test('download verification accepts only a trusted exact artifact subject', asyn
);
});
-test('nightly verification fetches provenance from the immutable GitHub Release asset', async (t) => {
+test('nightly verification fetches provenance from the versioned GitHub Release asset', 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');
diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md
index 1f5ee2b048..0b5fc3f60d 100644
--- a/docs/cli-npm-release.md
+++ b/docs/cli-npm-release.md
@@ -131,14 +131,13 @@ The two workflows publish in this order:
1. require the candidate npm run number to be newer than the current `nightly` tag;
2. publish the exact npm tarball with provenance under `nightly`;
3. require both the exact version and `nightly` tag to be readable from the public registry;
-4. require the candidate Desktop run number to be newer than every existing platform feed;
-5. append the immutable Desktop payloads to `nightlies.apache.org`;
-6. advance the mutable Desktop update feed last.
+4. build, verify, and attest the exact Desktop packages and GitHub `dev` metadata;
+5. bind a protected `v` tag to the exact source commit and verify all nine draft assets;
+6. publish the GitHub prerelease with Latest disabled only after the draft is complete.
This ordering prevents Desktop from advertising a Runtime Host version that npm does not have and
-lets npm Nightly operate before Desktop's Infra transport is enabled. A failed npm or Desktop run is
-never rerun in place because npm versions and the Nightlies version directory are immutable; start
-a fresh npm Nightly run instead:
+keeps npm Nightly independent from Desktop packaging. A failed npm or Desktop run is never rerun in
+place because each attempt has an immutable npm version; start a fresh npm Nightly run instead:
```sh
gh workflow run npm-publication.yml --ref main -f channel=nightly
diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md
index 06f9bef79d..989a59f04d 100644
--- a/docs/cli-npm-release.zh-CN.md
+++ b/docs/cli-npm-release.zh-CN.md
@@ -116,12 +116,12 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token
1. 要求候选 npm run number 大于当前 `nightly` tag;
2. 使用 provenance 将精确 npm tarball 发布到 `nightly`;
3. 要求公共 registry 中的精确版本和 `nightly` tag 都已可读;
-4. 要求候选 Desktop run number 大于所有现有平台 feed;
-5. 向 `nightlies.apache.org` 追加 immutable Desktop payload;
-6. 最后推进可变的 Desktop update feed。
+4. 构建、验证并 attest 精确的 Desktop 安装包和 GitHub `dev` metadata;
+5. 将受保护的 `v` tag 绑定到精确 source commit,并验证 Draft 中全部九项资产;
+6. 仅在 Draft 完整后发布 Latest 关闭的 GitHub prerelease。
-这个顺序既避免 Desktop 指向 npm 中不存在的 Runtime Host,也允许 npm Nightly 在 Desktop Infra
-就绪前独立运行。npm 或 Desktop run 失败后都不得原地 rerun;应启动新的 npm Nightly:
+这个顺序既避免 Desktop 指向 npm 中不存在的 Runtime Host,也让 npm Nightly 与 Desktop 打包彼此
+独立。npm 或 Desktop run 失败后都不得原地 rerun;应启动新的 npm Nightly:
```sh
gh workflow run npm-publication.yml --ref main -f channel=nightly
diff --git a/scripts/desktop-nightly-release.test.mjs b/scripts/desktop-nightly-release.test.mjs
index aca196c9ac..e350d69994 100644
--- a/scripts/desktop-nightly-release.test.mjs
+++ b/scripts/desktop-nightly-release.test.mjs
@@ -21,7 +21,7 @@ import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { basename, join } from 'node:path';
import test from 'node:test';
import {
prepareDesktopNightlyRelease,
@@ -50,7 +50,7 @@ async function writeUpdateSet(directory, version, platform) {
]);
}
-async function stageRelease(root, version, sourceCommit) {
+async function stageRelease(root, version) {
const input = join(root, 'input');
const output = join(root, 'output');
await mkdir(input);
@@ -64,7 +64,6 @@ async function stageRelease(root, version, sourceCommit) {
inputDirectory: input,
outputDirectory: output,
version,
- sourceCommit,
});
const bundle = join(root, 'bundle.json');
await writeFile(bundle, 'sigstore bundle');
@@ -78,13 +77,13 @@ test('Nightly publication verifies the exact draft before one Prerelease/non-Lat
const version = '0.2.0-dev.42.20260829';
const tag = `v${version}`;
const sourceCommit = 'a'.repeat(40);
- const directory = await stageRelease(root, version, sourceCommit);
+ const directory = await stageRelease(root, version);
const records = await productReleaseArtifactRecords(
directory,
desktopNightlyReleaseAssetNames(version),
);
const calls = [];
- let uploaded = false;
+ let remoteAssets = records.slice(0, 1);
let draft = true;
let tampered = false;
const release = () => ({
@@ -92,17 +91,15 @@ test('Nightly publication verifies the exact draft before one Prerelease/non-Lat
tagName: tag,
isDraft: draft,
isPrerelease: true,
- assets: uploaded
- ? records.map((record, index) =>
- tampered && index === 0 ? { ...record, digest: `sha256:${'0'.repeat(64)}` } : record,
- )
- : [],
+ assets: remoteAssets.map((record, index) =>
+ tampered && index === 0 ? { ...record, digest: `sha256:${'0'.repeat(64)}` } : record,
+ ),
});
const run = async (command, args) => {
calls.push([command, args]);
if (command === 'git') return { stdout: `${sourceCommit}\trefs/tags/${tag}\n` };
if (command === 'gh' && args[0] === 'release' && args[1] === 'upload') {
- uploaded = true;
+ remoteAssets = records;
return { stdout: '' };
}
if (command === 'gh' && args[0] === 'release' && args[1] === 'edit') return { stdout: '' };
@@ -162,6 +159,10 @@ test('Nightly publication verifies the exact draft before one Prerelease/non-Lat
const upload = calls.find(([, args]) => args[0] === 'release' && args[1] === 'upload');
const patchCall = calls.find(([, args]) => args.includes('PATCH'));
assert.ok(upload);
+ assert.deepEqual(
+ upload[1].slice(3, upload[1].indexOf('--repo')).map((path) => basename(path)),
+ records.slice(1).map(({ name }) => name),
+ );
assert.ok(patchCall);
assert.ok(calls.indexOf(upload) < calls.indexOf(patchCall));
assert.ok(patchCall[1].includes('draft=false'));
@@ -180,7 +181,7 @@ test('a missing Nightly release is created only as a draft prerelease with Lates
const version = '0.2.0-dev.42.20260829';
const tag = `v${version}`;
const sourceCommit = 'a'.repeat(40);
- const directory = await stageRelease(root, version, sourceCommit);
+ const directory = await stageRelease(root, version);
const records = await productReleaseArtifactRecords(
directory,
desktopNightlyReleaseAssetNames(version),
diff --git a/scripts/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs
index 89689ef12b..c8a23a5388 100644
--- a/scripts/desktop-nightly-stage.test.mjs
+++ b/scripts/desktop-nightly-stage.test.mjs
@@ -24,12 +24,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { stringify } from 'yaml';
-import {
- addDesktopNightlyAttestation,
- assertDesktopNightlyFeedAdvance,
- resolveDesktopNightlyCutover,
- stageDesktopNightly,
-} from './desktop-nightly.mjs';
+import { addDesktopNightlyAttestation, stageDesktopNightly } from './desktop-nightly.mjs';
import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs';
async function writeUpdateSet(directory, version, platform) {
@@ -52,33 +47,7 @@ async function writeUpdateSet(directory, version, platform) {
);
}
-function publishedNightly(version) {
- return {
- tag_name: `v${version}`,
- draft: false,
- prerelease: true,
- assets: [
- `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`,
- `Maka-${version}-attestation.sigstore.json`,
- 'dev-mac.yml',
- 'dev.yml',
- ].map((name) => ({ name })),
- };
-}
-
-async function writeCutoverMarker(directory, version) {
- await writeFile(
- join(directory, 'github-cutover.json'),
- `${JSON.stringify({ schemaVersion: 1, authority: 'github-releases', version })}\n`,
- );
-}
-
-test('staging creates the exact GitHub assets and a payload-free legacy bridge', async (t) => {
+test('staging creates only the exact GitHub Release assets', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-'));
t.after(() => rm(root, { recursive: true, force: true }));
const input = join(root, 'input');
@@ -96,7 +65,6 @@ test('staging creates the exact GitHub assets and a payload-free legacy bridge',
inputDirectory: input,
outputDirectory: output,
version,
- sourceCommit: 'a'.repeat(40),
});
const payloadNames = [
@@ -126,48 +94,14 @@ test('staging creates the exact GitHub assets and a payload-free legacy bridge',
}),
]);
- const macMetadata = (await import('yaml')).parse(
- await readFile(join(output, 'bridge', 'feed', 'latest-mac.yml'), 'utf8'),
- );
- const windowsMetadata = (await import('yaml')).parse(
- await readFile(join(output, 'bridge', 'feed', 'latest.yml'), 'utf8'),
- );
- assert.equal(
- macMetadata.files[0].url,
- `https://github.com/apache/maka/releases/download/v${version}/Maka-${version}-mac-arm64.zip`,
- );
- assert.equal(
- windowsMetadata.path,
- `https://github.com/apache/maka/releases/download/v${version}/Maka-${version}-win-x64.exe`,
- );
- const index = await readFile(join(output, 'bridge', '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(`releases/download/v${version}/Maka-${version}-mac-arm64\\.dmg`, 'u'),
- );
- assert.match(
- index,
- new RegExp(`releases/download/v${version}/Maka-${version}-win-x64\\.exe`, 'u'),
- );
- assert.deepEqual((await readdir(join(output, 'bridge', 'feed'))).sort(), [
- 'index.html',
- 'latest-mac.yml',
- 'latest.yml',
- ]);
- assert.deepEqual(
- JSON.parse(await readFile(join(output, 'bridge', 'completion', 'github-cutover.json'), 'utf8')),
- { schemaVersion: 1, authority: 'github-releases', version },
- );
assert.deepEqual(
(await readdir(release)).sort(),
[...payloadNames, 'dev-mac.yml', 'dev.yml'].sort(),
);
- assert.deepEqual((await readdir(join(output, 'bridge'))).sort(), ['completion', 'feed']);
+ assert.deepEqual(await readdir(output), ['release']);
});
-test('one attestation bundle is staged as a GitHub asset and at the legacy fixed path', async (t) => {
+test('one attestation bundle is staged only as a GitHub Release asset', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-attestation-'));
t.after(() => rm(root, { recursive: true, force: true }));
const output = join(root, 'output');
@@ -181,219 +115,5 @@ test('one attestation bundle is staged as a GitHub asset and at the legacy fixed
const name = `Maka-${version}-attestation.sigstore.json`;
assert.deepEqual(await readFile(join(release, name)), bytes);
- assert.deepEqual(await readFile(join(output, 'bridge', 'versions', version, name)), bytes);
-});
-
-test('the Desktop feed advances only to a newer npm run number', async (t) => {
- const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-feed-'));
- t.after(() => rm(directory, { recursive: true, force: true }));
- await Promise.all([
- writeFile(join(directory, 'latest-mac.yml'), 'version: 0.2.0-dev.42.20260829\n'),
- writeFile(join(directory, 'latest.yml'), 'version: 0.2.0-dev.42.20260829\n'),
- ]);
- await assert.doesNotReject(
- assertDesktopNightlyFeedAdvance({
- directory,
- candidateVersion: '0.3.0-dev.43.20260828',
- productVersion: '0.3.0',
- }),
- );
- await assert.rejects(
- assertDesktopNightlyFeedAdvance({
- directory,
- candidateVersion: '0.2.0-dev.41.20260830',
- productVersion: '0.2.0',
- }),
- /does not advance current run/u,
- );
-});
-
-test('the pending legacy feed bridges only for its explicitly authorized source SHA', async (t) => {
- const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-cutover-'));
- t.after(() => rm(directory, { recursive: true, force: true }));
- const previous = '0.2.0-dev.41.20260828';
- const candidate = '0.2.0-dev.42.20260829';
- const sourceCommit = 'a'.repeat(40);
- await Promise.all([
- writeFile(
- join(directory, 'latest-mac.yml'),
- `version: ${previous}\npath: versions/${previous}/Maka-${previous}-mac-arm64.zip\n`,
- ),
- writeFile(
- join(directory, 'latest.yml'),
- `version: ${previous}\npath: versions/${previous}/Maka-${previous}-win-x64.exe\n`,
- ),
- ]);
-
- assert.deepEqual(
- await resolveDesktopNightlyCutover({
- candidateVersion: candidate,
- cutoverSourceCommit: sourceCommit,
- feedDirectory: directory,
- productVersion: '0.2.0',
- releases: [],
- sourceCommit,
- }),
- { bridge: true, previousVersion: undefined },
- );
- await assert.rejects(
- resolveDesktopNightlyCutover({
- candidateVersion: candidate,
- cutoverSourceCommit: 'b'.repeat(40),
- feedDirectory: directory,
- productVersion: '0.2.0',
- releases: [],
- sourceCommit,
- }),
- /cutover source SHA/u,
- );
- await writeFile(
- join(directory, 'latest.yml'),
- `version: ${previous}\npath: https://example.invalid/Maka-${previous}-win-x64.exe\n`,
- );
- await assert.rejects(
- resolveDesktopNightlyCutover({
- candidateVersion: candidate,
- cutoverSourceCommit: sourceCommit,
- feedDirectory: directory,
- productVersion: '0.2.0',
- releases: [],
- sourceCommit,
- }),
- /legacy Nightlies asset/u,
- );
-});
-
-test('the completed bridge advances from the latest valid GitHub dev prerelease without writing Nightlies', async (t) => {
- const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-steady-'));
- t.after(() => rm(directory, { recursive: true, force: true }));
- const previous = '0.2.0-dev.41.20260828';
- const candidate = '0.2.0-dev.42.20260829';
- const sourceCommit = 'a'.repeat(40);
- const base = `https://github.com/apache/maka/releases/download/v${previous}`;
- await Promise.all([
- writeFile(
- join(directory, 'latest-mac.yml'),
- `version: ${previous}\npath: ${base}/Maka-${previous}-mac-arm64.zip\n`,
- ),
- writeFile(
- join(directory, 'latest.yml'),
- `version: ${previous}\npath: ${base}/Maka-${previous}-win-x64.exe\n`,
- ),
- ]);
- const input = {
- candidateVersion: candidate,
- cutoverSourceCommit: sourceCommit,
- feedDirectory: directory,
- productVersion: '0.2.0',
- releases: [publishedNightly(previous)],
- sourceCommit,
- };
-
- assert.deepEqual(await resolveDesktopNightlyCutover(input), {
- bridge: true,
- previousVersion: previous,
- });
- await writeCutoverMarker(directory, previous);
- assert.deepEqual(await resolveDesktopNightlyCutover(input), {
- bridge: false,
- previousVersion: previous,
- });
- await assert.rejects(
- resolveDesktopNightlyCutover({ ...input, candidateVersion: previous }),
- /does not advance current run/u,
- );
- await assert.rejects(
- resolveDesktopNightlyCutover({
- ...input,
- releases: [{ ...publishedNightly(previous), assets: [] }],
- }),
- /GitHub dev prerelease assets/u,
- );
- await Promise.all([
- writeFile(
- join(directory, 'latest-mac.yml'),
- `version: ${previous}\npath: https://github.com/apache/maka/releases/download/v9.9.9/Maka-${previous}-mac-arm64.zip\n`,
- ),
- writeFile(
- join(directory, 'latest.yml'),
- `version: ${previous}\npath: https://github.com/apache/maka/releases/download/v9.9.9/Maka-${previous}-win-x64.exe\n`,
- ),
- ]);
- await assert.rejects(resolveDesktopNightlyCutover(input), /exact GitHub Release/u);
- const missing = '0.2.0-dev.40.20260827';
- const missingBase = `https://github.com/apache/maka/releases/download/v${missing}`;
- await Promise.all([
- writeFile(
- join(directory, 'latest-mac.yml'),
- `version: ${missing}\npath: ${missingBase}/Maka-${missing}-mac-arm64.zip\n`,
- ),
- writeFile(
- join(directory, 'latest.yml'),
- `version: ${missing}\npath: ${missingBase}/Maka-${missing}-win-x64.exe\n`,
- ),
- ]);
- await assert.rejects(resolveDesktopNightlyCutover(input), /completed bridge/u);
-});
-
-test('a partially published legacy bridge converges on the next authorized fresh run', async (t) => {
- const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-partial-'));
- t.after(() => rm(directory, { recursive: true, force: true }));
- const legacy = '0.2.0-dev.40.20260827';
- const published = '0.2.0-dev.41.20260828';
- const candidate = '0.2.0-dev.42.20260829';
- const sourceCommit = 'a'.repeat(40);
- await Promise.all([
- writeFile(
- join(directory, 'latest-mac.yml'),
- `version: ${published}\npath: https://github.com/apache/maka/releases/download/v${published}/Maka-${published}-mac-arm64.zip\n`,
- ),
- writeFile(
- join(directory, 'latest.yml'),
- `version: ${legacy}\npath: versions/${legacy}/Maka-${legacy}-win-x64.exe\n`,
- ),
- ]);
-
- assert.deepEqual(
- await resolveDesktopNightlyCutover({
- candidateVersion: candidate,
- cutoverSourceCommit: sourceCommit,
- feedDirectory: directory,
- productVersion: '0.2.0',
- releases: [publishedNightly(published)],
- sourceCommit,
- }),
- { bridge: true, previousVersion: published },
- );
-});
-
-test('retained Nightlies from an older product line remain the cross-version ordering authority', async (t) => {
- const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-retained-'));
- t.after(() => rm(directory, { recursive: true, force: true }));
- const previous = '0.2.0-dev.42.20260829';
- const candidate = '0.3.0-dev.43.20260830';
- const base = `https://github.com/apache/maka/releases/download/v${previous}`;
- await Promise.all([
- writeFile(
- join(directory, 'latest-mac.yml'),
- `version: ${previous}\npath: ${base}/Maka-${previous}-mac-arm64.zip\n`,
- ),
- writeFile(
- join(directory, 'latest.yml'),
- `version: ${previous}\npath: ${base}/Maka-${previous}-win-x64.exe\n`,
- ),
- ]);
- await writeCutoverMarker(directory, previous);
-
- assert.deepEqual(
- await resolveDesktopNightlyCutover({
- candidateVersion: candidate,
- cutoverSourceCommit: '',
- feedDirectory: directory,
- productVersion: '0.3.0',
- releases: [publishedNightly(previous)],
- sourceCommit: 'a'.repeat(40),
- }),
- { bridge: false, previousVersion: previous },
- );
+ assert.deepEqual(await readdir(output), ['release']);
});
diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs
index a9a880643f..b604549cb9 100644
--- a/scripts/desktop-nightly-workflow-policy.test.mjs
+++ b/scripts/desktop-nightly-workflow-policy.test.mjs
@@ -124,11 +124,10 @@ test('the publisher verifies exact GitHub identity and assets before publishing
const workflow = await readWorkflow('desktop-nightly.yml');
const steps = workflow.jobs.publish.steps;
const positions = [
- 'Resolve the one-time Nightlies cutover',
'Attest every GitHub Nightly asset subject',
'Verify the issued Nightly provenance',
'Add the one offline provenance bundle',
- 'Ensure the exact immutable Nightly tag',
+ 'Ensure the exact versioned Nightly tag',
'Prepare and verify the draft GitHub Prerelease',
'Publish the complete GitHub Prerelease',
].map((name) => steps.findIndex((step) => step.name === name));
@@ -137,60 +136,21 @@ test('the publisher verifies exact GitHub identity and assets before publishing
positions,
positions.toSorted((left, right) => left - right),
);
- const cutover = steps[positions[0]];
+ assert.match(steps[positions[0]].with['subject-path'], /\.nightly-stage\/release\/\*/u);
+ assert.match(steps[positions[3]].run, /product-release-tag\.mjs ensure/u);
+ assert.match(steps[positions[4]].run, /desktop-nightly-release\.mjs prepare/u);
+ assert.match(steps[positions[5]].run, /desktop-nightly-release\.mjs publish/u);
assert.equal(
- cutover.env.DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA,
- '${{ vars.DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA }}',
- );
- assert.match(cutover.run, /desktop-nightly\.mjs resolve-cutover/u);
- assert.match(steps[positions[1]].with['subject-path'], /\.nightly-stage\/release\/\*/u);
- assert.match(steps[positions[4]].run, /product-release-tag\.mjs ensure/u);
- assert.match(steps[positions[5]].run, /desktop-nightly-release\.mjs prepare/u);
- assert.match(steps[positions[6]].run, /desktop-nightly-release\.mjs publish/u);
- assert.equal(
- steps[positions[2]].env.CERTIFICATE_IDENTITY,
+ steps[positions[1]].env.CERTIFICATE_IDENTITY,
'https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main',
);
});
-test('only the explicit cutover writes the legacy bundle and feed, never large payloads', async () => {
+test('Desktop Nightly has no Apache Nightlies transport or compatibility state', async () => {
const workflow = await readWorkflow('desktop-nightly.yml');
- const publish = workflow.jobs.publish;
- assert.equal(workflow.jobs.desktop.environment, 'nightly');
- assert.equal(publish.environment, 'nightly');
- assert.equal(
- publish.steps.filter((step) => step.uses?.startsWith('burnett01/rsync-deployments@')).length,
- 0,
+ assert.equal(workflow.jobs.publish.environment, 'nightly');
+ assert.doesNotMatch(
+ JSON.stringify(workflow),
+ /nightlies\.apache\.org|NIGHTLIES_RSYNC|resolve-cutover|github-cutover|\brsync\b|\bssh\b/u,
);
- const steps = publish.steps;
- const transport = publish.steps.find(
- (step) => step.name === 'Prepare authenticated Nightlies SSH transport',
- );
- assert.equal(transport.if, "steps.cutover.outputs.bridge == 'true'");
- assert.equal(transport.env.NIGHTLIES_RSYNC_KEY, '${{ secrets.NIGHTLIES_RSYNC_KEY }}');
- assert.deepEqual(Object.keys(transport.env).toSorted(), [
- 'NIGHTLIES_RSYNC_HOST',
- 'NIGHTLIES_RSYNC_KEY',
- 'NIGHTLIES_RSYNC_PATH',
- 'NIGHTLIES_RSYNC_PORT',
- 'NIGHTLIES_RSYNC_USER',
- ]);
- assert.match(transport.run, /StrictHostKeyChecking=no/u);
- assert.match(transport.run, /UserKnownHostsFile=\/dev\/null/u);
- assert.doesNotMatch(transport.run, /ssh-keyscan|KNOWN_HOSTS/u);
- const bundle = steps.find((step) => step.name === 'Stage the legacy cutover attestation');
- const release = steps.find((step) => step.name === 'Publish the complete GitHub Prerelease');
- const feed = steps.find((step) => step.name === 'Advance the legacy Nightlies feed to GitHub');
- const completion = steps.find((step) => step.name === 'Commit the completed legacy bridge');
- for (const step of [bundle, feed, completion]) {
- assert.equal(step.if, "steps.cutover.outputs.bridge == 'true'");
- assert.doesNotMatch(step.run, /--delete/u);
- assert.doesNotMatch(step.run, /mac-arm64\.(?:dmg|zip)|win-x64\.(?:exe|zip)|blockmap/u);
- }
- assert.match(bundle.run, /\.nightly-stage\/bridge\/versions\//u);
- assert.match(feed.run, /\.nightly-stage\/bridge\/feed\//u);
- assert.match(completion.run, /bridge\/completion\/github-cutover\.json/u);
- assert.ok(steps.indexOf(bundle) < steps.indexOf(release));
- assert.ok(steps.indexOf(release) < steps.indexOf(feed));
- assert.ok(steps.indexOf(feed) < steps.indexOf(completion));
});
diff --git a/scripts/desktop-nightly.mjs b/scripts/desktop-nightly.mjs
index 7ac3dfb6cc..2159552142 100644
--- a/scripts/desktop-nightly.mjs
+++ b/scripts/desktop-nightly.mjs
@@ -17,25 +17,11 @@
* under the License.
*/
-import {
- appendFile,
- copyFile,
- mkdir,
- readFile,
- readdir,
- rm,
- stat,
- writeFile,
-} from 'node:fs/promises';
+import { copyFile, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs';
-import {
- assertProductNightlyAdvances,
- assertProductNightlyVersion,
- parseProductNightlyVersion,
- productNightlyRunNumber,
-} from './release-version.mjs';
+import { assertProductNightlyVersion } from './release-version.mjs';
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -54,67 +40,6 @@ export function resolveRuntimeHostSetupPackage(productVersion, environment = pro
return `maka-agent@${resolveDesktopBuildVersion(productVersion, environment)}`;
}
-export async function assertDesktopNightlyFeedAdvance({
- directory,
- candidateVersion,
- productVersion,
-}) {
- const { parse } = await import('yaml');
- for (const name of ['latest-mac.yml', 'latest.yml']) {
- let source;
- try {
- source = await readFile(join(directory, name), 'utf8');
- } catch (error) {
- if (error?.code === 'ENOENT') continue;
- throw error;
- }
- const currentVersion = parse(source)?.version;
- if (typeof currentVersion !== 'string') {
- throw new Error(`Desktop Nightly feed ${name} has no valid version`);
- }
- assertProductNightlyAdvances(candidateVersion, currentVersion, productVersion);
- }
- return candidateVersion;
-}
-
-async function readLegacyNightlyFeed(directory) {
- const { parse } = await import('yaml');
- const entries = [];
- for (const name of ['latest-mac.yml', 'latest.yml']) {
- const metadata = parse(await readFile(join(directory, name), 'utf8'));
- if (typeof metadata?.version !== 'string' || typeof metadata?.path !== 'string') {
- throw new Error(`Desktop Nightly feed ${name} has no valid versioned payload`);
- }
- entries.push({ name, metadata });
- }
- return entries;
-}
-
-async function readCutoverMarker(directory) {
- let source;
- try {
- source = await readFile(join(directory, 'github-cutover.json'), 'utf8');
- } catch (error) {
- if (error?.code === 'ENOENT') return undefined;
- throw error;
- }
- let marker;
- try {
- marker = JSON.parse(source);
- } catch (error) {
- throw new Error('Desktop Nightly cutover marker is invalid', { cause: error });
- }
- if (
- marker?.schemaVersion !== 1 ||
- marker?.authority !== 'github-releases' ||
- typeof marker?.version !== 'string'
- ) {
- throw new Error('Desktop Nightly cutover marker is invalid');
- }
- parseProductNightlyVersion(marker.version);
- return marker;
-}
-
export function desktopNightlyReleaseAssetNames(version) {
const names = nightlyArtifactNames(version);
return [
@@ -130,100 +55,6 @@ export function desktopNightlyReleaseAssetNames(version) {
].sort();
}
-function latestPublishedDesktopNightly(releases) {
- let latest;
- const versions = new Set();
- for (const release of releases.flat()) {
- if (release?.draft === true || release?.prerelease !== true) continue;
- const match = /^v(.+-dev\..+)$/u.exec(release?.tag_name ?? '');
- if (!match) continue;
- const version = match[1];
- parseProductNightlyVersion(version);
- const actual = (release.assets ?? []).map(({ name }) => name).sort();
- const expected = desktopNightlyReleaseAssetNames(version);
- if (JSON.stringify(actual) !== JSON.stringify(expected)) {
- throw new Error(`GitHub dev prerelease assets are invalid for v${version}`);
- }
- versions.add(version);
- if (!latest || productNightlyRunNumber(version) > productNightlyRunNumber(latest)) {
- latest = version;
- }
- }
- return { latest, versions };
-}
-
-export async function resolveDesktopNightlyCutover({
- candidateVersion,
- cutoverSourceCommit,
- feedDirectory,
- productVersion,
- releases,
- sourceCommit,
-}) {
- assertDesktopNightlyVersion(candidateVersion, productVersion);
- if (!Array.isArray(releases)) throw new Error('GitHub Releases must be an array');
- const published = latestPublishedDesktopNightly(releases);
- const previousVersion = published.latest;
- assertProductNightlyAdvances(candidateVersion, previousVersion, productVersion);
- const feeds = await readLegacyNightlyFeed(feedDirectory);
- const marker = await readCutoverMarker(feedDirectory);
- const bridgeStates = feeds.map(({ name, metadata }) => {
- const artifact =
- name === 'latest-mac.yml'
- ? `Maka-${metadata.version}-mac-arm64.zip`
- : `Maka-${metadata.version}-win-x64.exe`;
- const expected = githubNightlyAssetUrl(metadata.version, artifact);
- const githubPath = metadata.path.startsWith(
- 'https://github.com/apache/maka/releases/download/',
- );
- if (githubPath && metadata.path !== expected) {
- throw new Error(`${name} does not point to the exact GitHub Release asset`);
- }
- if (
- githubPath &&
- Array.isArray(metadata.files) &&
- (metadata.files.length !== 1 || metadata.files[0]?.url !== expected)
- ) {
- throw new Error(`${name} does not point to the exact GitHub Release asset`);
- }
- const legacy = `versions/${metadata.version}/${artifact}`;
- if (
- !githubPath &&
- (metadata.path !== legacy ||
- (Array.isArray(metadata.files) &&
- (metadata.files.length !== 1 || metadata.files[0]?.url !== legacy)))
- ) {
- throw new Error(`${name} does not point to the exact legacy Nightlies asset`);
- }
- return githubPath;
- });
- const bridged = bridgeStates.every(Boolean);
- if (marker) {
- if (
- !bridged ||
- feeds.some(({ metadata }) => metadata.version !== marker.version) ||
- !published.versions.has(marker.version)
- ) {
- throw new Error('Desktop Nightly completed bridge does not match a GitHub dev prerelease');
- }
- return { bridge: false, previousVersion };
- }
- for (const [index, state] of bridgeStates.entries()) {
- if (state && !published.versions.has(feeds[index].metadata.version)) {
- throw new Error('Desktop Nightly legacy feed points to a missing GitHub dev prerelease');
- }
- }
- await assertDesktopNightlyFeedAdvance({
- directory: feedDirectory,
- candidateVersion,
- productVersion,
- });
- if (cutoverSourceCommit !== sourceCommit || !/^[0-9a-f]{40}$/u.test(sourceCommit)) {
- throw new Error('Desktop Nightly cutover source SHA does not match the exact build source');
- }
- return { bridge: true, previousVersion };
-}
-
function nightlyArtifactNames(version) {
return {
macZip: `Maka-${version}-mac-arm64.zip`,
@@ -233,52 +64,9 @@ function nightlyArtifactNames(version) {
};
}
-function githubNightlyAssetUrl(version, name) {
- return `https://github.com/apache/maka/releases/download/v${encodeURIComponent(version)}/${encodeURIComponent(name)}`;
-}
-
-async function writeLegacyBridgeMetadata(source, destination, version) {
- const { parse, stringify } = await import('yaml');
- const metadata = parse(await readFile(source, 'utf8'));
- metadata.path = githubNightlyAssetUrl(version, metadata.path);
- metadata.files = metadata.files.map((file) => ({
- ...file,
- url: githubNightlyAssetUrl(version, 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,
-}) {
+export async function stageDesktopNightly({ inputDirectory, outputDirectory, version }) {
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,
@@ -314,13 +102,7 @@ export async function stageDesktopNightly({
await rm(outputDirectory, { recursive: true, force: true });
const releaseDirectory = join(outputDirectory, 'release');
- const bridgeFeedDirectory = join(outputDirectory, 'bridge', 'feed');
- const bridgeCompletionDirectory = join(outputDirectory, 'bridge', 'completion');
- await Promise.all([
- mkdir(releaseDirectory, { recursive: true }),
- mkdir(bridgeFeedDirectory, { recursive: true }),
- mkdir(bridgeCompletionDirectory, { recursive: true }),
- ]);
+ await mkdir(releaseDirectory, { recursive: true });
await Promise.all(
[...payloads, ...metadataNames].map(async (name) => {
const source = join(inputDirectory, name);
@@ -329,26 +111,6 @@ export async function stageDesktopNightly({
await copyFile(source, join(releaseDirectory, name));
}),
);
- await Promise.all([
- writeLegacyBridgeMetadata(
- join(inputDirectory, 'dev-mac.yml'),
- join(bridgeFeedDirectory, 'latest-mac.yml'),
- version,
- ),
- writeLegacyBridgeMetadata(
- join(inputDirectory, 'dev.yml'),
- join(bridgeFeedDirectory, 'latest.yml'),
- version,
- ),
- ]);
- await writeFile(
- join(bridgeFeedDirectory, 'index.html'),
- nightlyIndex(version, sourceCommit, names),
- );
- await writeFile(
- join(bridgeCompletionDirectory, 'github-cutover.json'),
- `${JSON.stringify({ schemaVersion: 1, authority: 'github-releases', version })}\n`,
- );
}
export async function addDesktopNightlyAttestation({ outputDirectory, version, bundlePath }) {
@@ -359,24 +121,18 @@ export async function addDesktopNightlyAttestation({ outputDirectory, version, b
throw new Error('Desktop Nightly attestation must be a non-empty regular file');
}
const name = `Maka-${version}-attestation.sigstore.json`;
- const legacyDirectory = join(outputDirectory, 'bridge', 'versions', version);
- await mkdir(legacyDirectory, { recursive: true });
- await Promise.all([
- copyFile(bundlePath, join(outputDirectory, 'release', name)),
- copyFile(bundlePath, join(legacyDirectory, name)),
- ]);
+ await copyFile(bundlePath, join(outputDirectory, 'release', name));
return name;
}
async function main(args) {
const [command, ...rest] = args;
- if (command === 'stage' && rest.length === 4) {
- const [inputDirectory, outputDirectory, version, sourceCommit] = rest;
+ if (command === 'stage' && rest.length === 3) {
+ const [inputDirectory, outputDirectory, version] = rest;
await stageDesktopNightly({
inputDirectory,
outputDirectory,
version,
- sourceCommit,
});
return;
}
@@ -385,40 +141,8 @@ async function main(args) {
await addDesktopNightlyAttestation({ outputDirectory, version, bundlePath });
return;
}
- if (command === 'resolve-cutover' && rest.length === 4) {
- const [feedDirectory, releasesPath, candidateVersion, sourceCommit] = rest;
- const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
- const releases = JSON.parse(await readFile(releasesPath, 'utf8'));
- const result = await resolveDesktopNightlyCutover({
- candidateVersion,
- cutoverSourceCommit: process.env.DESKTOP_NIGHTLY_GITHUB_CUTOVER_SOURCE_SHA ?? '',
- feedDirectory,
- productVersion: productManifest.version,
- releases,
- sourceCommit,
- });
- if (!process.env.GITHUB_OUTPUT) {
- throw new Error('resolve-cutover requires GITHUB_OUTPUT');
- }
- await appendFile(
- process.env.GITHUB_OUTPUT,
- `bridge=${String(result.bridge)}\nprevious_version=${result.previousVersion ?? ''}\n`,
- 'utf8',
- );
- return;
- }
- if (command === 'assert-feed-advance' && rest.length === 2) {
- const [directory, candidateVersion] = rest;
- const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
- await assertDesktopNightlyFeedAdvance({
- directory,
- candidateVersion,
- productVersion: productManifest.version,
- });
- return;
- }
throw new Error(
- 'usage: desktop-nightly.mjs stage | add-attestation | resolve-cutover | assert-feed-advance ',
+ 'usage: desktop-nightly.mjs stage | add-attestation ',
);
}
diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs
index 5382234ac5..b7f1477de3 100644
--- a/scripts/desktop-nightly.test.mjs
+++ b/scripts/desktop-nightly.test.mjs
@@ -51,14 +51,6 @@ test('a nightly package embeds only the Apache GitHub dev update authority', ()
});
});
-test('a dev Nightly identity writes the GitHub dev feed', () => {
- const config = resolveDesktopBuilderConfig({
- MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.42.20260829',
- });
-
- assert.equal(config.publish[0].channel, 'dev');
-});
-
test('a packaged Nightly accepts the pinned GitHub dev update channel', async () => {
const packagedConfiguration = `provider: github
owner: apache
@@ -105,7 +97,7 @@ test('the GitHub dev provider resolves each platform payload to its absolute Rel
}
});
-test('GitHub differential updates derive the previous blockmap from the previous immutable tag', () => {
+test('GitHub differential updates derive the previous blockmap from the previous versioned tag', () => {
const previous = '0.2.0-dev.41.20260828';
const current = '0.2.0-dev.42.20260829';
const provider = new GitHubProvider(
From 3492ecd95a4c41bb394ff17e69dc14abb8323959 Mon Sep 17 00:00:00 2001
From: AstroHan
Date: Mon, 31 Aug 2026 16:49:47 +0800
Subject: [PATCH 3/5] fix(desktop): accept Nightly update metadata
Generated-by: Codex
---
scripts/desktop-nightly.test.mjs | 39 ++++++++++++++++++++++++++++++++
scripts/package-macos-arm64.mjs | 5 +++-
scripts/package-windows-x64.mjs | 11 ++++++---
3 files changed, 51 insertions(+), 4 deletions(-)
diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs
index b7f1477de3..6d61c6af4f 100644
--- a/scripts/desktop-nightly.test.mjs
+++ b/scripts/desktop-nightly.test.mjs
@@ -25,6 +25,8 @@ 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 { packageMacosArm64 } from './package-macos-arm64.mjs';
+import { packageWindowsX64 } from './package-windows-x64.mjs';
import { resolveDesktopBuildVersion, resolveRuntimeHostSetupPackage } from './desktop-nightly.mjs';
import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs';
@@ -51,6 +53,43 @@ test('a nightly package embeds only the Apache GitHub dev update authority', ()
});
});
+test('the macOS Nightly wrapper accepts dev update metadata', async () => {
+ const version = '0.2.0-dev.42.20260829';
+ await packageMacosArm64({
+ platform: 'darwin',
+ arch: 'arm64',
+ env: {
+ MAKA_DESKTOP_NIGHTLY_VERSION: version,
+ CSC_LINK: 'fixture',
+ CSC_KEY_PASSWORD: 'fixture',
+ APPLE_API_KEY: 'fixture',
+ APPLE_API_KEY_ID: 'fixture',
+ APPLE_API_ISSUER: 'fixture',
+ },
+ run: async () => {},
+ remove: async () => {},
+ assertFile: async (path) => {
+ if (path.endsWith('.yml')) assert.match(path, /\/dev-mac\.yml$/u);
+ },
+ });
+});
+
+test('the Windows Nightly wrapper accepts dev update metadata', async () => {
+ const version = '0.2.0-dev.42.20260829';
+ await packageWindowsX64({
+ platform: 'win32',
+ arch: 'x64',
+ env: { MAKA_DESKTOP_NIGHTLY_VERSION: version },
+ run: async () => {},
+ remove: async () => {},
+ makeDirectory: async () => {},
+ copy: async () => {},
+ assertFile: async (path) => {
+ if (path.endsWith('.yml')) assert.match(path, /\/dev\.yml$/u);
+ },
+ });
+});
+
test('a packaged Nightly accepts the pinned GitHub dev update channel', async () => {
const packagedConfiguration = `provider: github
owner: apache
diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs
index 47df4b0b5b..c7b97fba7e 100644
--- a/scripts/package-macos-arm64.mjs
+++ b/scripts/package-macos-arm64.mjs
@@ -85,7 +85,10 @@ export async function packageMacosArm64({
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');
+ const updateMetadataPath = join(
+ releaseDirectory,
+ buildVersion === manifest.version ? 'latest-mac.yml' : 'dev-mac.yml',
+ );
for (const path of requiredElectronLicensePaths) {
await assertFile(path);
diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs
index 6ad99bf0c3..28bdef8fe1 100644
--- a/scripts/package-windows-x64.mjs
+++ b/scripts/package-windows-x64.mjs
@@ -88,6 +88,8 @@ export async function packageWindowsX64({
env = process.env,
remove = rm,
assertFile = access,
+ makeDirectory = mkdir,
+ copy = copyFile,
} = {}) {
if (platform !== 'win32' || arch !== 'x64') {
throw new Error('Release packaging requires a Windows x64 host.');
@@ -97,7 +99,10 @@ export async function packageWindowsX64({
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 updateMetadataPath = join(
+ releaseDirectory,
+ buildVersion === manifest.version ? 'latest.yml' : 'dev.yml',
+ );
const unpackedDirectory = join(releaseDirectory, 'win-unpacked');
for (const path of requiredElectronLicensePaths) {
@@ -110,8 +115,8 @@ export async function packageWindowsX64({
await run('npm', ['run', 'check:runtime-host-peer-notices']);
await run('cargo', ['build', '--manifest-path', sandboxManifestPath, '--release', '--locked']);
await run('npm', ['run', 'check:windows-cargo-notices']);
- await mkdir(sandboxResourceDirectory, { recursive: true });
- await copyFile(sandboxBinaryPath, sandboxResourcePath);
+ await makeDirectory(sandboxResourceDirectory, { recursive: true });
+ await copy(sandboxBinaryPath, sandboxResourcePath);
await run('npm', ['run', 'check:release']);
await remove(releaseDirectory, { recursive: true, force: true });
await run('npm', ['--workspace', '@maka/desktop', 'run', 'package:windows-x64']);
From 6af9173a84ab11f33a10a7ff25faa028d43cccdc Mon Sep 17 00:00:00 2001
From: AstroHan
Date: Mon, 31 Aug 2026 16:50:28 +0800
Subject: [PATCH 4/5] refactor(desktop): remove redundant Latest check
Generated-by: Codex
---
scripts/desktop-nightly-release.mjs | 8 --------
scripts/desktop-nightly-release.test.mjs | 3 ---
2 files changed, 11 deletions(-)
diff --git a/scripts/desktop-nightly-release.mjs b/scripts/desktop-nightly-release.mjs
index a343781803..0b9407c341 100644
--- a/scripts/desktop-nightly-release.mjs
+++ b/scripts/desktop-nightly-release.mjs
@@ -268,14 +268,6 @@ export async function publishDesktopNightlyRelease({
assertReleaseState(await viewRelease({ repository, tag, cwd, run }), { tag, draft: false }),
expected,
);
- try {
- const latest = await run('gh', ['api', `repos/${repository}/releases/latest`], { cwd });
- if (JSON.parse(latest.stdout)?.tag_name === tag) {
- throw new Error(`Desktop Nightly ${tag} must not become Latest`);
- }
- } catch (error) {
- if (!/HTTP 404/u.test(`${error?.stderr ?? ''}\n${error?.message ?? ''}`)) throw error;
- }
return published;
}
diff --git a/scripts/desktop-nightly-release.test.mjs b/scripts/desktop-nightly-release.test.mjs
index e350d69994..00d0ce8e49 100644
--- a/scripts/desktop-nightly-release.test.mjs
+++ b/scripts/desktop-nightly-release.test.mjs
@@ -118,9 +118,6 @@ test('Nightly publication verifies the exact draft before one Prerelease/non-Lat
}),
};
}
- if (command === 'gh' && args.includes('repos/apache/maka/releases/latest')) {
- return { stdout: JSON.stringify({ tag_name: 'v0.1.11' }) };
- }
throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
};
From d284fca8664aa6fb69e091788fc9968a0c1279cf Mon Sep 17 00:00:00 2001
From: AstroHan
Date: Mon, 31 Aug 2026 17:07:21 +0800
Subject: [PATCH 5/5] test(desktop): make Nightly metadata checks portable
Generated-by: Codex
---
scripts/desktop-nightly.test.mjs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs
index 6d61c6af4f..c65b595756 100644
--- a/scripts/desktop-nightly.test.mjs
+++ b/scripts/desktop-nightly.test.mjs
@@ -21,7 +21,7 @@ import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { createRequire } from 'node:module';
import { promisify } from 'node:util';
-import { dirname, join } from 'node:path';
+import { basename, dirname, join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { resolveDesktopBuilderConfig } from '../apps/desktop/electron-builder.config.mjs';
@@ -69,7 +69,7 @@ test('the macOS Nightly wrapper accepts dev update metadata', async () => {
run: async () => {},
remove: async () => {},
assertFile: async (path) => {
- if (path.endsWith('.yml')) assert.match(path, /\/dev-mac\.yml$/u);
+ if (path.endsWith('.yml')) assert.equal(basename(path), 'dev-mac.yml');
},
});
});
@@ -85,7 +85,7 @@ test('the Windows Nightly wrapper accepts dev update metadata', async () => {
makeDirectory: async () => {},
copy: async () => {},
assertFile: async (path) => {
- if (path.endsWith('.yml')) assert.match(path, /\/dev\.yml$/u);
+ if (path.endsWith('.yml')) assert.equal(basename(path), 'dev.yml');
},
});
});