Files
tty7/.github/workflows/nightly.yml
l0ng-ai f45dd34cab feat(update): separate Stable and Nightly release channels (#386)
The update channel becomes a property of the installation rather than
something derived from how version numbers happen to sort, so a Nightly
follows Nightly instead of being walked back onto Stable by an update it
never asked for. Stable reads /releases/latest, which excludes
prereleases; Nightly reads /releases/tags/nightly. Neither feed can hand
the other an update, so an installation only changes channel when the
user changes it in Settings.

The nightly release cannot state its version in its tag — `nightly` is
force-moved every night, so `tag_name` is the literal string. It now
publishes nightly.json beside the packages, falling back to parsing asset
filenames for builds that predate the manifest. Prereleases are ordered by
every numeric identifier in the stamp, and the stamp goes to the minute so
two builds in one day are distinguishable; a stable release still outranks
every dated build of its core version, which is how switching back to
Stable graduates instead of downgrading.

Switching channel invalidates what the old feed produced: the staged
package, the deferred prompt, and the transfer still in flight, which
would otherwise finish and stage a build from the channel the user just
left. Settings keeps one action on the update row rather than three —
the update dialog covers the rest, but it is a moment rather than a place,
and where the package cannot be installed for the user the release page is
the whole update path. Skipping a version is retired along with its state,
its Settings row, and its localization keys.

Also carries the staging work this was branched from: an update is fetched
and verified while the prompt is up, so installing it is a restart, and
declining one defers it instead of retiring it permanently.
2026-08-07 19:44:31 +08:00

393 lines
16 KiB
YAML

name: Nightly
# Unattended nightly channel: build `main` every night, stamp a pre-release
# CalVer version (<next-stable>-nightly.<YYYYMMDDHHMM>), and publish everything
# to a single rolling `nightly` prerelease. Stable releases (release.yml) are
# untouched.
#
# This is a real update channel, not just a build: an installation set to
# Nightly reads /releases/tags/nightly and rolls from one of these to the next.
# Stable reads /releases/latest, which excludes prereleases, so it never sees
# them. See `core::update::release_endpoint`.
on:
schedule:
- cron: "0 18 * * *" # 18:00 UTC = 02:00 Beijing
workflow_dispatch:
permissions:
contents: write
concurrency:
group: nightly
cancel-in-progress: false
jobs:
# Decide whether tonight needs a build, and compute the version once.
plan:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.plan.outputs.build }}
version: ${{ steps.plan.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history + tags — needed for the skip check and version math
- id: plan
run: |
# Skip when HEAD is already published: last night's nightly points at
# it (main hasn't moved) or a stable tag does (tonight would just
# rebuild the release under a nightly name).
if git tag --points-at HEAD | grep -qE '^(nightly$|v[0-9])'; then
echo "nothing new since the last published build — skipping"
echo "build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Nightly version = next stable patch + timestamp suffix, so semver
# ordering lands between the previous and the next stable release:
# 26.7.0 < 26.7.1-nightly.202607161800 < 26.7.1.
#
# To the minute, not to the day: two builds on one day is what
# workflow_dispatch is for, and a date alone gives them the same
# version, which the updater reads as "already up to date" and never
# offers to the people who took the morning build. Still one numeric
# identifier, which is what `parse_version` orders nightlies by — it
# compares them all, so this stays sound if a segment is ever added.
LAST=$(git tag -l 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
BASE=${LAST#v}
NEXT="${BASE%.*}.$(( ${BASE##*.} + 1 ))"
VERSION="${NEXT}-nightly.$(date -u +%Y%m%d%H%M)"
echo "building $VERSION from ${GITHUB_SHA::7}"
echo "build=true" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# Mirrors release.yml's build matrix; keep the two in sync when editing.
build:
# Also behind server-musl: the Windows installer embeds its Linux musl
# binary for WSL. See the same note in release.yml.
needs: [plan, server-musl]
if: needs.plan.outputs.build == 'true'
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
os: macos
arch: arm64
target: aarch64-apple-darwin
- runner: macos-15-intel
os: macos
arch: x86_64
target: x86_64-apple-darwin
- runner: windows-latest
os: windows
arch: x86_64
target: x86_64-pc-windows-msvc
- runner: ubuntu-latest
os: linux
arch: x86_64
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout tty7
uses: actions/checkout@v4
with:
path: tty7
# Everything versioned — the binary (CARGO_PKG_VERSION), asset names,
# DMG plist, Inno installer — reads Cargo.toml, so stamping it is the only
# edit needed. Shared with the server-musl job below, and it fails loudly
# if the manifest's shape ever moves the version line out from under it.
- name: Stamp nightly version
working-directory: tty7
shell: bash
run: bash .github/scripts/stamp-version.sh "${{ needs.plan.outputs.version }}"
- name: Install Linux system dependencies
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y pkg-config cmake clang libxkbcommon-dev \
libxkbcommon-x11-dev libfontconfig1-dev libfreetype6-dev \
libwayland-dev libx11-dev libxcb1-dev libzstd-dev libssl-dev \
libkrb5-dev libfuse2 file imagemagick
echo "LIBGSSAPI_IMPL=mit" >> "$GITHUB_ENV"
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: tty7
- name: Build
working-directory: tty7
shell: bash
run: |
cargo build --release --target "${{ matrix.target }}"
if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then
cargo build --release --features updater \
--bin tty7-updater --target "${{ matrix.target }}"
fi
- name: Bundle macOS DMG
if: matrix.os == 'macos'
working-directory: tty7
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: bash .github/scripts/bundle-macos.sh "${{ matrix.target }}" "${{ matrix.arch }}"
- name: Verify macOS Nightly update archive
if: matrix.os == 'macos'
working-directory: tty7
shell: bash
run: |
set -euo pipefail
VERSION="${{ needs.plan.outputs.version }}"
ZIP="dist/tty7-${VERSION}-macos-${{ matrix.arch }}.zip"
VERIFY_ROOT="$RUNNER_TEMP/tty7-nightly-update-verify"
rm -rf "$VERIFY_ROOT"
mkdir -p "$VERIFY_ROOT"
/usr/bin/ditto -x -k "$ZIP" "$VERIFY_ROOT"
APP="$VERIFY_ROOT/tty7.app"
test -x "$APP/Contents/MacOS/tty7-updater"
ACTUAL_VERSION="$(/usr/libexec/PlistBuddy \
-c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")"
test "$ACTUAL_VERSION" = "$VERSION"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP"
- name: Package Linux tarball
if: matrix.os == 'linux'
working-directory: tty7
run: bash .github/scripts/bundle-linux.sh "${{ matrix.target }}" "${{ matrix.arch }}"
- name: Package Linux AppImage
if: matrix.os == 'linux'
working-directory: tty7
run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}"
# See release.yml for why this is best-effort rather than required.
- name: Fetch the bundled Linux server
if: matrix.os == 'windows'
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: nightly-tty7-server-linux-x86_64-musl
path: tty7/bundled-server
- name: Package Windows installer + zip
if: matrix.os == 'windows'
working-directory: tty7
shell: pwsh
run: '& ./.github/scripts/bundle-windows.ps1 "${{ matrix.target }}" "${{ matrix.arch }}"'
# Nightly builds the same packages as release.yml, so it gets the same
# check. Nightly is not an update channel — nobody updates *into* these
# artifacts — but a marker or version regression shows up here a night
# before it would reach a stable release.
- name: Verify Windows update package
if: matrix.os == 'windows'
working-directory: tty7
shell: pwsh
run: >-
& ./.github/scripts/verify-windows-package.ps1
"${{ matrix.arch }}" "${{ needs.plan.outputs.version }}"
# Same glob list as release.yml's Release step: the bundle scripts leave
# intermediates in dist/ (tty7.app, entitlements.plist, the Windows
# staging dir) that must not reach the release assets.
- uses: actions/upload-artifact@v7
with:
name: nightly-${{ matrix.os }}-${{ matrix.arch }}
path: |
tty7/dist/*.dmg
tty7/dist/*.tar.gz
tty7/dist/*.zip
tty7/dist/*-setup.exe
tty7/dist/*.AppImage
if-no-files-found: error
# Mirrors release.yml's server-musl job; keep the two in sync when editing.
# Nightly carries the server binaries too so the remote-install path can
# be exercised against the rolling channel instead of waiting for a tag.
server-musl:
needs: plan
if: needs.plan.outputs.build == 'true'
strategy:
fail-fast: false
# `target` is the build triple; `asset` is the published filename. Kept
# apart for the reason release.yml spells out: the triple's vendor field is
# `unknown`, and a download name has no business carrying it.
matrix:
include:
- target: x86_64-unknown-linux-musl
asset: tty7-server-linux-x86_64-musl
- target: aarch64-unknown-linux-musl
asset: tty7-server-linux-aarch64-musl
runs-on: ubuntu-latest
env:
RUSTFLAGS: -C strip=symbols
steps:
- name: Checkout tty7
uses: actions/checkout@v4
with:
path: tty7
# Stamped for the same reason the GUI builds are: the server reports
# CARGO_PKG_VERSION over the wire during the version handshake, and a
# nightly server claiming the last stable version would make that
# negotiation lie. The asset *name* is version-free either way.
- name: Stamp nightly version
working-directory: tty7
run: bash .github/scripts/stamp-version.sh "${{ needs.plan.outputs.version }}"
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: mlugg/setup-zig@v2
with:
version: 0.16.0
- uses: taiki-e/install-action@v2
with:
tool: cargo-zigbuild
- uses: Swatinem/rust-cache@v2
with:
workspaces: tty7
key: ${{ matrix.target }}
- name: Look for the tty7-server package
id: probe
working-directory: tty7
run: |
set -euo pipefail
if cargo metadata --no-deps --format-version 1 \
| jq -e '[.packages[].name] | index("tty7-server")' >/dev/null; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "::warning::tty7-server is not a workspace member yet — tonight's nightly carries no remote-server assets"
fi
# No `--locked` here, matching the rest of nightly: the version stamp above
# rewrites Cargo.toml, and cargo has to be free to refresh the root
# package's own lock entry.
- name: Build static tty7-server
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: cargo zigbuild --release -p tty7-server --target ${{ matrix.target }}
- name: Assert the binary is static
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: bash .github/scripts/assert-static.sh "target/${{ matrix.target }}/release/tty7-server"
- name: Stage the asset
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: |
set -euo pipefail
mkdir -p dist
cp "target/${{ matrix.target }}/release/tty7-server" \
"dist/${{ matrix.asset }}"
chmod +x "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v7
if: steps.probe.outputs.present == 'true'
with:
name: nightly-${{ matrix.asset }}
path: tty7/dist/${{ matrix.asset }}
if-no-files-found: error
# Single publish step after all platforms succeed, so the rolling release is
# always complete — a failed platform means tonight's nightly is skipped
# entirely and users keep yesterday's, never a partial asset set.
publish:
needs: [plan, build, server-musl]
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.plan.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
# The version has to be readable by a machine, and the tag cannot carry
# it: `nightly` is force-moved to a new commit every night, so the release
# object's `tag_name` is the literal string "nightly". Without this the
# updater is left reverse-engineering the version out of asset filenames.
#
# Written before checksums.txt so the manifest is hashed along with
# everything else. The updater reads it straight from the release without
# checking that hash — it only decides which version is on offer, and the
# package it then selects is verified the usual way — but the entry has to
# be there for anyone auditing the release by hand.
- name: Generate nightly.json
run: |
set -euo pipefail
jq -n \
--arg version "$VERSION" \
--arg commit "$GITHUB_SHA" \
--arg published_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{version: $version, commit: $commit, published_at: $published_at}' \
> dist/nightly.json
cat dist/nightly.json
# Same contract as release.yml — see `install::asset`. Written
# into dist/ before the upload below so it ships as an asset like any
# other, and so the prune step at the end sees it as current.
- name: Generate checksums.txt
run: |
set -euo pipefail
cd dist
rm -f checksums.txt
# Built in $RUNNER_TEMP and moved in: a redirect straight into dist/
# creates the file before find walks the directory, so it would hash
# itself as a zero-byte entry. `xargs -r` so an empty dist/ fails
# instead of hanging on stdin.
find . -type f -printf '%P\n' \
| LC_ALL=C sort | xargs -r sha256sum > "$RUNNER_TEMP/checksums.txt"
[ -s "$RUNNER_TEMP/checksums.txt" ] || { echo "::error::no assets to checksum"; exit 1; }
mv "$RUNNER_TEMP/checksums.txt" checksums.txt
sha256sum -c checksums.txt
cat checksums.txt
- name: Update rolling nightly release
run: |
# Move the tag first so the release object follows it to this SHA.
git push -f origin "$GITHUB_SHA:refs/tags/nightly"
TITLE="Nightly $VERSION"
NOTES="Automated nightly build of \`main\` @ ${GITHUB_SHA::7} ($(date -u +%F)). Rolling prerelease — assets are replaced every night; for the latest stable release see https://github.com/${GITHUB_REPOSITORY}/releases/latest."
if gh release view nightly >/dev/null 2>&1; then
gh release edit nightly --prerelease --title "$TITLE" --notes "$NOTES"
else
gh release create nightly --prerelease --title "$TITLE" --notes "$NOTES"
fi
# Upload before deleting: date-suffixed names never collide across
# nights, so both sets briefly coexist — if an upload dies midway,
# yesterday's complete nightly is still intact.
gh release upload nightly dist/* --clobber
# Now prune the previous night's assets (anything we didn't upload).
gh release view nightly --json assets -q '.assets[].name' |
while read -r name; do
[ -e "dist/$name" ] || gh release delete-asset nightly "$name" -y
done