Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 98 additions & 21 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
name: Nightly

# Builds main every night (and on demand) for every platform and replaces the rolling `nightly`
# GitHub prerelease with its setups and portable packages. A nightly is download-only: it is on
# no update feed, needs no signing key and never enters the `release` environment.
# GitHub prerelease with its setups and portable packages. With a nightly key (the `nightly`
# environment, see docs/how-to/release.md) it also signs update-nightly.json, which clients on
# 每日构建 follow, and puts the update packages next to it on the `updates` release. The production
# key and the `release` environment are never used here.
#
# Version: <next release>-nightly.<UTC date>, e.g. 1.0.3-nightly.20261108. It ranks below the
# release it leads up to, so that release replaces it. The scheduled run skips a night when main
# has not moved since the last nightly; a manual run always builds.
# Version: <next release>-nightly.<UTC date>.<run>, e.g. 1.0.3-nightly.20261108.42 (nightlyVersion in
# tools/release/lib/version.mjs). It ranks below the release it leads up to, so that release replaces
# it. The scheduled run skips a night when main has not moved since the last nightly; a manual run
# always builds.
on:
schedule:
- cron: '0 18 * * *' # 02:00 Beijing time
Expand All @@ -28,6 +31,7 @@ jobs:
build: ${{ steps.version.outputs.build }}
version: ${{ steps.version.outputs.version }}
sha: ${{ steps.version.outputs.sha }}
last: ${{ steps.version.outputs.last }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -40,6 +44,7 @@ jobs:
name: Version from package.json and the date
env:
EVENT: ${{ github.event_name }}
RUN: ${{ github.run_number }}
run: |
sha=$(git rev-parse HEAD)
last=$(git ls-remote origin refs/tags/nightly | cut -f1)
Expand All @@ -48,21 +53,19 @@ jobs:
echo "build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# The next release: package.json, or one patch later when that version already shipped.
version=$(node --no-warnings --input-type=module -e '
import { execFileSync } from "node:child_process"
import { readFileSync } from "node:fs"
import { parseVersion } from "./tools/release/lib/version.mjs"
const v = parseVersion(JSON.parse(readFileSync("apps/desktop/package.json", "utf8")).version)
let core = `${v.major}.${v.minor}.${v.patch}`
import { nightlyVersion } from "./tools/release/lib/version.mjs"
const tags = execFileSync("git", ["tag", "--list"], { encoding: "utf8" }).split("\n")
if (tags.includes(core) || tags.includes(`v${core}`)) core = `${v.major}.${v.minor}.${v.patch + 1}`
console.log(`${core}-nightly.${process.argv[1]}`)
' "$(date -u +%Y%m%d)")
const pkg = JSON.parse(readFileSync("apps/desktop/package.json", "utf8")).version
console.log(nightlyVersion(pkg, tags, new Date(), Number(process.argv[1])))
' "$RUN")
echo "nightly $version from $sha"
echo "build=true" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "last=$last" >> "$GITHUB_OUTPUT"

build:
name: Build ${{ matrix.platform }}
Expand Down Expand Up @@ -128,46 +131,120 @@ jobs:
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-${{ matrix.platform }}
# Downloads only: the full package and file list exist for updates, which nightlies are not part of.
# Setups and portable packages for the `nightly` release; full packages and file lists for updates.
path: |
dist/release/artifacts/MossStudio-Setup-*
dist/release/artifacts/MossStudio-*-portable.*
dist/release/artifacts/MossStudio-*.dmg
dist/release/artifacts/MossStudio-*
dist/release/artifacts/files-*
if-no-files-found: error
compression-level: 0
retention-days: 3

publish:
name: Replace the nightly release
name: Update feed and the nightly release
needs: [prepare, build]
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 45
# Holds MOSS_NIGHTLY_SIGNING_KEY. Limit the environment to `main`; it needs no reviewer, because
# clients trust its key for update-nightly.json only (NIGHTLY_UPDATE_KEYS).
environment: nightly
permissions:
contents: write
env:
VERSION: ${{ needs.prepare.outputs.version }}
SHA: ${{ needs.prepare.outputs.sha }}
LAST: ${{ needs.prepare.outputs.last }}
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ART: dist/release/artifacts
PREV: dist/release/previous
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.sha }}
fetch-depth: 0
- uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- name: Install the runtime packages the checks use (yauzl, zod)
run: pnpm install --frozen-lockfile --prod --ignore-scripts
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: nightly-*
path: nightly
path: dist/release/artifacts
merge-multiple: true
- name: Notes
run: |
# The commits since the last nightly, when it is an ancestor (a force-push can drop it).
subjects=()
if [ -n "$LAST" ] && git merge-base --is-ancestor "$LAST" "$SHA" 2>/dev/null; then
mapfile -t subjects < <(git log --no-merges --format=%s "$LAST..$SHA")
fi
node --input-type=module -e '
import { nightlyNotes } from "./tools/release/lib/feed.mjs"
const [sha, repo, ...subjects] = process.argv.slice(1)
console.log(nightlyNotes({ sha, repo, subjects }))
' "$SHA" "$GH_REPO" "${subjects[@]}" > feed-notes.md
cat > notes.md <<EOF
每日构建 **$VERSION**,来自 \`main\` 的 [\`${SHA::7}\`](https://github.com/$GH_REPO/commit/$SHA)。

- 每晚自动更新,只保留最新一版。
- 未经发布前的完整测试,可能不稳定。日常使用请下载[正式版](https://github.com/$GH_REPO/releases/latest)。
- 不会通过应用内更新推送给任何人,只能在这里手动下载。
- 已安装的 MossStudio 在「设置 › 通知与更新」把更新渠道选为「每日构建」后,会每晚自动更新到这里的新版本。
- 安装包未做系统签名,首次打开时系统可能会提示。
EOF
- name: Feed (only with a nightly key)
id: feed
env:
MOSS_UPDATE_SIGNING_KEY: ${{ secrets.MOSS_NIGHTLY_SIGNING_KEY }}
run: |
if [ -z "$MOSS_UPDATE_SIGNING_KEY" ]; then
echo "::notice::No MOSS_NIGHTLY_SIGNING_KEY in the nightly environment: this nightly is download-only."
echo "feed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
node --no-warnings tools/release/fetch-nightly.mjs --out "$PREV"
node tools/release/make-deltas.mjs --version "$VERSION" --previous "$PREV" --artifacts "$ART"
node tools/release/make-feed.mjs --nightly --version "$VERSION" --artifacts "$ART" --notes-file feed-notes.md --current-nightly "$PREV/current-update-nightly.json"
if [ ! -f "$ART/update-nightly.json" ]; then echo "feed=false" >> "$GITHUB_OUTPUT"; exit 0; fi
node tools/release/sign-feed.mjs "$ART/update-nightly.json"
# Fails unless the key's public half is in NIGHTLY_UPDATE_KEYS, so clients will accept the feed.
node --no-warnings tools/release/verify-artifacts.mjs --version "$VERSION" --artifacts "$ART" --previous "$PREV" --nightly-keys
echo "feed=true" >> "$GITHUB_OUTPUT"
- name: Update packages and the feed on the rolling `updates` release
if: steps.feed.outputs.feed == 'true'
run: |
if ! gh release view updates >/dev/null 2>&1; then
gh release create updates --title "Update feeds" --notes "Signed update feeds (update-<channel>.json + .sig) read by MossStudio, and the latest nightly's update packages. Replaced by every release and nightly; not a download." --prerelease --latest=false --target "$SHA"
fi
shopt -s nullglob
packages=("$ART"/MossStudio-"$VERSION"-*-full.zip "$ART"/files-"$VERSION"-*.json "$ART"/MossStudio-*-to-"$VERSION"-*.delta.zip)
gh release upload updates "${packages[@]}" --clobber
# The signature first: a client that reads the new .sig with the old feed fails the
# check and retries later; it never trusts anything unverified.
gh release upload updates "$ART/update-nightly.json.sig" --clobber
gh release upload updates "$ART/update-nightly.json" --clobber
# Only now that the feed names the new packages: drop older nightlies' ones, keeping the
# previous nightly's so a download that started from the old feed can finish.
previous=""
if [ -f "$PREV/current-update-nightly.json" ]; then
previous=$(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).version' "$PREV/current-update-nightly.json")
fi
gh release view updates --json assets -q '.assets[].name' > updates-assets.txt
node --input-type=module -e '
import { readFileSync } from "node:fs"
import { staleNightlyPackages } from "./tools/release/lib/version.mjs"
const names = readFileSync("updates-assets.txt", "utf8").split("\n").filter(Boolean)
for (const name of staleNightlyPackages(names, process.argv.slice(1))) console.log(name)
' "$VERSION" "$previous" > stale.txt
while read -r name; do gh release delete-asset updates "$name" --yes; done < stale.txt
- name: Replace release `nightly`
run: |
# Delete first so the tag moves to the new commit and no old file lingers.
if gh release view nightly >/dev/null 2>&1; then gh release delete nightly --cleanup-tag --yes; fi
git ls-remote --exit-code "https://github.com/$GH_REPO" refs/tags/nightly >/dev/null 2>&1 && gh api -X DELETE "repos/$GH_REPO/git/refs/tags/nightly"
gh release create nightly nightly/* --target "$SHA" --title "MossStudio $VERSION(每日构建)" --notes-file notes.md --prerelease --latest=false
shopt -s nullglob
downloads=("$ART"/MossStudio-Setup-* "$ART"/MossStudio-*-portable.* "$ART"/MossStudio-*.dmg)
gh release create nightly "${downloads[@]}" --target "$SHA" --title "MossStudio $VERSION(每日构建)" --notes-file notes.md --prerelease --latest=false
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ jobs:
- name: Replace the feeds on the rolling `updates` release
run: |
if ! gh release view updates >/dev/null 2>&1; then
gh release create updates --title "Update feeds" --notes "Signed update feeds (update-<channel>.json + .sig) read by MossStudio. Replaced by every release; not a download." --prerelease --latest=false --target "$GITHUB_SHA"
gh release create updates --title "Update feeds" --notes "Signed update feeds (update-<channel>.json + .sig) read by MossStudio, and the latest nightly's update packages. Replaced by every release and nightly; not a download." --prerelease --latest=false --target "$GITHUB_SHA"
fi
shopt -s nullglob
# The signature first: a client that reads the new .sig with the old feed fails the
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/e2e/specs/update.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,11 @@ describe('设置 › 通知与更新', () => {
await channel.getByRole('button', { name: '测试版' }).click()
await expect.poll(() => app.calls('update:set-channel')).toEqual([{ channel: 'beta' }])
await expect.poll(() => channel.getByRole('button', { name: '测试版' }).getAttribute('aria-pressed')).toBe('true')
await channel.getByRole('button', { name: '每日构建' }).click()
await expect.poll(() => app.calls('update:set-channel')).toEqual([{ channel: 'beta' }, { channel: 'nightly' }])
await expect.poll(() => channel.getByRole('button', { name: '每日构建' }).getAttribute('aria-pressed')).toBe('true')
expect(await channel.getByRole('button', { name: '测试版' }).getAttribute('aria-pressed')).toBe('false')
await app.screenshot('update-channel-nightly-light')
const auto = page.getByRole('switch', { name: '自动更新' })
expect(await auto.getAttribute('aria-checked')).toBe('true')
await auto.click()
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { spawn } from 'node:child_process'
// original-fs: app folders hold app.asar, which Electron's own fs treats as a folder.
import { chmodSync, copyFileSync, existsSync, mkdirSync, promises as fsp, readdirSync, renameSync, rmSync } from 'original-fs'
import { basename, join } from 'node:path'
import type { InstallLocator, InstallRecord, PlatformKey, UpdateChannel } from '@shared/install'
import type { InstallLocator, InstallRecord, PlatformKey, RecordChannel } from '@shared/install'
import { codedError } from '../lib/codedError'
import { copyTree, listTree, treeBytes } from './copy'
import { writeLinuxIntegration, windowsShortcutPaths, writeWindowsShortcuts, type WriteShortcut } from './integration'
Expand Down Expand Up @@ -61,7 +61,7 @@ export interface InstallRequest {
/** Explicit data root (macOS 高级); null = keep the existing one or the default. */
dataRoot: string | null
legacy: LegacyChoice | null
channel?: UpdateChannel
channel?: RecordChannel
}

/** Streams a program's output lines (ditto -V); resolves with the exit code. */
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/services/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const DEFAULT_SETTINGS: Settings = {
lanWebPort: null,
lanWebInstanceId: null,
updateChannel: 'stable',
nightlyUpdates: false,
autoDownloadUpdates: true,
skippedUpdateVersion: null,
dismissedAnnouncements: [],
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/update/feed.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// original-fs: in the packaged main process node:fs treats every *.asar as a folder; app folders are plain files here.
import { promises as fsp } from 'original-fs'
import { join } from 'node:path'
import type { UpdateChannel, UpdateFeed } from '@shared/install'
import { UPDATE_CHANNELS, type UpdateChannel, type UpdateFeed } from '@shared/install'
import type { UpdatePublicKey } from '@shared/updateKeys'
import type { Settings } from '@shared/types'
import { compareSemver } from '@shared/update'
Expand Down Expand Up @@ -132,7 +132,7 @@ async function readHighWaterFile(dir: string): Promise<HighWaterFile> {
try {
const raw = JSON.parse(await readFile(join(dir, HIGH_WATER_FILE), 'utf8')) as Record<string, unknown>
const result: HighWaterFile = {}
for (const channel of ['stable', 'beta'] as const) {
for (const channel of UPDATE_CHANNELS) {
const entry = raw[channel] as Partial<FeedHighWater> | undefined
if (entry && typeof entry.version === 'string' && typeof entry.publishedAt === 'string')
result[channel] = { version: entry.version, publishedAt: entry.publishedAt }
Expand Down
7 changes: 3 additions & 4 deletions apps/desktop/src/main/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { getSettings, updateSettings } from '../services/settings'
import { readBusySnapshot } from './busySources'
import { updateContext } from './context'
import { armAppHealth, type HealthWindow } from './health'
import { isUpdateTestBuild, trustedUpdateKeys, updateFeedUrl } from './keys'
import { hasAnyUpdateKey, isUpdateTestBuild, trustedUpdateKeys, updateFeedUrl } from './keys'
import { cleanExitMarkerPath, failedMarkerPath, UpdateRuntime } from './runtime'
import { UpdateScheduler } from './schedule'
import { loadTestHook, readyToRestart, shouldCrash, TEST_CRASH_EXIT_CODE, writeTestStatus, type UpdateTestHook } from './testHook'
Expand Down Expand Up @@ -48,13 +48,12 @@ function writable(path: string): boolean {
function getRuntime(): UpdateRuntime {
if (runtime) return runtime
const run = getRunContext()
const keys = trustedUpdateKeys()
const context = updateContext({ run, hasKeys: keys.length > 0, exists: existsSync, writable })
const context = updateContext({ run, hasKeys: hasAnyUpdateKey(), exists: existsSync, writable })
const recordPath = run.recordPath
runtime = new UpdateRuntime({
version: app.getVersion(),
context,
keys: () => keys,
keys: (channel) => trustedUpdateKeys(channel),
feedUrl: (channel) => updateFeedUrl(channel),
// The CI hook always downloads automatically.
settings: () => (hook()?.pollMs ? { ...getSettings(), autoDownloadUpdates: true } : getSettings()),
Expand Down
22 changes: 17 additions & 5 deletions apps/desktop/src/main/update/keys.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { feedUrl, type UpdateChannel } from '@shared/install'
import { PRODUCTION_UPDATE_KEYS, parseUpdateKeys, type UpdatePublicKey } from '@shared/updateKeys'
import { feedUrl, UPDATE_CHANNELS, type UpdateChannel } from '@shared/install'
import { NIGHTLY_UPDATE_KEYS, PRODUCTION_UPDATE_KEYS, parseUpdateKeys, type UpdatePublicKey } from '@shared/updateKeys'

/**
* Production keys plus, in CI integration builds only, the test keys baked in at BUILD time via
* The keys a channel's feed may be signed with: the production keys, the nightly keys for the
* nightly feed only, and, in CI integration builds only, the test keys baked in at BUILD time via
* the `__MOSS_UPDATE_TEST_KEYS__` define (release builds define it as []). Nothing here reads
* the runtime environment: a key from the environment would let anyone sign a feed.
*/
export function trustedUpdateKeys(): UpdatePublicKey[] {
export function trustedUpdateKeys(
channel: UpdateChannel,
keys: { production: readonly UpdatePublicKey[]; nightly: readonly UpdatePublicKey[] } = {
production: PRODUCTION_UPDATE_KEYS,
nightly: NIGHTLY_UPDATE_KEYS,
},
): UpdatePublicKey[] {
const test = typeof __MOSS_UPDATE_TEST_KEYS__ === 'undefined' ? [] : parseUpdateKeys(__MOSS_UPDATE_TEST_KEYS__)
return [...PRODUCTION_UPDATE_KEYS, ...test]
return [...keys.production, ...(channel === 'nightly' ? keys.nightly : []), ...test]
}

/** True when some channel can be verified at all (otherwise updating is unsupported). */
export function hasAnyUpdateKey(keys: (channel: UpdateChannel) => readonly UpdatePublicKey[] = trustedUpdateKeys): boolean {
return UPDATE_CHANNELS.some((channel) => keys(channel).length > 0)
}

/** Feed URL for a channel: feedUrl() unless the build defined `__MOSS_UPDATE_FEED_URL__` (CI). */
Expand Down
Loading
Loading