diff --git a/.github/workflows/release-preparation.yml b/.github/workflows/release-preparation.yml index b8ba5dc..87bd878 100644 --- a/.github/workflows/release-preparation.yml +++ b/.github/workflows/release-preparation.yml @@ -95,14 +95,14 @@ jobs: run: | VERSION="${{ needs.release-meta.outputs.version }}" OUTPUT_DIR="artifacts/amd64" - VERSION="$VERSION" PLATFORM="linux" ARCH="amd64" OUTPUT_DIR="$OUTPUT_DIR" ./packaging/build_bundle.sh + VERSION="$VERSION" PLATFORM="linux" ARCH="x86_64" OUTPUT_DIR="$OUTPUT_DIR" ./packaging/build_bundle.sh ls -lah "$OUTPUT_DIR" - name: Smoke test bundle install run: | VERSION="${{ needs.release-meta.outputs.version }}" OUTPUT_DIR="artifacts/amd64" - BUNDLE="aish-${VERSION}-linux-amd64" + BUNDLE="aish-${VERSION}-linux-x86_64" EXTRACT_DIR="$PWD/build/install-smoke" INSTALL_ROOT="$PWD/build/install-root" @@ -116,28 +116,6 @@ jobs: test -x "$INSTALL_ROOT/usr/local/bin/aish-sandbox" test -f "$INSTALL_ROOT/etc/aish/security_policy.yaml" - - name: Validate CDN release layout - run: | - VERSION="${{ needs.release-meta.outputs.version }}" - OUTPUT_DIR="artifacts/amd64" - BUNDLE="aish-${VERSION}-linux-amd64.tar.gz" - SHA_FILE="${BUNDLE}.sha256" - RELEASE_LAYOUT="$PWD/build/release-layout/download" - - rm -rf "$RELEASE_LAYOUT" - mkdir -p "$RELEASE_LAYOUT/releases/${VERSION}" - cp "$OUTPUT_DIR/$BUNDLE" "$RELEASE_LAYOUT/releases/${VERSION}/" - cp "$OUTPUT_DIR/$SHA_FILE" "$RELEASE_LAYOUT/releases/${VERSION}/" - printf '%s' "$VERSION" > "$RELEASE_LAYOUT/latest" - - test -f "$RELEASE_LAYOUT/latest" - test -f "$RELEASE_LAYOUT/releases/${VERSION}/$BUNDLE" - test -f "$RELEASE_LAYOUT/releases/${VERSION}/$SHA_FILE" - ( - cd "$RELEASE_LAYOUT/releases/${VERSION}" - sha256sum -c "$SHA_FILE" - ) - - name: Upload dry-run artifacts uses: actions/upload-artifact@v6 with: @@ -162,5 +140,5 @@ jobs: - Tag: ${{ needs.release-meta.outputs.tag }} - Previous stable tag: ${{ needs.release-meta.outputs.previous_stable_tag || 'none' }} - Release metadata artifacts, CDN layout validation, and dry-run bundle validation have completed successfully. + Release metadata artifacts and dry-run bundle validation have completed successfully. EOF diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25eab08..3280357 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,14 +99,14 @@ jobs: run: | VERSION="${{ needs.release-meta.outputs.version }}" OUTPUT_DIR="artifacts/amd64" - VERSION="$VERSION" PLATFORM="linux" ARCH="amd64" OUTPUT_DIR="$OUTPUT_DIR" ./packaging/build_bundle.sh + VERSION="$VERSION" PLATFORM="linux" ARCH="x86_64" OUTPUT_DIR="$OUTPUT_DIR" ./packaging/build_bundle.sh ls -lah "$OUTPUT_DIR" - name: Smoke test bundle install run: | VERSION="${{ needs.release-meta.outputs.version }}" OUTPUT_DIR="artifacts/amd64" - BUNDLE="aish-${VERSION}-linux-amd64" + BUNDLE="aish-${VERSION}-linux-x86_64" EXTRACT_DIR="$PWD/build/install-smoke" INSTALL_ROOT="$PWD/build/install-root" @@ -126,58 +126,11 @@ jobs: tag_name: ${{ needs.release-meta.outputs.tag }} files: artifacts/amd64/* - - name: Upload release artifacts - uses: actions/upload-artifact@v6 - with: - name: release-bundle-linux-amd64 - path: artifacts/amd64/* - if-no-files-found: error - - publish-release-bundles: - name: Publish release bundles - needs: - - release-meta - - release-gate - - build-release-bundle - runs-on: ubuntu-latest - environment: release - env: - VERSION: ${{ needs.release-meta.outputs.version }} - ARTIFACT_ROOT: artifacts/release - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - AWS_DEFAULT_REGION: auto - CDN_BASE_URL: ${{ vars.CDN_BASE_URL }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Download release artifacts - uses: actions/download-artifact@v6 - with: - pattern: release-bundle-linux-* - path: ${{ env.ARTIFACT_ROOT }} - - - name: Ensure aws CLI is available - run: | - if ! command -v aws >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y awscli - fi - aws --version - - - name: Publish release bundles - run: bash ./packaging/scripts/publish_release.sh - release-summary: name: Summarize published release needs: - release-meta - - create-release - build-release-bundle - - publish-release-bundles runs-on: ubuntu-latest steps: - name: Write job summary @@ -188,5 +141,5 @@ jobs: - Version: ${{ needs.release-meta.outputs.version }} - Tag: ${{ needs.release-meta.outputs.tag }} - GitHub Release metadata was generated from the tag push, bundle assets were uploaded to both GitHub Release and the release bucket, and download/latest was updated successfully. + GitHub Release metadata was generated from the tag push, and bundle assets were uploaded successfully. EOF diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b3c840..24b0e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,17 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `aish update` command for self-updating to the latest stable version from CDN-hosted release metadata +- `aish update` command for self-updating to latest version from GitHub releases - `aish uninstall` command for uninstalling aish with optional `--purge` flag -- UpdateManager class for handling stable CDN update logic and GitHub pre-release discovery +- UpdateManager class for handling update logic with GitHub API integration - UninstallManager class for handling uninstall logic and data cleanup - i18n support for update and uninstall commands in Chinese and English - DejaGnu integration tests for update and uninstall commands -### Changed - -- Changed stable self-update and release publication to use the CDN layout `download/latest` plus `download/releases//...`, while preserving GitHub releases for pre-release discovery and release pages. - ## [0.2.0] - 2026-04-03 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a412165..c040e66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,8 +46,8 @@ Welcome to make Shell smarter! - `Release Metadata` is the shared release action that normalizes stable version inputs, validates repository version state, and uploads both markdown and JSON metadata artifacts. - `make prepare-release-files VERSION=X.Y.Z [DATE=YYYY-MM-DD]` updates `pyproject.toml`, `src/aish/__init__.py`, `uv.lock`, and inserts a dated release section at the top of `CHANGELOG.md`. - Prepare release files locally in a normal PR, merge that PR into `main`, then run `Release Preparation` as the single preflight validation for the target stable version. It now includes release metadata checks, bundle dry-run validation, and live smoke validation with real provider credentials. -- `Release Preparation` validates the target stable version, generates a release summary from the versioned changelog section, builds dry-run bundles, verifies the CDN layout (`download/latest` and `download/releases//...`), and runs install smoke checks before publication. -- `Release` is triggered by pushing a stable tag `vX.Y.Z`. It validates the tag against repository metadata, verifies that the tagged commit is on `main`, waits on the protected `release` environment approval gate, creates the GitHub Release entry with generated notes, uploads bundle assets, and publishes the same artifacts to the CDN release layout used by the installer and stable self-update. +- `Release Preparation` validates the target stable version, generates a release summary from the versioned changelog section, builds dry-run bundles, and runs install smoke checks before publication. +- `Release` is triggered by pushing a stable tag `vX.Y.Z`. It validates the tag against repository metadata, verifies that the tagged commit is on `main`, waits on the protected `release` environment approval gate, creates the GitHub Release entry with generated notes, and uploads bundle assets. - Configure the GitHub Environment named `release` with required reviewers if you want manual approval before production publishing. ## Python Code Style diff --git a/QUICKSTART.md b/QUICKSTART.md index 88f5195..1903866 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -8,15 +8,13 @@ curl -fsSL https://www.aishell.ai/repo/install.sh | bash ``` -The installer reads stable version metadata from `https://cdn.aishell.ai/download/latest` and downloads bundles from `https://cdn.aishell.ai/download/releases//`. Override behavior with `AISH_DOWNLOAD_BASE_URL`, `AISH_LATEST_URL`, or the legacy alias `AISH_REPO_URL` when needed. - ### Method 2: Manual Bundle Install -Download the published stable bundle `aish--linux-amd64.tar.gz` from the CDN release directory `https://cdn.aishell.ai/download/releases//` (use `amd64`, not `x86_64`), then run: +Download the matching `aish--linux-.tar.gz` bundle from the official release directory, then run: ```bash -tar -xzf aish--linux-amd64.tar.gz -cd aish--linux-amd64 +tar -xzf aish--linux-.tar.gz +cd aish--linux- sudo ./install.sh ``` diff --git a/README.md b/README.md index 441a75f..053fe63 100644 --- a/README.md +++ b/README.md @@ -81,15 +81,13 @@ Empower the Shell to think. Evolve Operations. curl -fsSL https://www.aishell.ai/repo/install.sh | bash ``` -The installer resolves stable versions from `https://cdn.aishell.ai/download/latest` and downloads bundles from `https://cdn.aishell.ai/download/releases//...`. You can override this with `AISH_DOWNLOAD_BASE_URL`, `AISH_LATEST_URL`, or the legacy alias `AISH_REPO_URL`. - #### Option 2: Manual bundle install -Download the published stable bundle `aish--linux-amd64.tar.gz` from the CDN release directory `https://cdn.aishell.ai/download/releases//` (use `amd64`, not `x86_64`), then run: +Download the matching `aish--linux-.tar.gz` bundle from the official release directory, then run: ```bash -tar -xzf aish--linux-amd64.tar.gz -cd aish--linux-amd64 +tar -xzf aish--linux-.tar.gz +cd aish--linux- sudo ./install.sh ``` @@ -128,7 +126,7 @@ aish> ;explain this command: tar -czf a.tgz ./dir curl -fsSL https://www.aishell.ai/repo/install.sh | bash ``` -The installer resolves the latest stable version from `https://cdn.aishell.ai/download/latest`, downloads the matching bundle from `https://cdn.aishell.ai/download/releases//`, and installs `aish`, `aish-sandbox`, and `aish-uninstall` into `/usr/local/bin`. +The installer resolves the latest release directory under `https://www.aishell.ai/repo`, downloads the matching bundle for your architecture, and installs `aish`, `aish-sandbox`, and `aish-uninstall` into `/usr/local/bin`. ### Run from Source (Development/Trial) diff --git a/crates/aish-cli/src/update.rs b/crates/aish-cli/src/update.rs index 58b5339..d75f3cf 100644 --- a/crates/aish-cli/src/update.rs +++ b/crates/aish-cli/src/update.rs @@ -1,7 +1,7 @@ -//! Self-update via CDN-hosted stable releases and GitHub pre-releases. +//! Self-update via GitHub releases. //! -//! Stable updates read the CDN latest metadata and download versioned release -//! bundles. Pre-release discovery continues to use the GitHub releases API. +//! Supports platform-aware download, progress display, mirror fallback, +//! archive extraction with install.sh execution, and automatic cleanup. use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -9,9 +9,10 @@ use std::path::{Path, PathBuf}; use aish_core::AishError; use aish_i18n::{t, t_with_args}; -const DEFAULT_DOWNLOAD_BASE_URL: &str = "https://cdn.aishell.ai/download"; +const GITHUB_API_LATEST: &str = "https://api.github.com/repos/AI-Shell-Team/aish/releases/latest"; const GITHUB_API_LIST: &str = "https://api.github.com/repos/AI-Shell-Team/aish/releases"; -const GITHUB_RELEASES_PAGE_BASE: &str = "https://github.com/AI-Shell-Team/aish/releases"; +const GITHUB_RELEASES_BASE: &str = "https://github.com/AI-Shell-Team/aish/releases/download"; +const FALLBACK_MIRROR: &str = "https://www.aishell.ai/repo"; const CONNECTION_TIMEOUT_SECS: u64 = 10; const DOWNLOAD_TIMEOUT_SECS: u64 = 300; @@ -58,6 +59,7 @@ fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { fn detect_platform() -> Result<(&'static str, &'static str), AishError> { let plat = match std::env::consts::OS { "linux" => "linux", + "macos" => "darwin", other => { return Err(AishError::Config({ let mut args = std::collections::HashMap::new(); @@ -68,6 +70,7 @@ fn detect_platform() -> Result<(&'static str, &'static str), AishError> { }; let arch = match std::env::consts::ARCH { "x86_64" => "amd64", + "aarch64" => "arm64", other => { return Err(AishError::Config({ let mut args = std::collections::HashMap::new(); @@ -97,93 +100,18 @@ fn build_http_client(timeout_secs: u64) -> Result Option { - std::env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn download_base_url() -> String { - env_var("AISH_DOWNLOAD_BASE_URL") - .or_else(|| env_var("AISH_REPO_URL")) - .unwrap_or_else(|| DEFAULT_DOWNLOAD_BASE_URL.to_string()) - .trim_end_matches('/') - .to_string() -} - -fn latest_version_url() -> String { - env_var("AISH_LATEST_URL").unwrap_or_else(|| format!("{}/latest", download_base_url())) -} - -fn release_download_url(tag_name: &str, filename: &str) -> String { - let version_str = tag_name.strip_prefix('v').unwrap_or(tag_name); - format!( - "{}/releases/{}/{}", - download_base_url(), - version_str, - filename - ) -} - -fn normalize_release_tag(version_value: &str) -> Result { - let trimmed = version_value.trim(); - if trimmed.is_empty() { - return Err(AishError::Config(t("cli.update.latest_metadata_invalid"))); - } - - let normalized = trimmed.trim_start_matches('v'); - let valid = normalized - .split('.') - .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())); - - if !valid || !normalized.chars().any(|ch| ch.is_ascii_digit()) { - return Err(AishError::Config({ - let mut args = std::collections::HashMap::new(); - args.insert("value".to_string(), trimmed.to_string()); - t_with_args("cli.update.latest_metadata_invalid_value", &args) - })); - } - - Ok(format!("v{}", normalized)) -} - -fn stable_release_info(client: &reqwest::blocking::Client) -> Result { - let resp = client.get(latest_version_url()).send().map_err(|e| { - AishError::Config({ - let mut args = std::collections::HashMap::new(); - args.insert("error".to_string(), e.to_string()); - t_with_args("cli.update.check_failed", &args) - }) - })?; - - if !resp.status().is_success() { - return Err(AishError::Config({ - let mut args = std::collections::HashMap::new(); - args.insert("status".to_string(), resp.status().to_string()); - t_with_args("cli.update.release_metadata_error", &args) - })); - } - - let tag_name = normalize_release_tag(&resp.text().map_err(|e| { - AishError::Config({ - let mut args = std::collections::HashMap::new(); - args.insert("error".to_string(), e.to_string()); - t_with_args("cli.update.parse_release_failed", &args) - }) - })?)?; - - Ok(serde_json::json!({ - "tag_name": tag_name, - "html_url": format!("{}/tag/{}", GITHUB_RELEASES_PAGE_BASE, tag_name), - "body": "", - })) -} +pub fn check_for_updates( + current_version: &str, + include_pre_release: bool, +) -> Result, AishError> { + let client = build_http_client(CONNECTION_TIMEOUT_SECS)?; + let url = if include_pre_release { + GITHUB_API_LIST + } else { + GITHUB_API_LATEST + }; -fn pre_release_info( - client: &reqwest::blocking::Client, -) -> Result, AishError> { - let resp = client.get(GITHUB_API_LIST).send().map_err(|e| { + let resp = client.get(url).send().map_err(|e| { AishError::Config({ let mut args = std::collections::HashMap::new(); args.insert("error".to_string(), e.to_string()); @@ -199,29 +127,26 @@ fn pre_release_info( })); } - let releases: Vec = resp.json().map_err(|e| { - AishError::Config({ - let mut args = std::collections::HashMap::new(); - args.insert("error".to_string(), e.to_string()); - t_with_args("cli.update.parse_releases_failed", &args) - }) - })?; - - Ok(releases.into_iter().next()) -} - -pub fn check_for_updates( - current_version: &str, - include_pre_release: bool, -) -> Result, AishError> { - let client = build_http_client(CONNECTION_TIMEOUT_SECS)?; let release = if include_pre_release { - match pre_release_info(&client)? { + let releases: Vec = resp.json().map_err(|e| { + AishError::Config({ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + t_with_args("cli.update.parse_releases_failed", &args) + }) + })?; + match releases.into_iter().next() { Some(r) => r, None => return Ok(None), } } else { - stable_release_info(&client)? + resp.json().map_err(|e| { + AishError::Config({ + let mut args = std::collections::HashMap::new(); + args.insert("error".to_string(), e.to_string()); + t_with_args("cli.update.parse_release_failed", &args) + }) + })? }; extract_update_info(&release, current_version) @@ -278,7 +203,7 @@ fn download_with_progress(url: &str, dest: &Path, label: &str) -> Result<(), Ais return Err(AishError::Config({ let mut args = std::collections::HashMap::new(); args.insert("status".to_string(), resp.status().to_string()); - t_with_args("cli.update.download_status_error", &args) + t_with_args("cli.update.github_api_error", &args) })); } @@ -380,41 +305,8 @@ fn sha256_file(path: &Path) -> Result { Ok(format!("{:x}", hasher.finalize())) } -fn expected_sha256(path: &Path) -> Result { - let contents = std::fs::read_to_string(path).map_err(|e| { - AishError::Config({ - let mut args = std::collections::HashMap::new(); - args.insert("error".to_string(), e.to_string()); - t_with_args("cli.update.read_error", &args) - }) - })?; - - contents - .split_whitespace() - .next() - .filter(|value| value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit())) - .map(|value| value.to_ascii_lowercase()) - .ok_or_else(|| AishError::Config(t("cli.update.checksum_file_invalid"))) -} - -fn verify_download_checksum(archive_path: &Path, checksum_path: &Path) -> Result<(), AishError> { - let expected = expected_sha256(checksum_path)?; - let actual = sha256_file(archive_path)?; - - if actual != expected { - return Err(AishError::Config({ - let mut args = std::collections::HashMap::new(); - args.insert("expected".to_string(), expected); - args.insert("actual".to_string(), actual); - t_with_args("cli.update.checksum_mismatch", &args) - })); - } - - Ok(()) -} - // --------------------------------------------------------------------------- -// Download release bundle from CDN +// Download release (GitHub → mirror fallback) // --------------------------------------------------------------------------- fn download_release(tag_name: &str) -> Result { @@ -432,15 +324,27 @@ fn download_release(tag_name: &str) -> Result { })?; let dest_path = temp_dir.join(&filename); - let checksum_filename = format!("{filename}.sha256"); - let checksum_path = temp_dir.join(&checksum_filename); - - let release_url = release_download_url(tag_name, &filename); - let checksum_url = release_download_url(tag_name, &checksum_filename); - println!("\x1b[1;36m{}\x1b[0m", t("cli.update.downloading_release")); - download_with_progress(&release_url, &dest_path, &filename)?; - download_with_progress(&checksum_url, &checksum_path, &checksum_filename)?; - verify_download_checksum(&dest_path, &checksum_path)?; + + // Try GitHub first + let github_url = format!("{}/{}/{}", GITHUB_RELEASES_BASE, tag_name, filename); + println!( + "\x1b[1;36m{}\x1b[0m", + t("cli.update.downloading_from_github") + ); + if download_with_progress(&github_url, &dest_path, &filename).is_ok() { + let path_str = dest_path.display().to_string(); + println!("\x1b[32m{}\x1b[0m", { + let mut args = std::collections::HashMap::new(); + args.insert("path".to_string(), path_str); + t_with_args("cli.update.downloaded", &args) + }); + return Ok(dest_path); + } + + // Fallback to mirror + println!("\x1b[33m{}\x1b[0m", t("cli.update.downloading_from_mirror")); + let mirror_url = format!("{}/{}/{}", FALLBACK_MIRROR, tag_name, filename); + download_with_progress(&mirror_url, &dest_path, &format!("{} (mirror)", filename))?; let path_str = dest_path.display().to_string(); println!("\x1b[32m{}\x1b[0m", { let mut args = std::collections::HashMap::new(); @@ -637,12 +541,6 @@ pub fn run_update(check_only: bool, pre_release: bool) { #[cfg(test)] mod tests { use super::*; - use std::sync::{Mutex, OnceLock}; - - fn env_test_lock() -> std::sync::MutexGuard<'static, ()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() - } #[test] fn test_compare_versions_equal() { @@ -686,10 +584,12 @@ mod tests { #[test] fn test_detect_platform() { - match (std::env::consts::OS, std::env::consts::ARCH) { - ("linux", "x86_64") => assert_eq!(detect_platform().unwrap(), ("linux", "amd64")), - _ => assert!(detect_platform().is_err()), - } + // Should succeed on any supported platform + let result = detect_platform(); + assert!(result.is_ok()); + let (plat, arch) = result.unwrap(); + assert!(plat == "linux" || plat == "darwin"); + assert!(arch == "amd64" || arch == "arm64"); } #[test] @@ -718,131 +618,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - #[test] - fn test_download_base_url_defaults_to_cdn() { - let _guard = env_test_lock(); - unsafe { - std::env::remove_var("AISH_DOWNLOAD_BASE_URL"); - std::env::remove_var("AISH_REPO_URL"); - } - assert_eq!(download_base_url(), "https://cdn.aishell.ai/download"); - } - - #[test] - fn test_download_base_url_prefers_explicit_override() { - let _guard = env_test_lock(); - unsafe { - std::env::set_var("AISH_REPO_URL", "https://legacy.example.com/download"); - std::env::set_var( - "AISH_DOWNLOAD_BASE_URL", - "https://cdn.example.com/download/", - ); - } - assert_eq!(download_base_url(), "https://cdn.example.com/download"); - unsafe { - std::env::remove_var("AISH_DOWNLOAD_BASE_URL"); - std::env::remove_var("AISH_REPO_URL"); - } - } - - #[test] - fn test_latest_version_url_uses_default_pattern() { - let _guard = env_test_lock(); - unsafe { - std::env::remove_var("AISH_DOWNLOAD_BASE_URL"); - std::env::remove_var("AISH_REPO_URL"); - std::env::remove_var("AISH_LATEST_URL"); - } - assert_eq!( - latest_version_url(), - "https://cdn.aishell.ai/download/latest" - ); - } - - #[test] - fn test_latest_version_url_prefers_override() { - let _guard = env_test_lock(); - unsafe { - std::env::set_var( - "AISH_LATEST_URL", - "https://cdn.example.com/custom/latest.txt", - ); - } - assert_eq!( - latest_version_url(), - "https://cdn.example.com/custom/latest.txt" - ); - unsafe { - std::env::remove_var("AISH_LATEST_URL"); - } - } - - #[test] - fn test_release_download_url_uses_versioned_cdn_path() { - let _guard = env_test_lock(); - unsafe { - std::env::set_var("AISH_DOWNLOAD_BASE_URL", "https://cdn.example.com/download"); - } - assert_eq!( - release_download_url("v0.3.0", "aish-0.3.0-linux-amd64.tar.gz"), - "https://cdn.example.com/download/releases/0.3.0/aish-0.3.0-linux-amd64.tar.gz" - ); - unsafe { - std::env::remove_var("AISH_DOWNLOAD_BASE_URL"); - } - } - - #[test] - fn test_normalize_release_tag_accepts_plain_version_text() { - assert_eq!(normalize_release_tag("0.3.0\n").unwrap(), "v0.3.0"); - assert_eq!(normalize_release_tag("v0.3.0").unwrap(), "v0.3.0"); - } - - #[test] - fn test_normalize_release_tag_rejects_invalid_metadata() { - assert!(normalize_release_tag("").is_err()); - assert!(normalize_release_tag("latest").is_err()); - assert!(normalize_release_tag("release-2026-04-23").is_err()); - assert!(normalize_release_tag("0-rc1").is_err()); - } - - #[test] - fn test_expected_sha256_accepts_sha256sum_output() { - let dir = std::env::temp_dir().join("aish_test_expected_sha256"); - std::fs::create_dir_all(&dir).unwrap(); - let checksum_path = dir.join("bundle.tar.gz.sha256"); - std::fs::write( - &checksum_path, - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9 bundle.tar.gz\n", - ) - .unwrap(); - - assert_eq!( - expected_sha256(&checksum_path).unwrap(), - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - ); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn test_verify_download_checksum_rejects_mismatch() { - let dir = std::env::temp_dir().join("aish_test_verify_download_checksum"); - std::fs::create_dir_all(&dir).unwrap(); - let archive_path = dir.join("bundle.tar.gz"); - let checksum_path = dir.join("bundle.tar.gz.sha256"); - std::fs::write(&archive_path, b"hello world").unwrap(); - std::fs::write( - &checksum_path, - "0000000000000000000000000000000000000000000000000000000000000000 bundle.tar.gz\n", - ) - .unwrap(); - - assert!(verify_download_checksum(&archive_path, &checksum_path).is_err()); - - let _ = std::fs::remove_dir_all(&dir); - } - #[test] fn test_find_install_sh_not_found() { let dir = std::env::temp_dir().join("aish_test_find_empty"); diff --git a/crates/aish-i18n/locales/en-US.yaml b/crates/aish-i18n/locales/en-US.yaml index 5d93cba..924d26b 100644 --- a/crates/aish-i18n/locales/en-US.yaml +++ b/crates/aish-i18n/locales/en-US.yaml @@ -46,10 +46,6 @@ cli: check_failed: "Failed to check for updates: {error}" parse_releases_failed: "Failed to parse releases: {error}" parse_release_failed: "Failed to parse release: {error}" - latest_metadata_invalid: "Latest release metadata was empty or invalid" - latest_metadata_invalid_value: "Latest release metadata contained an invalid version: {value}" - checksum_file_invalid: "Downloaded checksum metadata was invalid" - checksum_mismatch: "Downloaded bundle checksum mismatch: expected {expected}, got {actual}" download_failed: "Download failed: {error}" file_create_failed: "Failed to create file: {error}" download_read_error: "Download read error: {error}" @@ -63,14 +59,13 @@ cli: unsupported_platform: "Unsupported platform: {platform}" unsupported_arch: "Unsupported architecture: {arch}" github_api_error: "GitHub API returned status {status}" - release_metadata_error: "Release metadata endpoint returned status {status}" - download_status_error: "Download endpoint returned status {status}" extraction_failed: "Extraction failed: {error}" installation_failed: "Installation failed: {error}" checking: "Checking for updates..." update_available: "Update available: {current} → {latest}" already_latest: "Already on the latest version ({version})." - downloading_release: "Downloading release bundle..." + downloading_from_github: "Downloading from GitHub..." + downloading_from_mirror: "GitHub download failed, trying mirror..." downloaded: "Downloaded: {path}" extracting: "Extracting archive..." install_sh_hash: "install.sh SHA256: {hash}" @@ -521,9 +516,8 @@ tools: description: "Execute a bash command and return the output. Use this tool to run shell commands." param: command: "The bash command to execute" - timeout: "Timeout in seconds. If omitted, the command runs until completion or cancellation." + timeout: "Timeout in seconds (default: 120)" missing_command: "Missing 'command' parameter" - invalid_timeout: "Invalid timeout: expected a positive integer number of seconds" execute_failed: "Failed to execute: {error}" output_truncated: "[...{bytes} bytes truncated...]\n{tail}" truncated_notice: "[...{bytes} bytes truncated...]" diff --git a/crates/aish-i18n/locales/zh-CN.yaml b/crates/aish-i18n/locales/zh-CN.yaml index 62aae4c..ae47aec 100644 --- a/crates/aish-i18n/locales/zh-CN.yaml +++ b/crates/aish-i18n/locales/zh-CN.yaml @@ -46,10 +46,6 @@ cli: check_failed: "检查更新失败: {error}" parse_releases_failed: "解析 releases 失败: {error}" parse_release_failed: "解析 release 失败: {error}" - latest_metadata_invalid: "最新版本元数据为空或无效" - latest_metadata_invalid_value: "最新版本元数据包含无效版本号: {value}" - checksum_file_invalid: "下载到的校验和元数据无效" - checksum_mismatch: "下载 bundle 的校验和不匹配:期望 {expected},实际 {actual}" download_failed: "下载失败: {error}" file_create_failed: "创建文件失败: {error}" download_read_error: "下载读取错误: {error}" @@ -63,14 +59,13 @@ cli: unsupported_platform: "不支持的平台: {platform}" unsupported_arch: "不支持的架构: {arch}" github_api_error: "GitHub API 返回状态码 {status}" - release_metadata_error: "release 元数据接口返回状态码 {status}" - download_status_error: "下载接口返回状态码 {status}" extraction_failed: "解压失败: {error}" installation_failed: "安装失败: {error}" checking: "正在检查更新..." update_available: "有更新可用: {current} → {latest}" already_latest: "已是最新版本 ({version})。" - downloading_release: "正在下载发布 bundle..." + downloading_from_github: "正在从 GitHub 下载..." + downloading_from_mirror: "GitHub 下载失败,尝试镜像..." downloaded: "已下载: {path}" extracting: "正在解压归档..." install_sh_hash: "install.sh SHA256: {hash}" @@ -520,9 +515,8 @@ tools: description: "执行 bash 命令并返回输出。使用此工具运行 shell 命令。" param: command: "要执行的 bash 命令" - timeout: "超时时间(秒)。如果省略,则命令会一直运行直到完成或被取消。" + timeout: "超时时间(秒)(默认:120)" missing_command: "缺少 'command' 参数" - invalid_timeout: "无效的超时时间:必须是正整数秒数" execute_failed: "执行失败: {error}" output_truncated: "[...截断了 {bytes} 字节...]\n{tail}" truncated_notice: "[...截断了 {bytes} 字节...]" diff --git a/crates/aish-llm/src/session.rs b/crates/aish-llm/src/session.rs index 4a93e6a..9b56da2 100644 --- a/crates/aish-llm/src/session.rs +++ b/crates/aish-llm/src/session.rs @@ -410,13 +410,11 @@ impl LlmSession { self.emit_content_delta( &accumulated, &accumulated, - false, ); } else { self.emit_content_delta( &delta, &accumulated, - false, ); } } @@ -517,9 +515,6 @@ impl LlmSession { // No tool calls — return accumulated content if tool_calls_accum.is_empty() { - if !accumulated.is_empty() && should_emit_final_stream_delta(&messages) { - self.emit_content_delta(&accumulated, &accumulated, true); - } // Log generation span to Langfuse if let (Some(ref langfuse), Some(ref tid)) = (&self.langfuse, &trace_id) { langfuse @@ -856,28 +851,19 @@ impl LlmSession { sub } - fn emit_content_delta(&self, delta: &str, accumulated: &str, is_final: bool) { + fn emit_content_delta(&self, delta: &str, accumulated: &str) { self.emit_event(LlmEvent { event_type: LlmEventType::ContentDelta, - data: content_delta_payload(delta, accumulated, is_final), + data: serde_json::json!({ + "delta": delta, + "accumulated": accumulated + }), timestamp: now_timestamp(), metadata: None, }); } } -fn content_delta_payload(delta: &str, accumulated: &str, is_final: bool) -> serde_json::Value { - serde_json::json!({ - "delta": delta, - "accumulated": accumulated, - "is_final": is_final, - }) -} - -fn should_emit_final_stream_delta(messages: &[ChatMessage]) -> bool { - messages.iter().any(|message| message.role == "tool") -} - /// Helper: current time as a UNIX timestamp in seconds (f64). fn now_timestamp() -> f64 { std::time::SystemTime::now() @@ -1190,23 +1176,4 @@ mod tests { assert!(tool_names.contains(&"grep")); assert!(!tool_names.contains(&"bash_exec")); } - - #[test] - fn test_content_delta_payload_marks_preview_phase() { - let payload = content_delta_payload("preview", "preview", false); - - assert_eq!(payload["delta"].as_str(), Some("preview")); - assert_eq!(payload["accumulated"].as_str(), Some("preview")); - assert_eq!(payload["is_final"].as_bool(), Some(false)); - } - - #[test] - fn test_should_emit_final_stream_delta_only_after_tool_context() { - let plain_messages = vec![make_msg("system", "sys"), make_msg("user", "hello")]; - assert!(!should_emit_final_stream_delta(&plain_messages)); - - let mut tool_messages = plain_messages; - tool_messages.push(ChatMessage::tool_result("tool-1", "done")); - assert!(should_emit_final_stream_delta(&tool_messages)); - } } diff --git a/crates/aish-shell/src/app.rs b/crates/aish-shell/src/app.rs index 179708f..cff4718 100644 --- a/crates/aish-shell/src/app.rs +++ b/crates/aish-shell/src/app.rs @@ -494,18 +494,15 @@ impl AishShell { LlmEventType::ContentDelta => { if let Some(delta) = event.data.get("delta").and_then(|d| d.as_str()) { if !delta.is_empty() { - let is_final = content_delta_is_final(&event.data); animation_ref.stop(); - if is_final && !ttft_recorded_ref.load(Ordering::SeqCst) { + if !ttft_recorded_ref.load(Ordering::SeqCst) { if let Some(start) = *thinking_start_ref.lock().unwrap() { let elapsed = start.elapsed().as_secs_f64(); *ttft_value_ref.lock().unwrap() = elapsed; ttft_recorded_ref.store(true, Ordering::SeqCst); } } - if is_final { - streamed_flag.store(true, Ordering::SeqCst); - } + streamed_flag.store(true, Ordering::SeqCst); clear_reasoning(); // Robot emoji prefix on first content chunk if !content_started_flag.load(Ordering::SeqCst) { @@ -1912,12 +1909,6 @@ pub fn collapse_output( result } -fn content_delta_is_final(data: &serde_json::Value) -> bool { - data.get("is_final") - .and_then(|value| value.as_bool()) - .unwrap_or(true) -} - #[cfg(test)] mod collapsing_tests { use super::*; @@ -1947,21 +1938,6 @@ mod collapsing_tests { let result = collapse_output(&output, Some("/tmp/offload.raw"), 20, 5); assert!(result.contains("/tmp/offload.raw")); } - - #[test] - fn test_content_delta_is_final_defaults_true() { - assert!(content_delta_is_final( - &serde_json::json!({ "delta": "answer" }) - )); - } - - #[test] - fn test_content_delta_is_final_respects_preview_flag() { - assert!(!content_delta_is_final(&serde_json::json!({ - "delta": "preview", - "is_final": false, - }))); - } } /// Format tool arguments for display in the streaming output. diff --git a/crates/aish-tools/src/bash.rs b/crates/aish-tools/src/bash.rs index 7a18187..5a043db 100644 --- a/crates/aish-tools/src/bash.rs +++ b/crates/aish-tools/src/bash.rs @@ -9,8 +9,12 @@ use aish_pty::{BashOffloadSettings, BashOutputOffload, CancelToken, PtyExecutor} /// The BashOutputOffload will handle threshold-based truncation and disk offload. const CAPTURE_KEEP_BYTES: usize = 10 * 1024 * 1024; // 10MB +/// Default timeout for command execution in seconds. +const DEFAULT_TIMEOUT_SECS: u64 = 120; + /// Check if a command likely needs interactive stdin (e.g. sudo password prompt). -/// False positives are acceptable because output is still captured for the LLM. +/// False positives (e.g. `grep sudo file`) are harmless — interactive mode just +/// means output also goes to the terminal, it's still captured for the LLM. fn needs_interactive(command: &str) -> bool { let lower = command.to_lowercase(); lower.contains("sudo") || lower.contains(" su ") || lower.starts_with("su ") @@ -26,18 +30,6 @@ fn get_description() -> &'static str { DESCRIPTION.get_or_init(|| aish_i18n::t("tools.bash.description")) } -fn timeout_secs(args: &serde_json::Value) -> Result, ToolResult> { - match args.get("timeout") { - None => Ok(None), - Some(timeout) => match timeout.as_i64() { - Some(seconds) if seconds > 0 => Ok(Some(seconds as u64)), - _ => Err(ToolResult::error(aish_i18n::t( - "tools.bash.invalid_timeout", - ))), - }, - } -} - impl Default for BashTool { fn default() -> Self { Self::new() @@ -69,8 +61,8 @@ impl Tool for BashTool { }, "timeout": { "type": "integer", - "minimum": 1, - "description": "Timeout in seconds. If omitted, the command runs until completion or cancellation." + "description": "Timeout in seconds (default: 120)", + "default": 120 } }, "required": ["command"] @@ -82,11 +74,13 @@ impl Tool for BashTool { Some(cmd) => cmd, None => return ToolResult::error(aish_i18n::t("tools.bash.missing_command")), }; - let timeout_secs = match timeout_secs(&args) { - Ok(value) => value, - Err(error) => return error, - }; + let timeout_secs = args + .get("timeout") + .and_then(|t| t.as_u64()) + .unwrap_or(DEFAULT_TIMEOUT_SECS); + // Use interactive executor (stdin forwarding + terminal display) for + // commands that may prompt for a password. Otherwise capture silently. let executor = if needs_interactive(command) { PtyExecutor::new(CAPTURE_KEEP_BYTES) } else { @@ -94,14 +88,13 @@ impl Tool for BashTool { }; let cancel_token = Arc::new(CancelToken::new()); - if let Some(timeout_secs) = timeout_secs { - let timeout_token = Arc::clone(&cancel_token); - let timeout_duration = Duration::from_secs(timeout_secs); - std::thread::spawn(move || { - std::thread::sleep(timeout_duration); - timeout_token.cancel(); - }); - } + // Spawn a thread that cancels after timeout. + let timeout_token = Arc::clone(&cancel_token); + let timeout_duration = Duration::from_secs(timeout_secs); + std::thread::spawn(move || { + std::thread::sleep(timeout_duration); + timeout_token.cancel(); + }); let env_vars: std::collections::HashMap = std::env::vars().collect(); @@ -129,6 +122,10 @@ impl Tool for BashTool { offload_result.offload_payload.as_ref(), ); + // Always report ok=true when PTY execution succeeds. + // Non-zero exit codes are normal command outcomes, not tool + // failures — the LLM sees and decides what to do. + // Returning ok=false triggers a pointless retry of the same command. ToolResult { ok: true, output, @@ -198,6 +195,7 @@ mod tests { let result = tool.execute(serde_json::json!({ "command": "exit 42" })); + // Tool succeeds even with non-zero exit — LLM reads to decide. assert!( result.ok, "tool execution should succeed regardless of exit code" @@ -216,43 +214,10 @@ mod tests { "command": "sleep 60", "timeout": 1 })); + // Tool succeeds even when command is killed by timeout. assert!( result.ok, "tool execution should succeed even after timeout kill" ); } - - #[test] - fn test_bash_tool_parameters_do_not_advertise_default_timeout() { - let tool = BashTool::new(); - let params = tool.parameters(); - let timeout = ¶ms["properties"]["timeout"]; - - assert!(timeout.get("default").is_none()); - assert_eq!( - timeout["description"].as_str(), - Some("Timeout in seconds. If omitted, the command runs until completion or cancellation.") - ); - } - - #[test] - fn test_timeout_secs_is_optional() { - assert_eq!( - timeout_secs(&serde_json::json!({ "command": "echo hi" })).unwrap(), - None - ); - assert_eq!( - timeout_secs(&serde_json::json!({ "command": "echo hi", "timeout": 3 })).unwrap(), - Some(3) - ); - } - - #[test] - fn test_timeout_secs_rejects_invalid_values() { - assert!(timeout_secs(&serde_json::json!({ "command": "echo hi", "timeout": 0 })).is_err()); - assert!(timeout_secs(&serde_json::json!({ "command": "echo hi", "timeout": -1 })).is_err()); - assert!( - timeout_secs(&serde_json::json!({ "command": "echo hi", "timeout": "5" })).is_err() - ); - } } diff --git a/crates/aish-tools/src/system_diagnose.rs b/crates/aish-tools/src/system_diagnose.rs index c2fdca5..b5bad11 100644 --- a/crates/aish-tools/src/system_diagnose.rs +++ b/crates/aish-tools/src/system_diagnose.rs @@ -7,7 +7,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; -use aish_core::{LlmEvent, LlmEventType}; +use aish_core::LlmEvent; use aish_llm::diagnose_agent::build_diagnose_prompt; use aish_llm::types::LlmCallbackResult; use aish_llm::{DiagnoseAgent, LlmSession, SubSessionConfig, Tool, ToolResult}; @@ -75,18 +75,6 @@ impl SystemDiagnoseTool { } } -fn should_forward_diagnose_event(event_type: &LlmEventType) -> bool { - matches!( - event_type, - LlmEventType::ToolExecutionStart - | LlmEventType::ToolExecutionEnd - | LlmEventType::Error - | LlmEventType::ToolConfirmationRequired - | LlmEventType::InteractionRequired - | LlmEventType::Cancelled - ) -} - impl Tool for SystemDiagnoseTool { fn name(&self) -> &str { "system_diagnose_agent" @@ -146,9 +134,6 @@ impl Tool for SystemDiagnoseTool { if let Some(cb) = maybe_cb { let proxy_cb: Arc Option + Send + Sync> = Arc::new(move |event: LlmEvent| { - if !should_forward_diagnose_event(&event.event_type) { - return None; - } let mut modified_data = match event.data.as_object() { Some(obj) => { let mut new_obj = obj.clone(); @@ -232,37 +217,3 @@ impl Tool for SystemDiagnoseTool { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_should_forward_diagnose_event_matches_allowlist() { - let cases = [ - (LlmEventType::OpStart, false), - (LlmEventType::OpEnd, false), - (LlmEventType::GenerationStart, false), - (LlmEventType::GenerationEnd, false), - (LlmEventType::ContentDelta, false), - (LlmEventType::ReasoningStart, false), - (LlmEventType::ReasoningDelta, false), - (LlmEventType::ReasoningEnd, false), - (LlmEventType::ToolExecutionStart, true), - (LlmEventType::ToolExecutionEnd, true), - (LlmEventType::Error, true), - (LlmEventType::ToolConfirmationRequired, true), - (LlmEventType::InteractionRequired, true), - (LlmEventType::Cancelled, true), - ]; - - for (event_type, expected) in cases { - assert_eq!( - should_forward_diagnose_event(&event_type), - expected, - "unexpected forwarding decision for {:?}", - event_type - ); - } - } -} diff --git a/packaging/build_bundle.sh b/packaging/build_bundle.sh index 50ca685..b3a4dde 100755 --- a/packaging/build_bundle.sh +++ b/packaging/build_bundle.sh @@ -11,25 +11,11 @@ load_cargo_version() { | sed 's/version.*=.*"\([^"]*\)".*/\1/' } -normalize_bundle_arch() { - case "$1" in - x86_64|amd64) - printf 'amd64' - ;; - aarch64|arm64) - printf 'arm64' - ;; - *) - printf '%s' "$1" - ;; - esac -} - VERSION="${VERSION:-${1:-}}" if [[ -z "$VERSION" ]]; then VERSION="$(load_cargo_version)" fi -ARCH="$(normalize_bundle_arch "${ARCH:-${2:-amd64}}")" +ARCH="${ARCH:-${2:-x86_64}}" PLATFORM="${PLATFORM:-${4:-linux}}" TARGET="${AISH_BUILD_TARGET:-x86_64-unknown-linux-musl}" OUTPUT_DIR="${OUTPUT_DIR:-${3:-dist/release}}" diff --git a/packaging/scripts/publish_release.sh b/packaging/scripts/publish_release.sh deleted file mode 100644 index 34e1f83..0000000 --- a/packaging/scripts/publish_release.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -require_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "Missing required environment variable: ${name}" >&2 - exit 1 - fi -} - -require_env VERSION -require_env ARTIFACT_ROOT -require_env R2_BUCKET -require_env R2_ENDPOINT -require_env AWS_ACCESS_KEY_ID -require_env AWS_SECRET_ACCESS_KEY - -DOWNLOAD_PREFIX="${DOWNLOAD_PREFIX:-download}" -CDN_BASE_URL="${CDN_BASE_URL:-https://cdn.aishell.ai}" -VERSION="${VERSION#v}" -ARTIFACT_ROOT="${ARTIFACT_ROOT%/}" -VALIDATION_RETRY_COUNT="${VALIDATION_RETRY_COUNT:-10}" -VALIDATION_RETRY_DELAY_SECS="${VALIDATION_RETRY_DELAY_SECS:-2}" - -mapfile -t ARTIFACT_FILES < <( - find "$ARTIFACT_ROOT" -type f \ - \( -name "aish-${VERSION}-linux-*.tar.gz" \ - -o -name "aish-${VERSION}-linux-*.tar.gz.sha256" \) \ - | sort -) - -if [[ "${#ARTIFACT_FILES[@]}" -eq 0 ]]; then - echo "No release artifacts found under ${ARTIFACT_ROOT}" >&2 - exit 1 -fi - -mapfile -t BUNDLE_FILES < <( - find "$ARTIFACT_ROOT" -type f -name "aish-${VERSION}-linux-*.tar.gz" | sort -) - -if [[ "${#BUNDLE_FILES[@]}" -eq 0 ]]; then - echo "No release bundles found under ${ARTIFACT_ROOT}" >&2 - exit 1 -fi - -for bundle_path in "${BUNDLE_FILES[@]}"; do - if [[ ! -f "${bundle_path}.sha256" ]]; then - echo "Missing checksum for $(basename "$bundle_path")" >&2 - exit 1 - fi -done - -upload_object() { - local source_path="$1" - local destination_key="$2" - local cache_control="$3" - shift 3 - - aws s3 cp "$source_path" "s3://${R2_BUCKET}/${destination_key}" \ - --endpoint-url "$R2_ENDPOINT" \ - --cache-control "$cache_control" \ - "$@" -} - -for artifact_path in "${ARTIFACT_FILES[@]}"; do - artifact_name="$(basename "$artifact_path")" - release_key="${DOWNLOAD_PREFIX}/releases/${VERSION}/${artifact_name}" - cache_control="public, max-age=31536000, immutable" - content_type_args=() - - if [[ "$artifact_name" == *.sha256 ]]; then - content_type_args=(--content-type text/plain) - fi - - echo "Uploading ${artifact_name} to ${release_key}" - upload_object "$artifact_path" "$release_key" "$cache_control" "${content_type_args[@]}" -done - -latest_file="$(mktemp)" -trap 'rm -f "$latest_file"' EXIT -printf '%s' "$VERSION" > "$latest_file" - -echo "Updating ${DOWNLOAD_PREFIX}/latest" -upload_object "$latest_file" "${DOWNLOAD_PREFIX}/latest" "no-store" --content-type text/plain - -validate_url() { - local url="$1" - local attempt=1 - local exit_code=0 - - while (( attempt <= VALIDATION_RETRY_COUNT )); do - if curl -fsSI --connect-timeout 10 --max-time 30 "$url" >/dev/null; then - return 0 - else - exit_code=$? - fi - if (( attempt == VALIDATION_RETRY_COUNT )); then - return "$exit_code" - fi - sleep $((VALIDATION_RETRY_DELAY_SECS * attempt)) - ((attempt += 1)) - done -} - -validated_urls=( - "${CDN_BASE_URL%/}/${DOWNLOAD_PREFIX}/latest" -) - -for artifact_path in "${ARTIFACT_FILES[@]}"; do - artifact_name="$(basename "$artifact_path")" - validated_urls+=("${CDN_BASE_URL%/}/${DOWNLOAD_PREFIX}/releases/${VERSION}/${artifact_name}") -done - -for url in "${validated_urls[@]}"; do - echo "Validating ${url}" - validate_url "$url" -done - -if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then - { - echo "## CDN Publish" - echo - echo "- Version: ${VERSION}" - echo "- Bucket: ${R2_BUCKET}" - echo "- Latest URL: ${CDN_BASE_URL%/}/${DOWNLOAD_PREFIX}/latest" - echo - echo "### Published artifacts" - for artifact_path in "${ARTIFACT_FILES[@]}"; do - artifact_name="$(basename "$artifact_path")" - echo "- ${DOWNLOAD_PREFIX}/releases/${VERSION}/${artifact_name}" - done - } >> "$GITHUB_STEP_SUMMARY" -fi