From 04275dbb36d640b91569aaf01b80f116e66f3200 Mon Sep 17 00:00:00 2001 From: Lowder Date: Mon, 7 Sep 2026 17:34:50 +0500 Subject: [PATCH] feat: switch versioning --- .github/scripts/validate-release.py | 18 +++ .github/workflows/build.yml | 171 +++++++++++--------- .github/workflows/docker-publish.yml | 22 ++- .github/workflows/probe-build.yml | 26 ++- .github/workflows/release.yml | 169 ++++---------------- .github/workflows/validate-release.yml | 24 +++ Cargo.lock | Bin 128554 -> 128576 bytes probe/Cargo.toml | 2 +- probe/src/sni.rs | 11 +- website/Cargo.toml | 4 +- website/src/probe_updates.rs | 209 +++++++++++++++++++++++-- 11 files changed, 420 insertions(+), 236 deletions(-) create mode 100644 .github/scripts/validate-release.py create mode 100644 .github/workflows/validate-release.yml diff --git a/.github/scripts/validate-release.py b/.github/scripts/validate-release.py new file mode 100644 index 0000000..d2f0ea7 --- /dev/null +++ b/.github/scripts/validate-release.py @@ -0,0 +1,18 @@ +"""Validate a component release tag against its Cargo manifest.""" +import os +import sys +import tomllib +from pathlib import Path + +component = sys.argv[1] +if component not in {"probe", "website", "reporter"}: + raise SystemExit(f"Unknown release component: {component}") +with Path(component, "Cargo.toml").open("rb") as manifest: + version = tomllib.load(manifest)["package"]["version"] +if os.environ.get("GITHUB_REF_TYPE") == "tag": + expected = f"{component}-v{version}" + actual = os.environ["GITHUB_REF_NAME"] + if actual != expected: + raise SystemExit(f"Tag {actual!r} does not match {component}/Cargo.toml; expected {expected!r}") +with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"version={version}\n") diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2753baf..44f3998 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,81 +1,100 @@ -name: Build Rust Application - +name: Build Reporter + on: push: branches: [ master ] - tags: [ 'v*.*.*' ] + tags: [ 'reporter-v*.*.*' ] pull_request: workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - -jobs: - linux-amd64: - name: Linux amd64 (.deb) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install cargo-deb - run: cargo install cargo-deb - - - name: Build Debian package - run: cargo deb -p reporter -- --bin cheburchecker - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: linux-amd64-deb - path: target/debian/*.deb - compression-level: 0 - - linux-arm64: - name: Linux arm64 (.deb via cross) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install cargo-deb - run: cargo install cargo-deb - - - name: Build binary - uses: houseabsolute/actions-rust-cross@v1 - with: - command: build - target: "aarch64-unknown-linux-gnu" - args: "--release --bin cheburchecker" - strip: true - - - name: Create Debian archive - run: cargo deb --target aarch64-unknown-linux-gnu --no-strip --no-build -p reporter -- --bin cheburchecker - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: linux-arm64-deb - path: target/aarch64-unknown-linux-gnu/debian/*.deb - - windows: - name: Windows amd64 - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Build release binary - run: cargo build --release --bin cheburchecker - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: windows-amd64-exe - path: target/release/*.exe + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + uses: ./.github/workflows/validate-release.yml + with: + component: reporter + + linux-amd64: + needs: validate + name: Linux amd64 (.deb) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Build Debian package + run: cargo deb -p reporter -- --bin cheburchecker + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: linux-amd64-deb + path: target/debian/*.deb + compression-level: 0 + + linux-arm64: + needs: validate + name: Linux arm64 (.deb via cross) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-deb + run: cargo install cargo-deb + + - name: Build binary + uses: houseabsolute/actions-rust-cross@v1 + with: + command: build + target: "aarch64-unknown-linux-gnu" + args: "--release --bin cheburchecker" + strip: true + + - name: Create Debian archive + run: cargo deb --target aarch64-unknown-linux-gnu --no-strip --no-build -p reporter -- --bin cheburchecker + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: linux-arm64-deb + path: target/aarch64-unknown-linux-gnu/debian/*.deb + + windows: + needs: validate + name: Windows amd64 + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build release binary + run: cargo build --release --bin cheburchecker + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: windows-amd64-exe + path: target/release/*.exe + + release: + needs: [linux-amd64, linux-arm64, windows] + if: github.event_name == 'push' && github.ref_type == 'tag' + permissions: + contents: write + actions: read + uses: ./.github/workflows/release.yml + with: + component: reporter + artifact-pattern: '*' diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index edba3f3..7bdcd8f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -3,7 +3,7 @@ name: Build and Publish Docker Images on: push: branches: [ "master" ] - tags: [ 'v*.*.*' ] + tags: [ 'website-v*.*.*' ] pull_request: branches: [ "master" ] @@ -12,7 +12,13 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + validate: + uses: ./.github/workflows/validate-release.yml + with: + component: website + build: + needs: validate name: Build ${{ matrix.service }} image runs-on: ubuntu-latest strategy: @@ -54,8 +60,8 @@ jobs: tags: | type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{version}},value=${{ needs.validate.outputs.version }},enable=${{ github.ref_type == 'tag' }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.validate.outputs.version }},enable=${{ github.ref_type == 'tag' }} type=sha type=raw,value=latest,enable={{is_default_branch}} @@ -81,3 +87,13 @@ jobs: - name: Trigger Coolify deploy run: | curl --fail --request POST '${{ secrets.COOLIFY_WEBHOOK }}' --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}' + + release: + needs: [build] + if: github.event_name == 'push' && github.ref_type == 'tag' + permissions: + contents: write + actions: read + uses: ./.github/workflows/release.yml + with: + component: website diff --git a/.github/workflows/probe-build.yml b/.github/workflows/probe-build.yml index 5a1d17a..d6f1a7f 100644 --- a/.github/workflows/probe-build.yml +++ b/.github/workflows/probe-build.yml @@ -3,7 +3,7 @@ name: Build Probe on: push: branches: [ master ] - tags: [ 'v*.*.*' ] + tags: [ 'probe-v*.*.*' ] pull_request: workflow_dispatch: @@ -14,7 +14,13 @@ env: OPENWRT_ARM64_ARCHES: aarch64_generic aarch64_cortex-a53 aarch64_cortex-a72 jobs: + validate: + uses: ./.github/workflows/validate-release.yml + with: + component: probe + linux-amd64: + needs: validate name: Linux amd64 binary and .deb runs-on: ubuntu-latest steps: @@ -58,6 +64,7 @@ jobs: compression-level: 0 linux-arm64: + needs: validate name: Linux arm64 binary, .deb, and OpenWrt packages runs-on: ubuntu-latest steps: @@ -134,6 +141,7 @@ jobs: compression-level: 0 windows-amd64: + needs: validate name: Windows amd64 binary runs-on: windows-latest steps: @@ -160,6 +168,7 @@ jobs: compression-level: 0 docker: + needs: validate name: Docker image amd64/arm64 runs-on: ubuntu-latest permissions: @@ -223,8 +232,8 @@ jobs: tags: | type=ref,event=branch type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{version}},value=${{ needs.validate.outputs.version }},enable=${{ github.ref_type == 'tag' }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.validate.outputs.version }},enable=${{ github.ref_type == 'tag' }} type=sha type=raw,value=latest,enable={{is_default_branch}} @@ -239,3 +248,14 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + release: + needs: [linux-amd64, linux-arm64, windows-amd64, docker] + if: github.event_name == 'push' && github.ref_type == 'tag' + permissions: + contents: write + actions: read + uses: ./.github/workflows/release.yml + with: + component: probe + artifact-pattern: 'cheburprobe-*' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a467243..3bb434d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,15 @@ -name: Draft Release +name: Draft component release on: - push: - tags: [ 'v*.*.*' ] + workflow_call: + inputs: + component: + required: true + type: string + artifact-pattern: + required: false + type: string + default: '' permissions: actions: read @@ -17,148 +24,29 @@ jobs: name: Draft GitHub release runs-on: ubuntu-latest steps: - - name: Collect build artifacts - uses: actions/github-script@v8 - env: - TAG_NAME: ${{ github.ref_name }} - WORKFLOWS: | - Build Rust Application - Build Probe + - name: Download component artifacts + if: inputs.component != 'website' + uses: actions/download-artifact@v8 with: - script: | - const fs = require('fs'); - const path = require('path'); - - const owner = context.repo.owner; - const repo = context.repo.repo; - const tagName = process.env.TAG_NAME; - const workflows = process.env.WORKFLOWS - .split(/\r?\n/) - .map((name) => name.trim()) - .filter(Boolean); - const targetSha = context.sha; - const outDir = path.join(process.cwd(), 'artifact-archives'); - const pollDelayMs = 30_000; - const timeoutMs = 30 * 60 * 1000; - - fs.mkdirSync(outDir, { recursive: true }); - - async function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - async function findWorkflowRun(workflowName) { - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { - const runs = await github.paginate( - github.rest.actions.listWorkflowRunsForRepo, - { - owner, - repo, - head_sha: targetSha, - event: 'push', - per_page: 100, - }, - ); - - const run = runs.find((candidate) => - candidate.name === workflowName && - candidate.head_sha === targetSha && - candidate.head_branch === tagName - ); - - if (run?.status === 'completed') { - if (run.conclusion === 'success') { - return run; - } - - throw new Error( - `${workflowName} completed with conclusion ${run.conclusion}: ${run.html_url}`, - ); - } - - core.info( - run - ? `${workflowName} is ${run.status}; waiting for completion.` - : `Waiting for ${workflowName} to start for ${tagName}.`, - ); - await sleep(pollDelayMs); - } - - throw new Error(`Timed out waiting for ${workflowName} on ${tagName}.`); - } - - for (const workflowName of workflows) { - const run = await findWorkflowRun(workflowName); - core.info(`Downloading artifacts from ${workflowName}: ${run.html_url}`); - - const artifacts = await github.paginate( - github.rest.actions.listWorkflowRunArtifacts, - { - owner, - repo, - run_id: run.id, - per_page: 100, - }, - ); - - for (const artifact of artifacts.filter((item) => !item.expired)) { - if (artifact.name.endsWith('.dockerbuild')) { - core.info(`Skipping Docker build record artifact ${artifact.name}`); - continue; - } - - const archive = await github.rest.actions.downloadArtifact({ - owner, - repo, - artifact_id: artifact.id, - archive_format: 'zip', - }); - - const artifactPath = path.join(outDir, `${artifact.name}.zip`); - fs.writeFileSync(artifactPath, Buffer.from(archive.data)); - core.info(`Saved ${artifactPath}`); - } - } - - - name: Extract release assets - shell: bash - run: | - set -euo pipefail - - mkdir -p release-artifacts - - for archive in artifact-archives/*.zip; do - artifact_name="$(basename "$archive" .zip)" - extract_dir="$(mktemp -d)" - - unzip -q "$archive" -d "$extract_dir" - - while IFS= read -r -d '' file; do - filename="$(basename "$file")" - target="release-artifacts/$filename" - - if [ -e "$target" ]; then - target="release-artifacts/${artifact_name}-${filename}" - fi - - mv "$file" "$target" - done < <(find "$extract_dir" -type f -print0) - - rm -rf "$extract_dir" - done + pattern: ${{ inputs.artifact-pattern }} + path: release-artifacts + merge-multiple: true - name: Generate release body + id: body uses: actions/github-script@v8 env: TAG_NAME: ${{ github.ref_name }} + COMPONENT: ${{ inputs.component }} with: script: | const fs = require('fs'); const path = require('path'); const tag = process.env.TAG_NAME; + fs.mkdirSync('release-artifacts', { recursive: true }); + const component = process.env.COMPONENT; + core.setOutput('prerelease', tag.slice((component + '-v').length).split('+')[0].includes('-')); const files = fs.readdirSync('release-artifacts') .filter((name) => fs.statSync(path.join('release-artifacts', name)).isFile()) .sort(); @@ -222,14 +110,14 @@ jobs: const body = [ '## Документация', '', - '- [Cheburprobe](' + sourceBaseUrl + '/probe/README.md)', - '- [Cheburchecker](' + sourceBaseUrl + '/reporter/README.md)', + '- [Документация](' + sourceBaseUrl + (component === 'website' ? '/README.md' : '/' + component + '/README.md') + ')', '', - '## Файлы релиза', + component === 'website' ? '## Docker images' : '## Файлы релиза', '', - '| Файл | Описание |', - '| --- | --- |', - ...rows, + ...(component === 'website' ? [ + '`ghcr.io/' + context.repo.owner.toLowerCase() + '/' + context.repo.repo.toLowerCase() + ':' + tag.slice('website-v'.length) + '`', + '`ghcr.io/' + context.repo.owner.toLowerCase() + '/' + context.repo.repo.toLowerCase() + '-frontend:' + tag.slice('website-v'.length) + '`', + ] : ['| Файл | Описание |', '| --- | --- |', ...rows]), '', ].join('\n'); @@ -241,6 +129,7 @@ jobs: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} draft: true + prerelease: ${{ steps.body.outputs.prerelease }} body_path: release-body.md - fail_on_unmatched_files: true + fail_on_unmatched_files: ${{ inputs.component != 'website' }} files: release-artifacts/* diff --git a/.github/workflows/validate-release.yml b/.github/workflows/validate-release.yml new file mode 100644 index 0000000..cabfc29 --- /dev/null +++ b/.github/workflows/validate-release.yml @@ -0,0 +1,24 @@ +name: Validate component version + +on: + workflow_call: + inputs: + component: + required: true + type: string + outputs: + version: + value: ${{ jobs.validate.outputs.version }} + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v6 + - name: Validate release tag + id: version + env: + COMPONENT: ${{ inputs.component }} + run: python3 .github/scripts/validate-release.py "$COMPONENT" diff --git a/Cargo.lock b/Cargo.lock index 9ed63f130d48b638a9075e81636cf36b2d0b1172..3804e8cc69daef805ea8d1ca16d75ae51f433326 100644 GIT binary patch literal 128576 zcmeIuF#!Mo0K%a4Pi+c6h(KY$fB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* z1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd z0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwA zz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEj zFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r z3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@ z0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VK zfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5 zV8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM z7%*VKfB^#r3>YwAz<>b*1`HT5V8DO@0|pEjFkrxd0RsjM7%*VKfB^#r3>YwAz<>b* L1`HT5VBj|}_D=u- literal 128554 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 YcQsN*Vp#3r^#J)`i*ba(wt(XNKdXHzL;wH) diff --git a/probe/Cargo.toml b/probe/Cargo.toml index 80c2568..e18d944 100644 --- a/probe/Cargo.toml +++ b/probe/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "probe" -version = "0.6.4" +version = "0.6.5" edition = "2024" license-file = "../LICENSE" description = "Dynamic network probe daemon for Cheburcheck" diff --git a/probe/src/sni.rs b/probe/src/sni.rs index da8204a..afe3324 100644 --- a/probe/src/sni.rs +++ b/probe/src/sni.rs @@ -1,10 +1,11 @@ use anyhow::{Result, bail}; use futures::future::join_all; -use log::warn; +use log::{debug, warn}; use reports::probe::{Host, HostProbeResult, ProbeConfig, ProbeEvidence}; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; use rustls::{ClientConfig, DigitallySignedStruct, Error as TlsError, SignatureScheme}; +use std::io; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -57,12 +58,16 @@ async fn probe_host(host: &Host, target: &str) -> ProbeEvidence { let server_name = match ServerName::try_from(target.to_string()) { Ok(server_name) => server_name, - Err(_) => return ProbeEvidence::ClientHello, + Err(_) => return ProbeEvidence::ConnectionError, }; let mut tls = match time::timeout(timeout, connector.connect(server_name, tcp)).await { Ok(Ok(tls)) => tls, - Ok(Err(_)) | Err(_) => return ProbeEvidence::ClientHello, + Ok(Err(e)) if e.kind() == io::ErrorKind::InvalidData => { + debug!("TLS handshake error for host {}: {:?}", host.host, e); + return ProbeEvidence::ConnectionError; + } + _ => return ProbeEvidence::ClientHello, }; let request = format!( diff --git a/website/Cargo.toml b/website/Cargo.toml index 373cfc6..129ced3 100644 --- a/website/Cargo.toml +++ b/website/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "website" -version = "1.3.4" +version = "1.3.5" edition = "2024" [dependencies] +anyhow = "1.0" +semver = "1.0" rocket = { version = "0.5.1", features = ["msgpack", "json"] } rocket_dyn_templates = { version = "0.2.0", features = ["tera"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "migrate", "chrono", "uuid"] } diff --git a/website/src/probe_updates.rs b/website/src/probe_updates.rs index 9d1dca0..0a5f441 100644 --- a/website/src/probe_updates.rs +++ b/website/src/probe_updates.rs @@ -1,4 +1,5 @@ use crate::api::ProbeUpdateDownloadRateLimiter; +use anyhow::Context; use log::error; use reqwest::Client; use rocket::State; @@ -6,6 +7,7 @@ use rocket::http::{ContentType, Status}; use rocket::serde::json::Json; use rocket::tokio::sync::Mutex; use rocket_client_addr::ClientRealAddr; +use semver::Version; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -37,9 +39,66 @@ struct CachedRelease { #[derive(Clone, Debug, Deserialize)] struct GithubRelease { + tag_name: String, + draft: bool, + prerelease: bool, assets: Vec, } +impl GithubRelease { + fn probe_version(&self) -> Option { + if self.draft || self.prerelease { + return None; + } + // Legacy v* tags used the website version. Read the probe version from + // a standalone binary instead, never compare the two version streams. + let version = + if let Some(version) = self.tag_name.strip_prefix("probe-v") { + if !self.assets.iter().any(|asset| { + valid_asset_name(&asset.name) && asset.name.starts_with("cheburprobe") + }) { + return None; + } + Version::parse(version).ok()? + } else { + let legacy = Version::parse(self.tag_name.strip_prefix('v')?).ok()?; + if !legacy.pre.is_empty() { + return None; + } + self.assets + .iter() + .filter_map(|asset| { + let name = asset.name.strip_prefix("cheburprobe-")?; + let version = name + .strip_suffix("-linux-amd64") + .or_else(|| name.strip_suffix("-linux-arm64")) + .or_else(|| name.strip_suffix("-windows-x86_64.exe"))?; + Version::parse(version) + .ok() + .filter(|version| version.pre.is_empty()) + }) + .max_by(Version::cmp_precedence)? + }; + version.pre.is_empty().then_some(version) + } +} + +// Keep the first legacy release only as a fallback. Component releases take +// precedence, and the caller stops pagination as soon as one is found. +fn select_probe_release( + candidate: GithubRelease, + legacy: &mut Option, +) -> Option { + candidate.probe_version()?; + if candidate.tag_name.starts_with("probe-v") { + return Some(candidate); + } + if legacy.is_none() { + *legacy = Some(candidate); + } + None +} + #[derive(Clone, Debug, Deserialize)] struct GithubAsset { id: u64, @@ -107,7 +166,33 @@ impl ProbeUpdateProxy { } } - async fn release(&self) -> Result { + async fn latest_github_release(&self, releases_url: &str) -> anyhow::Result { + let mut legacy = None; + let mut page = 1_u64; + loop { + let releases = self + .github_request(releases_url, "application/vnd.github+json") + .query(&[("per_page", 100), ("page", page)]) + .send() + .await? + .error_for_status()? + .json::>() + .await?; + let last_page = releases.len() < 100; + for release in releases { + if let Some(release) = select_probe_release(release, &mut legacy) { + return Ok(release); + } + } + if last_page { + break; + } + page += 1; + } + legacy.context("no published stable probe release with probe assets found") + } + + async fn release(&self) -> anyhow::Result { let mut cache = self.cache.lock().await; if let Some(cached) = cache.as_ref() && cached.fetched_at.elapsed() < self.cache_ttl @@ -115,14 +200,8 @@ impl ProbeUpdateProxy { return Ok(cached.release.clone()); } - let url = format!("{GITHUB_API_BASE_URL}/repos/{DEFAULT_REPOSITORY}/releases/latest"); - let github_release = self - .github_request(&url, "application/vnd.github+json") - .send() - .await? - .error_for_status()? - .json::() - .await?; + let url = format!("{GITHUB_API_BASE_URL}/repos/{DEFAULT_REPOSITORY}/releases"); + let github_release = self.latest_github_release(&url).await?; let release = Release { assets: github_release .assets @@ -270,6 +349,118 @@ fn valid_asset_name(name: &str) -> bool { mod tests { use super::*; + fn release(tag: &str, probe_version: &str) -> GithubRelease { + GithubRelease { + tag_name: tag.to_owned(), + draft: false, + prerelease: false, + assets: vec![GithubAsset { + id: 42, + name: format!("cheburprobe-{probe_version}-linux-amd64"), + }], + } + } + + #[test] + fn selects_first_stable_probe_release_in_api_order() { + let mut legacy = None; + let selected = [ + release("website-v9.0.0", "9.0.0"), + release("reporter-v9.0.0", "9.0.0"), + release("probe-v0.9.0", "0.9.0"), + release("probe-v0.10.0", "0.10.0"), + ] + .into_iter() + .find_map(|candidate| select_probe_release(candidate, &mut legacy)); + assert_eq!(selected.unwrap().tag_name, "probe-v0.9.0"); + } + + #[test] + fn ignores_drafts_prereleases_invalid_tags_and_missing_probe_assets() { + let mut draft = release("probe-v8.0.0", "8.0.0"); + draft.draft = true; + let mut prerelease = release("probe-v9.0.0", "9.0.0"); + prerelease.prerelease = true; + let mut empty = release("probe-v10.0.0", "10.0.0"); + empty.assets.clear(); + let mut luci_only = release("probe-v11.0.0", "11.0.0"); + luci_only.assets[0].name = "luci-app-cheburprobe-11.0.0-r1.apk".to_owned(); + for candidate in [ + draft, + prerelease, + empty, + luci_only, + release("probe-v12.0.0-rc.1", "12.0.0-rc.1"), + release("probe-vinvalid", "13.0.0"), + release("v14.0.0-rc.1", "14.0.0"), + release("v14.0.0", "14.0.0-rc.1"), + ] { + assert_eq!(candidate.probe_version(), None, "{}", candidate.tag_name); + } + } + + #[test] + fn keeps_first_legacy_release_as_fallback_but_prefers_component_release() { + let mut legacy = None; + assert_eq!( + release("v9.0.0", "0.6.4").probe_version(), + Some(Version::new(0, 6, 4)) + ); + assert!(select_probe_release(release("v9.0.0", "0.6.4"), &mut legacy).is_none()); + assert!(select_probe_release(release("v10.0.0", "0.6.5"), &mut legacy).is_none()); + assert_eq!(legacy.as_ref().unwrap().tag_name, "v9.0.0"); + let selected = select_probe_release(release("probe-v0.6.3", "0.6.3"), &mut legacy); + assert_eq!(selected.unwrap().tag_name, "probe-v0.6.3"); + } + + // Both pages are full: returning the match must avoid requesting page three. + // Page one contains only releases of a different component. + #[rocket::async_test] + async fn stops_pagination_on_first_matching_release() { + use rocket::tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = rocket::tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .unwrap(); + let url = format!("http://{}/releases", listener.local_addr().unwrap()); + let server = rocket::tokio::spawn(async move { + for page in 1..=2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with(&format!("GET /releases?per_page=100&page={page} "))); + let item = if page == 1 { + r#"{"tag_name":"website-v9.0.0","draft":false,"prerelease":false,"assets":[]}"# + } else { + r#"{"tag_name":"probe-v0.6.5","draft":false,"prerelease":false,"assets":[{"id":42,"name":"cheburprobe-0.6.5-linux-amd64"}]}"# + }; + let body = format!("[{}]", vec![item; 100].join(",")); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + let mut proxy = ProbeUpdateProxy::from_env().unwrap(); + proxy.github_token = None; + let selected = + rocket::tokio::time::timeout(Duration::from_secs(5), proxy.latest_github_release(&url)) + .await + .unwrap() + .unwrap(); + assert_eq!(selected.tag_name, "probe-v0.6.5"); + server.await.unwrap(); + } + #[test] fn accepts_only_probe_release_asset_names() { assert!(valid_asset_name("cheburprobe-0.6.0-linux-amd64"));