Files
tty7/.github/workflows/nightly.yml
T
l0ng-ai 0d0969ac56 fix(test): stop closing_the_pool_drops_queued_work racing, and guard i18n placeholders
`closing_the_pool_drops_queued_work` never tested a queued job. `submit`
grows the pool whenever `jobs.len() > idle`, so blocking one worker and
submitting a second job spawns a second worker and runs it immediately —
the assertion only held when `close()` beat a brand-new thread to the state
lock. On a loaded machine it lost that race about one run in six.

A job only stays queued when the pool is saturated, so the test now fills it
to MAX_WORKERS first, asserts the job is on the queue before closing, and
waits for the workers to drain afterwards. 14 consecutive `-p tty7-core --lib`
runs green, against 2 failures in 16 before.

Placeholders in a translation were checked by nobody: `apply_template`
substitutes by name and leaves anything it was not given alone, so a `{name}`
dropped from the zh string reaches the user as a sentence that has simply
stopped naming the host, and an invented one draws braces on screen. Neither
is a missing or empty translation, which is all the parity test could see.
Both are now checked, for the plural and select branches too. Nothing was
wrong today.

The `KEPT_IN_ENGLISH` staleness check printed rather than asserted, and cargo
swallows a passing test's stdout, so the list could only ever grow.

The Linux build dependencies existed in four copies across three workflows;
release and nightly had the AppImage packaging tools (libfuse2, file,
imagemagick) mixed into the same list, which reads as if they were needed to
build. One script now, with the packaging extras installed separately, and the
ci.yml comment points at the docs page that actually documents the list rather
than at the README, which never did.

2931 tests pass.
2026-08-15 18:11:17 +08:00

486 lines
19 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: |
bash .github/scripts/install-linux-deps.sh
# Packaging only, and nothing to do with building tty7: libfuse2 is
# what an AppImage needs to run at all, `file` and imagemagick are
# used while assembling one. Kept separate from the build list so
# neither set is read as the reason for the other.
sudo apt-get install -y libfuse2 file imagemagick
- 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
# Mirrors release.yml's server-macos job; keep the two in sync when editing.
# Nightly carries the macOS servers for the same reason it carries the Linux
# ones: so the remote-install path onto a Mac can be exercised against the
# rolling channel instead of waiting for a tag.
server-macos:
needs: plan
if: needs.plan.outputs.build == 'true'
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
arch: arm64
asset: tty7-server-macos-aarch64
- target: x86_64-apple-darwin
arch: x86_64
asset: tty7-server-macos-x86_64
runs-on: macos-latest
env:
RUSTFLAGS: -C strip=symbols
steps:
- name: Checkout tty7
uses: actions/checkout@v4
with:
path: tty7
# Stamped for the same reason the musl servers are: the version the server
# reports during the handshake has to be tonight's, not the last stable.
- 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: 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 macOS remote-server assets"
fi
# No `--locked`, matching the rest of nightly: the version stamp above
# rewrites Cargo.toml and cargo has to be free to refresh its lock entry.
- name: Build tty7-server
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: cargo build --release -p tty7-server --target ${{ matrix.target }}
# Ad-hoc, for the reason release.yml spells out: it is what arm64 requires
# before it will execute anything, the linker only applies it to the arm64
# slice, and x86_64 is what a Rosetta shell asks for.
- name: Ad-hoc sign the binary
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: codesign --force --sign - "target/${{ matrix.target }}/release/tty7-server"
- name: Assert the binary is self-contained
if: steps.probe.outputs.present == 'true'
working-directory: tty7
run: |
bash .github/scripts/assert-macho.sh \
"target/${{ matrix.target }}/release/tty7-server" "${{ matrix.arch }}"
- 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, server-macos]
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