diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 145b570..62ad034 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,6 +273,7 @@ jobs: ( cd dist && sha256sum anyr-* > checksums.txt ) ls -la dist ls -la dist/checksums.txt + test -s dist/checksums.txt || { echo "checksums.txt missing or empty"; exit 1; } test -n "$(ls -A dist)" || { echo "no binaries in artifacts"; exit 1; } SHORT="$(echo "${GITHUB_SHA}" | cut -c1-7)" printf 'Beta from main (%s).\n\ncurl -fsSL https://anyrouter.dev/setup.sh | bash -s -- --channel beta\nchannel: beta\n' "$SHORT" > notes.md diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 76bda80..e57de14 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -13,6 +13,10 @@ on: permissions: contents: write +# Upload retry is inlined in each gh release upload step so +# workflow_dispatch of an older tag still uploads. The tag checkout used +# to compile binaries may not contain helper scripts added later. + jobs: build: # release: only non-prerelease; workflow_dispatch: always (tag input) @@ -42,7 +46,9 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.sha }} + # Compile the tagged source. Scripts used only after this step + # (bench.py) exist on historical tags; upload retry is inlined. + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name || github.sha }} - uses: dtolnay/rust-toolchain@stable with: @@ -63,7 +69,9 @@ jobs: bin="target/${{ matrix.target }}/release/anyr.exe" fi strip "$bin" 2>/dev/null || true + test -s "$bin" || { echo "missing built binary $bin" >&2; exit 1; } cp "$bin" "${{ matrix.asset }}" + test -s "${{ matrix.asset }}" || { echo "empty asset ${{ matrix.asset }}" >&2; exit 1; } - name: bench shell: bash @@ -77,10 +85,29 @@ jobs: --out "bench/${{ matrix.asset }}.json" - name: upload ${{ matrix.asset }} + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} - run: gh release upload "${RELEASE_TAG}" "${{ matrix.asset }}" --clobber + run: | + set -euo pipefail + n=1 + while true; do + if out=$(gh release upload "${RELEASE_TAG}" "${{ matrix.asset }}" --clobber 2>&1); then + printf '%s\n' "$out" + break + fi + printf '%s\n' "$out" >&2 + if ! printf '%s\n' "$out" | grep -qi 'release not found'; then + exit 1 + fi + if [ "$n" -ge 3 ]; then + echo "gh release upload failed: release not found after 3 attempts (${RELEASE_TAG})" >&2 + exit 1 + fi + sleep $((n * 5)) + n=$((n + 1)) + done - uses: actions/upload-artifact@v4 with: @@ -93,7 +120,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.sha }} + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name || github.sha }} - uses: dtolnay/rust-toolchain@stable with: @@ -122,36 +149,47 @@ jobs: --out bench/anyr.wasm.json - name: upload wasm assets + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} run: | - gh release upload "${RELEASE_TAG}" \ - anyr.wasm --clobber + set -euo pipefail + n=1 + while true; do + if out=$(gh release upload "${RELEASE_TAG}" anyr.wasm --clobber 2>&1); then + printf '%s\n' "$out" + break + fi + printf '%s\n' "$out" >&2 + if ! printf '%s\n' "$out" | grep -qi 'release not found'; then + exit 1 + fi + if [ "$n" -ge 3 ]; then + echo "gh release upload failed: release not found after 3 attempts (${RELEASE_TAG})" >&2 + exit 1 + fi + sleep $((n * 5)) + n=$((n + 1)) + done - uses: actions/upload-artifact@v4 with: name: bench-anyr.wasm path: bench/anyr.wasm.json - notes: - if: ${{ github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false }} + # Checksums must ship even when one platform (often Windows upload) fails. + checksums: + if: ${{ always() && !cancelled() && (github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false) }} needs: [build, wasm] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.sha }} - - - uses: actions/download-artifact@v4 - with: - path: artifacts - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" + # Workflow revision, not the tag: historical tags lack newer scripts. + ref: ${{ github.sha }} - - name: generate checksums + - name: generate and upload checksums.txt shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -159,22 +197,84 @@ jobs: run: | set -euo pipefail mkdir -p dist - gh release download "${RELEASE_TAG}" --dir dist --pattern 'anyr-*' + gh release download "${RELEASE_TAG}" --dir dist --pattern 'anyr-*' || true + count="$(find dist -maxdepth 1 -type f -name 'anyr-*' | wc -l | tr -d ' ')" + echo "binaries on ${RELEASE_TAG}: ${count}" + ls -la dist || true + if [ "${count}" = "0" ]; then + echo "hard gate: no anyr-* binaries on ${RELEASE_TAG}; cannot write checksums.txt" >&2 + exit 1 + fi ( cd dist && sha256sum anyr-* > checksums.txt ) - ls -la dist/checksums.txt + test -s dist/checksums.txt + cat dist/checksums.txt + n=1 + while true; do + if out=$(gh release upload "${RELEASE_TAG}" dist/checksums.txt --clobber 2>&1); then + printf '%s\n' "$out" + break + fi + printf '%s\n' "$out" >&2 + if ! printf '%s\n' "$out" | grep -qi 'release not found'; then + exit 1 + fi + if [ "$n" -ge 3 ]; then + echo "gh release upload failed: release not found after 3 attempts (${RELEASE_TAG})" >&2 + exit 1 + fi + sleep $((n * 5)) + n=$((n + 1)) + done + + notes: + if: ${{ always() && !cancelled() && (github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false) }} + needs: [checksums] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - uses: actions/download-artifact@v4 + with: + path: artifacts + continue-on-error: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" - name: write changelog + bench to release + continue-on-error: true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} run: | python scripts/bench.py report artifacts \ --out bench-report.md --json-out bench-report.json \ - --title "Binary size and startup (${RELEASE_TAG})" + --title "Binary size and startup (${RELEASE_TAG})" || true python scripts/release_notes.py github \ --tag "${RELEASE_TAG}" \ --bench bench-report.md \ --out notes.md gh release edit "${RELEASE_TAG}" --notes-file notes.md - gh release upload "${RELEASE_TAG}" \ - bench-report.md bench-report.json dist/checksums.txt --clobber + if [ -f bench-report.md ]; then + n=1 + while true; do + if out=$(gh release upload "${RELEASE_TAG}" \ + bench-report.md bench-report.json --clobber 2>&1); then + printf '%s\n' "$out" + break + fi + printf '%s\n' "$out" >&2 + if ! printf '%s\n' "$out" | grep -qi 'release not found'; then + exit 1 + fi + if [ "$n" -ge 3 ]; then + echo "gh release upload failed: release not found after 3 attempts (${RELEASE_TAG})" >&2 + exit 1 + fi + sleep $((n * 5)) + n=$((n + 1)) + done + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 4920761..011a87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ GitHub Releases use this file as the release notes (full history through that ta * **cli:** keep Claude HUD floors on virtual presets end-to-end (ANTHROPIC_MODEL, extra-body min_context, compact window, no catalog remap) * **cli:** bare `anyr update` keeps the config channel; only `--beta`/`--stable` persist a switch * **cli:** skip a broken latest GitHub release (checksum / missing asset) and install the next good build on that channel +* **cli:** print update warnings on a new line so they are not glued to the spinner +* **ci:** upload checksums.txt even when one platform build fails; retry Windows release-not-found uploads ## [0.1.14](https://github.com/anyrouter-dev/cli/compare/v0.1.13...v0.1.14) (2026-09-15) diff --git a/src/spinner.rs b/src/spinner.rs index 7b79d8a..77b1e1f 100644 --- a/src/spinner.rs +++ b/src/spinner.rs @@ -1,12 +1,17 @@ //! In-place CLI spinner. Frames actually advance on a timer so a TTY never //! shows a frozen loading glyph. Non-TTY prints a static status line instead. +use std::cell::RefCell; use std::io::{self, IsTerminal, Write}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; +thread_local! { + static ACTIVE_PAUSE: RefCell>> = RefCell::new(None); +} + use crate::term::{self, BLUE, SUCCESS}; /// Braille spinner frames. Consecutive indices are visually distinct. @@ -73,6 +78,7 @@ fn paint_ok_mark() -> String { /// Live spinner on a TTY; a single status line otherwise. pub struct Spinner { stop: Arc, + pause: Arc, ticks: Arc, handle: Option>, out: Arc>>, @@ -103,12 +109,14 @@ impl Spinner { let message = message.into(); let out: Arc>> = Arc::new(Mutex::new(Box::new(writer))); let stop = Arc::new(AtomicBool::new(false)); + let pause = Arc::new(AtomicBool::new(false)); let ticks = Arc::new(AtomicUsize::new(0)); let handle = if tty { #[cfg(not(target_arch = "wasm32"))] { let out_t = Arc::clone(&out); let stop_t = Arc::clone(&stop); + let pause_t = Arc::clone(&pause); let ticks_t = Arc::clone(&ticks); let msg = message.clone(); let interval = if interval.is_zero() { @@ -117,7 +125,7 @@ impl Spinner { interval }; Some(thread::spawn(move || { - tick_loop(out_t, stop_t, ticks_t, msg, interval); + tick_loop(out_t, stop_t, pause_t, ticks_t, msg, interval); })) } #[cfg(target_arch = "wasm32")] @@ -135,8 +143,9 @@ impl Spinner { lock_write(&out, format!("{message}\n").as_bytes()); None }; - Self { + let spinner = Self { stop, + pause: Arc::clone(&pause), ticks, handle, out, @@ -144,7 +153,17 @@ impl Spinner { interval, min_ticks, finished: false, - } + }; + ACTIVE_PAUSE.with(|slot| { + *slot.borrow_mut() = Some(Arc::clone(&spinner.pause)); + }); + spinner + } + + /// Pause the live glyph, finish the current line, then print `msg`. + /// Stops `(stable channel)warning:` glue when stderr shares the TTY. + pub fn warn(&self, msg: &str) { + warn_beside_spinner_on(&self.out, self.tty, &self.pause, self.interval, msg); } pub fn tick_count(&self) -> usize { @@ -205,6 +224,9 @@ impl Spinner { impl Drop for Spinner { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); + ACTIVE_PAUSE.with(|slot| { + *slot.borrow_mut() = None; + }); if let Some(handle) = self.handle.take() { let _ = handle.join(); } @@ -214,23 +236,69 @@ impl Drop for Spinner { } } +/// Print an install warning on its own line. Pauses a live spinner first so +/// the glyph line is not glued to `warning:`. +pub fn warn_beside_spinner(msg: &str) { + let pause = ACTIVE_PAUSE.with(|slot| slot.borrow().clone()); + let tty = io::stdout().is_terminal(); + if let Some(pause) = pause.as_ref() { + pause.store(true, Ordering::SeqCst); + thread::sleep(Duration::from_millis(40)); + } + if tty { + let mut out = io::stdout(); + let _ = out.write_all(format!("\r\x1b[K\n{msg}\n\n").as_bytes()); + let _ = out.flush(); + } else { + eprintln!("{msg}"); + } + if let Some(pause) = pause.as_ref() { + pause.store(false, Ordering::SeqCst); + } +} + +fn warn_beside_spinner_on( + out: &Mutex>, + tty: bool, + pause: &AtomicBool, + interval: Duration, + msg: &str, +) { + pause.store(true, Ordering::SeqCst); + thread::sleep(interval.max(Duration::from_millis(15))); + if tty { + lock_write(out, format!("\r\x1b[K\n{msg}\n\n").as_bytes()); + } else { + lock_write(out, format!("{msg}\n").as_bytes()); + } + pause.store(false, Ordering::SeqCst); +} + #[cfg(not(target_arch = "wasm32"))] fn tick_loop( out: Arc>>, stop: Arc, + pause: Arc, ticks: Arc, message: String, interval: Duration, ) { let mut index = 0usize; while !stop.load(Ordering::Relaxed) { + if pause.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(5).min(interval)); + continue; + } let glyph = paint_glyph(frame(index)); let line = format!("\r{glyph} {message}\x1b[K"); lock_write(&out, line.as_bytes()); ticks.fetch_add(1, Ordering::Relaxed); index = index.wrapping_add(1); let until = Instant::now() + interval; - while Instant::now() < until && !stop.load(Ordering::Relaxed) { + while Instant::now() < until + && !stop.load(Ordering::Relaxed) + && !pause.load(Ordering::Relaxed) + { thread::sleep(Duration::from_millis(5).min(interval)); } } @@ -318,4 +386,32 @@ mod tests { assert!(text.contains(RESTART_RESUME_HINT), "{text}"); assert!(text.contains("Ctrl+G"), "{text}"); } + + #[test] + fn warn_starts_on_new_line_not_glued_to_channel() { + let buf = Arc::new(Mutex::new(Vec::new())); + let spinner = Spinner::start_on( + SharedBuf(Arc::clone(&buf)), + true, + "Updating v0.1.14 -> v0.1.15 (stable channel)", + Duration::from_millis(15), + 2, + ); + spinner.warn("warning: release has no checksums.txt — skipping verification"); + spinner.succeed("Updated to v0.1.15"); + let text = String::from_utf8_lossy(&buf.lock().unwrap()).into_owned(); + assert!( + !text.contains("(stable channel)warning:"), + "warning must not glue to the spinner line:\n{text:?}" + ); + let n = text + .matches("warning: release has no checksums.txt") + .count(); + assert_eq!(n, 1, "warning must print once, got {n} in:\n{text:?}"); + let stripped = text.replace("\x1b[K", ""); + assert!( + stripped.contains("\nwarning:"), + "expected a newline before warning, got {text:?}" + ); + } } diff --git a/src/upgrade.rs b/src/upgrade.rs index c301a08..b786c95 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -461,7 +461,9 @@ fn verify_downloaded_asset(url: &str, tmp: &Path) -> Result<(), String> { let checksums = checksums_url(url); match fetch_checksums_body(&checksums)? { None => { - eprintln!("warning: release has no checksums.txt — skipping verification"); + crate::spinner::warn_beside_spinner( + "warning: release has no checksums.txt — skipping verification", + ); Ok(()) } Some(body) => { @@ -645,9 +647,13 @@ fn run_auto(parsed: &ParsedArgs, env: &BTreeMap) -> Result {}", installable[0].version_str()); return Ok(0); } - match try_releases(&installable, os, arch, replace_current_binary, |msg| { - eprintln!("{msg}") - }) { + match try_releases( + &installable, + os, + arch, + replace_current_binary, + crate::spinner::warn_beside_spinner, + ) { Ok((rel, _)) => { let ver = rel.version_str().to_string(); write_notice(env, &ver); @@ -835,19 +841,23 @@ pub fn run(parsed: &ParsedArgs, env: &BTreeMap) -> Result { - spinner.succeed(&updated_line(rel.version_str())); if rel.tag_name != installable[0].tag_name { - eprintln!( + spinner.warn(&format!( "Installed {} after a newer {} {} release failed verification.", rel.tag_name, channel.as_str(), installable[0].tag_name - ); + )); } + spinner.succeed(&updated_line(rel.version_str())); Ok(0) } Err(err) => { diff --git a/tests/release_lock.rs b/tests/release_lock.rs index f285864..4f3f03b 100644 --- a/tests/release_lock.rs +++ b/tests/release_lock.rs @@ -174,6 +174,30 @@ fn workflow_files_exist_and_do_not_auto_merge() { binaries.contains("macos-13"), "stable releases still ship Intel macOS via macos-13" ); + assert!( + binaries.contains("checksums:"), + "release-binaries must have a dedicated checksums job" + ); + assert!( + binaries.contains("always()"), + "checksums must run even if a matrix platform fails" + ); + assert!( + binaries.contains("release not found"), + "uploads must retry release-not-found inline (historical tags lack helper scripts)" + ); + assert!( + !binaries.contains("bash scripts/"), + "workflow must not call helper scripts from a tag checkout" + ); + assert!( + binaries.contains("hard gate"), + "checksums job must fail when the release has zero binaries" + ); + assert!( + binaries.contains("ref: ${{ github.sha }}"), + "checksums/notes must check out the workflow SHA, not the historical tag" + ); assert!( binaries.contains("bench-report.md"), "release notes must include the bench report" @@ -200,6 +224,7 @@ fn workflow_files_exist_and_do_not_auto_merge() { "cargo llvm-cov --locked --all-targets", "codecov/codecov-action", "lcov.info", + "test -s dist/checksums.txt", ] { assert!(ci.contains(needle), "ci.yml must include {needle}"); }