feat(ci): add nightly desktop publish workflow (#14244)

* feat(ci): add nightly desktop publish workflow

Support manually dispatched nightly desktop builds from main, with auto-generated version tags and conflict-safe concurrency.

* fix(desktop): keep nightly builds as Actions artifacts
This commit is contained in:
Bee
2026-09-17 17:39:14 -07:00
committed by GitHub
parent 999a2b8e7b
commit 706ceac669
3 changed files with 119 additions and 27 deletions
+93 -26
View File
@@ -1,11 +1,14 @@
name: desktop-publish
# Like ext-vscode-publish-nightly, nightlies are manually dispatched because
# PublishDesktop requires approval. Dispatch from main with channel=nightly.
# Nightlies upload Actions artifacts only; they do not publish releases or feeds.
on:
workflow_dispatch:
inputs:
git_tag:
description: "Existing release tag to publish, for example desktop-v0.1.0"
required: true
description: "Existing stable/beta tag; leave empty for nightly"
required: false
type: string
confirm_publish:
description: 'Type "publish" to confirm the desktop release.'
@@ -18,8 +21,13 @@ on:
options:
- stable
- beta
- nightly
default: stable
concurrency:
group: desktop-publish-${{ inputs.channel }}
cancel-in-progress: false
permissions:
contents: read
@@ -32,10 +40,12 @@ jobs:
name: Validate release tag
if: |
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
outputs:
ref: ${{ steps.version.outputs.ref }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
channel: ${{ steps.version.outputs.channel }}
@@ -84,7 +94,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
ref: ${{ inputs.channel == 'nightly' && github.sha || inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
@@ -103,6 +113,20 @@ jobs:
# manifest landing on desktop-latest would auto-update every stable
# install onto the beta. The stable regex rejects prerelease
# suffixes for the same reason.
if [ "$CHANNEL" = "nightly" ]; then
if [ -n "$TAG" ]; then
echo "nightly builds do not use a release tag; leave git_tag empty"
exit 1
fi
BASE=$(node -p "require('./apps/examples/desktop-app/package.json').version.split('-')[0]")
VERSION="${BASE}-nightly.$(date -u +%Y%m%d%H%M%S).${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}"
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "channel=nightly" >> "$GITHUB_OUTPUT"
echo "product=Cline Nightly" >> "$GITHUB_OUTPUT"
exit 0
fi
case "$CHANNEL" in
stable)
if ! printf "%s\n" "$TAG" | grep -Eq '^desktop-v[0-9]+\.[0-9]+\.[0-9]+$'; then
@@ -155,6 +179,7 @@ jobs:
exit 1
fi
echo "ref=${HEAD_COMMIT}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT"
@@ -237,7 +262,22 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
ref: ${{ needs.validate.outputs.ref }}
- name: Stamp nightly version
if: needs.validate.outputs.channel == 'nightly'
shell: bash
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
node <<'JS'
const fs = require('node:fs');
for (const file of ['apps/examples/desktop-app/package.json', 'apps/examples/desktop-app/src-tauri/tauri.conf.json']) {
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.version = process.env.VERSION;
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
}
JS
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -292,7 +332,7 @@ jobs:
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --target universal-apple-darwin $CONFIG_ARGS
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry config for the sidecar binary. Tauri's beforeBuildCommand
# (`bun run build` -> build:sidecar:bin) compiles the sidecar during
# this step and inlines these values into the binary via `--define`
@@ -369,11 +409,15 @@ jobs:
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-(beta|nightly)/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-(latest|nightly)/latest.json"
;;
nightly)
WANT=""
FORBID="releases/download/desktop-(latest|beta|nightly)/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
@@ -386,22 +430,22 @@ jobs:
# pipefail.
found=0
for bin in "$APP/Contents/MacOS/"*; do
if strings -a "$bin" | grep "$FORBID" >/dev/null; then
if strings -a "$bin" | grep -E "$FORBID" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if strings -a "$bin" | grep "$WANT" >/dev/null; then
if [ -n "$WANT" ] && strings -a "$bin" | grep "$WANT" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
if [ -n "$WANT" ] && [ "$found" -ne 1 ]; then
echo "No binary in ${APP}/Contents/MacOS embeds ${WANT}."
echo "The updater endpoint overlay did not apply; check the"
echo "--config flags on the build step and tauri.beta.conf.json."
exit 1
fi
echo "Updater endpoint verified: ${WANT}"
echo "Updater endpoint verified: ${WANT:-disabled}"
# Guardrail: assert the telemetry config actually made it into the
# compiled sidecar. Missing env on the build step (or a regression in
@@ -465,6 +509,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: desktop-universal
retention-days: 30
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
@@ -528,7 +573,22 @@ jobs:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ needs.validate.outputs.tag }}
ref: ${{ needs.validate.outputs.ref }}
- name: Stamp nightly version
if: needs.validate.outputs.channel == 'nightly'
shell: bash
env:
VERSION: ${{ needs.validate.outputs.version }}
run: |
node <<'JS'
const fs = require('node:fs');
for (const file of ['apps/examples/desktop-app/package.json', 'apps/examples/desktop-app/src-tauri/tauri.conf.json']) {
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.version = process.env.VERSION;
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
}
JS
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
@@ -631,7 +691,7 @@ jobs:
# deliberately unquoted: it must word-split into separate flags.
run: bunx tauri build --bundles nsis $CONFIG_ARGS --config "$SIGN_CONF"
env:
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
CONFIG_ARGS: ${{ needs.validate.outputs.channel == 'nightly' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.nightly.conf.json' || needs.validate.outputs.channel == 'beta' && '--config src-tauri/tauri.release.conf.json --config src-tauri/tauri.beta.conf.json' || '--config src-tauri/tauri.release.conf.json' }}
# Telemetry inlined into the sidecar at compile time, same as macOS.
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -663,11 +723,15 @@ jobs:
case "$CHANNEL" in
stable)
WANT="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-(beta|nightly)/latest.json"
;;
beta)
WANT="releases/download/desktop-beta/latest.json"
FORBID="releases/download/desktop-latest/latest.json"
FORBID="releases/download/desktop-(latest|nightly)/latest.json"
;;
nightly)
WANT=""
FORBID="releases/download/desktop-(latest|beta|nightly)/latest.json"
;;
*)
echo "unknown channel: ${CHANNEL}"
@@ -677,22 +741,22 @@ jobs:
found=0
for bin in src-tauri/target/release/*.exe; do
if grep -a "$FORBID" "$bin" >/dev/null; then
if grep -aE "$FORBID" "$bin" >/dev/null; then
echo "$bin embeds the other channel's feed URL (${FORBID})"
exit 1
fi
if grep -a "$WANT" "$bin" >/dev/null; then
if [ -n "$WANT" ] && grep -a "$WANT" "$bin" >/dev/null; then
found=1
fi
done
if [ "$found" -ne 1 ]; then
if [ -n "$WANT" ] && [ "$found" -ne 1 ]; then
echo "No exe in src-tauri/target/release embeds ${WANT}."
echo "The updater endpoint overlay did not apply; check the"
echo "--config flags on the build step and tauri.beta.conf.json."
exit 1
fi
echo "Updater endpoint verified: ${WANT}"
echo "Updater endpoint verified: ${WANT:-disabled}"
# Same guardrail as the macOS job, run natively on the Windows sidecar.
- name: Verify sidecar telemetry config was inlined
@@ -769,12 +833,14 @@ jobs:
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-windows-x64
retention-days: 30
path: apps/examples/desktop-app/dist/publish/*
if-no-files-found: error
release:
name: Create GitHub release
needs: [validate, build, build-windows]
if: needs.validate.outputs.channel != 'nightly'
runs-on: ubuntu-latest
permissions:
contents: write
@@ -782,7 +848,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ needs.validate.outputs.tag }}
ref: ${{ needs.validate.outputs.ref }}
fetch-depth: 0
fetch-tags: true
@@ -860,15 +926,15 @@ jobs:
- name: Get Previous Desktop Tag
id: prev_tag
env:
CURRENT_TAG: ${{ needs.validate.outputs.tag }}
SOURCE_REF: ${{ needs.validate.outputs.ref }}
CHANNEL: ${{ needs.validate.outputs.channel }}
run: |
# Stable compare links skip beta tags so they read stable -> stable;
# beta compares against whatever shipped last on either channel.
if [ "$CHANNEL" = "stable" ]; then
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-beta*' "$CURRENT_TAG^" 2>/dev/null || echo "")
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' --exclude 'desktop-v*-*' "$SOURCE_REF^" 2>/dev/null || echo "")
else
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
PREV_TAG=$(git describe --tags --abbrev=0 --match 'desktop-v*' "$SOURCE_REF^" 2>/dev/null || echo "")
fi
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
@@ -876,6 +942,7 @@ jobs:
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.validate.outputs.tag }}
target_commitish: ${{ needs.validate.outputs.ref }}
name: "Desktop v${{ needs.validate.outputs.version }}"
# The repo-wide "latest" release stays owned by CLI releases; the
# desktop auto-update feed is the rolling desktop-latest release.
@@ -915,10 +982,10 @@ jobs:
fi
if ! gh release view "$FEED" >/dev/null 2>&1; then
if [ "$CHANNEL" = "beta" ]; then
if [ "$CHANNEL" != "stable" ]; then
gh release create "$FEED" \
--title "Cline desktop beta (auto-update feed)" \
--notes "Rolling release backing the beta desktop app auto-updater. The latest.json asset points at the newest desktop-vX.Y.Z-beta.N release. Only beta installs poll this feed; stable installs use desktop-latest. Do not delete." \
--title "Cline desktop ${CHANNEL} (auto-update feed)" \
--notes "Rolling release backing the ${CHANNEL} desktop app auto-updater. Only ${CHANNEL} installs poll this feed. Do not delete." \
--latest=false \
--prerelease \
--target "$(git rev-parse HEAD)"
@@ -205,7 +205,22 @@ fn set_update_status(
refresh_tray_status(app, update_state);
}
// An empty endpoint list disables both background and on-demand updates.
// Nightly builds use this configuration because they are Actions artifacts only.
fn updates_enabled(app: &tauri::AppHandle) -> bool {
app.config()
.plugins
.0
.get("updater")
.and_then(|config| config.get("endpoints"))
.and_then(serde_json::Value::as_array)
.is_some_and(|endpoints| !endpoints.is_empty())
}
async fn check_and_install_update(app: &tauri::AppHandle, state: &UpdateState) {
if !updates_enabled(app) {
return;
}
let _cycle = state.cycle.lock().await;
// An update that already finished downloading only needs a restart; keep
// reporting "ready" instead of flipping back to transient states unless a
@@ -1257,7 +1272,7 @@ fn main() {
});
// Dev builds are not installed app bundles, so there is nothing the
// updater could meaningfully check or replace.
if !cfg!(debug_assertions) {
if !cfg!(debug_assertions) && updates_enabled(app.handle()) {
let update_state = app.state::<Arc<UpdateState>>().inner().clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
@@ -0,0 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline Nightly",
"identifier": "bot.cline.app.nightly",
"plugins": {
"updater": {
"endpoints": []
}
}
}