diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 63baaa8bf1..5a73d17efc 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -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)" diff --git a/apps/examples/desktop-app/src-tauri/src/main.rs b/apps/examples/desktop-app/src-tauri/src/main.rs index 920253209e..f5b9abd4a8 100644 --- a/apps/examples/desktop-app/src-tauri/src/main.rs +++ b/apps/examples/desktop-app/src-tauri/src/main.rs @@ -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::>().inner().clone(); let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { diff --git a/apps/examples/desktop-app/src-tauri/tauri.nightly.conf.json b/apps/examples/desktop-app/src-tauri/tauri.nightly.conf.json new file mode 100644 index 0000000000..f2f064334c --- /dev/null +++ b/apps/examples/desktop-app/src-tauri/tauri.nightly.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Cline Nightly", + "identifier": "bot.cline.app.nightly", + "plugins": { + "updater": { + "endpoints": [] + } + } +}