Skip to content

Commit ed4adef

Browse files
committed
Add CI and tag-triggered release workflows
CI runs the frontend build, the node suites, fmt, clippy and cargo test on every push and pull request. Release fires on a v* tag: it refuses to start without the signing key, checks the tag against all three manifests, re-runs every gate, then builds signed and uploads the installer, the portable zip, their hashes and the updater manifest. Also applies rustfmt.
1 parent 367f832 commit ed4adef

7 files changed

Lines changed: 293 additions & 29 deletions

File tree

.github/workflows/ci.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: CI
2+
3+
# The same filter on both, so a direct push cannot skip a check a pull request
4+
# would have run. Nothing is path-ignored: the frontend, the Rust crate and the
5+
# installer script all gate the release, and Docs/ is not in the repo at all.
6+
on:
7+
push:
8+
branches: [main]
9+
pull_request:
10+
11+
jobs:
12+
build:
13+
runs-on: windows-latest
14+
steps:
15+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
16+
17+
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
18+
with:
19+
toolchain: stable
20+
components: clippy, rustfmt
21+
22+
# No setup-node: windows-latest already ships a Node new enough for Vite
23+
# and for the node --test style suites, so this is one less action to pin.
24+
- name: Frontend
25+
run: |
26+
npm ci
27+
npm run build
28+
29+
# These are plain scripts, not a framework, and they assert the things a
30+
# type checker cannot: percent semantics, the tile ordering, and that every
31+
# provider in the Rust registry has a logo that is legible on the dark
32+
# surface. Cheap, so run them before the slow Rust gates.
33+
- name: Frontend tests
34+
run: |
35+
node src/lib/format.test.js
36+
node src/lib/icons.test.js
37+
node src/lib/tiles.test.js
38+
39+
# The frontend build above must come first: tauri.conf.json points
40+
# frontendDist at ../dist, and the crate will not configure without it.
41+
- name: Format check
42+
working-directory: src-tauri
43+
run: cargo fmt --check
44+
45+
- name: Clippy
46+
working-directory: src-tauri
47+
run: cargo clippy --no-deps --all-targets -- -D warnings
48+
49+
# Ignored tests are excluded on purpose: they hit real provider APIs with
50+
# local credentials that no runner has. They are for a developer's machine.
51+
- name: Test
52+
working-directory: src-tauri
53+
run: cargo test

.github/workflows/release.yml

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
name: Release
2+
3+
# Fires on a version tag: git tag v0.4.0 && git push origin v0.4.0
4+
on:
5+
push:
6+
tags:
7+
- "v*"
8+
9+
permissions:
10+
contents: write # to create the release and upload assets
11+
12+
jobs:
13+
release:
14+
runs-on: windows-latest
15+
steps:
16+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
17+
18+
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
19+
with:
20+
toolchain: stable
21+
components: clippy, rustfmt
22+
23+
# Without the signing key the build still produces an installer, then fails
24+
# at the very end when it cannot write the updater signature. That wastes a
25+
# full Tauri release build, so refuse up front with a message that says what
26+
# to do rather than a cryptic tauri error ten minutes later.
27+
- name: Require the signing key
28+
shell: pwsh
29+
env:
30+
KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
31+
run: |
32+
if (-not $env:KEY) {
33+
Write-Output "::error::Secret TAURI_SIGNING_PRIVATE_KEY is not set. Without it the release cannot be signed and no installed copy would accept the update. Add it in Settings, Secrets and variables, Actions."
34+
exit 1
35+
}
36+
Write-Output "Signing key present."
37+
38+
# The version lives in three manifests and the updater compares the running
39+
# build against the tag. If they disagree, every user is offered an update
40+
# that installs and then offers itself again forever. Fail here instead.
41+
- name: Verify version consistency
42+
shell: pwsh
43+
env:
44+
TAG: ${{ github.ref_name }}
45+
run: |
46+
$expected = $env:TAG -replace '^v', ''
47+
$checks = [ordered]@{
48+
'src-tauri/tauri.conf.json' = [regex]::Match((Get-Content src-tauri/tauri.conf.json -Raw), '"version"\s*:\s*"([^"]+)"').Groups[1].Value
49+
'src-tauri/Cargo.toml' = [regex]::Match((Get-Content src-tauri/Cargo.toml -Raw), '(?m)^version\s*=\s*"([^"]+)"').Groups[1].Value
50+
'package.json' = [regex]::Match((Get-Content package.json -Raw), '"version"\s*:\s*"([^"]+)"').Groups[1].Value
51+
}
52+
$bad = $false
53+
foreach ($name in $checks.Keys) {
54+
if ($checks[$name] -ne $expected) {
55+
Write-Output "::error::$name is '$($checks[$name])' but tag v$expected requires '$expected'"
56+
$bad = $true
57+
}
58+
}
59+
if ($bad) {
60+
Write-Output "Refusing to release: bump every version reference to $expected, commit, delete the tag and retag."
61+
exit 1
62+
}
63+
Write-Output "All three manifests agree: $expected"
64+
65+
# A tag can point at a commit CI never saw, so re-run the gates here rather
66+
# than trusting that they were green somewhere. Never ship a broken build.
67+
- name: Frontend
68+
run: |
69+
npm ci
70+
npm run build
71+
72+
- name: Frontend tests
73+
run: |
74+
node src/lib/format.test.js
75+
node src/lib/icons.test.js
76+
node src/lib/tiles.test.js
77+
78+
- name: Format check
79+
working-directory: src-tauri
80+
run: cargo fmt --check
81+
82+
- name: Clippy
83+
working-directory: src-tauri
84+
run: cargo clippy --no-deps --all-targets -- -D warnings
85+
86+
- name: Test
87+
working-directory: src-tauri
88+
run: cargo test
89+
90+
- name: Build signed
91+
env:
92+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
93+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
94+
run: npx tauri build
95+
96+
# Two artifacts from ONE build, so the exe inside the zip is byte identical
97+
# to the one the installer writes:
98+
# the NSIS setup exe for people who want a normal Windows install,
99+
# the portable zip for install.ps1 and for voli, whose schema rejects
100+
# standalone executables outright.
101+
# The zip keeps a stable, unversioned name so that
102+
# releases/latest/download/AgentsBar-x64.zip always resolves, which is what
103+
# install.ps1 fetches.
104+
- name: Package
105+
id: package
106+
shell: pwsh
107+
env:
108+
TAG: ${{ github.ref_name }}
109+
run: |
110+
$version = $env:TAG -replace '^v', ''
111+
$rel = "src-tauri/target/release"
112+
$setup = "$rel/bundle/nsis/AgentsBar_${version}_x64-setup.exe"
113+
if (-not (Test-Path $setup)) {
114+
Write-Output "::error::Expected installer not found at $setup"
115+
Get-ChildItem "$rel/bundle/nsis" | ForEach-Object { Write-Output " found: $($_.Name)" }
116+
exit 1
117+
}
118+
if (-not (Test-Path "$setup.sig")) {
119+
Write-Output "::error::No .sig beside the installer: the build was not signed and the updater would reject this release."
120+
exit 1
121+
}
122+
123+
# agentsbar.exe sits at the archive ROOT, which is why the voli manifest
124+
# omits extract_dir and install.ps1 can copy the extraction into place.
125+
Compress-Archive -Path "$rel/agentsbar.exe" -DestinationPath "AgentsBar-x64.zip" -Force
126+
Copy-Item $setup "AgentsBar_${version}_x64-setup.exe"
127+
Copy-Item "$setup.sig" "AgentsBar_${version}_x64-setup.exe.sig"
128+
129+
# sha256sum format, ASCII: install.ps1 parses the hash out of the first
130+
# field, and PowerShell's utf8 would prepend a BOM that corrupts it.
131+
foreach ($f in @("AgentsBar-x64.zip", "AgentsBar_${version}_x64-setup.exe")) {
132+
$h = (Get-FileHash $f -Algorithm SHA256).Hash.ToLower()
133+
"$h $f" | Out-File "$f.sha256" -Encoding ascii -NoNewline
134+
Write-Output "$f $h"
135+
}
136+
"version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
137+
138+
# The updater fetches this manifest, checks the signature against the public
139+
# key pinned in tauri.conf.json, and only then offers the update.
140+
- name: Build updater manifest
141+
shell: pwsh
142+
env:
143+
TAG: ${{ github.ref_name }}
144+
run: |
145+
$version = $env:TAG -replace '^v', ''
146+
$sig = Get-Content "AgentsBar_${version}_x64-setup.exe.sig" -Raw
147+
$manifest = [ordered]@{
148+
version = $version
149+
notes = "See https://github.com/${{ github.repository }}/releases/tag/$env:TAG"
150+
pub_date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
151+
platforms = [ordered]@{
152+
"windows-x86_64" = [ordered]@{
153+
signature = $sig.Trim()
154+
url = "https://github.com/${{ github.repository }}/releases/download/$env:TAG/AgentsBar_${version}_x64-setup.exe"
155+
}
156+
}
157+
}
158+
$manifest | ConvertTo-Json -Depth 5 | Out-File latest.json -Encoding utf8
159+
Write-Output "latest.json written for $version"
160+
161+
- name: Create release
162+
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
163+
with:
164+
generate_release_notes: true
165+
files: |
166+
AgentsBar_${{ steps.package.outputs.version }}_x64-setup.exe
167+
AgentsBar_${{ steps.package.outputs.version }}_x64-setup.exe.sha256
168+
AgentsBar_${{ steps.package.outputs.version }}_x64-setup.exe.sig
169+
AgentsBar-x64.zip
170+
AgentsBar-x64.zip.sha256
171+
latest.json
172+
install.ps1
173+
174+
# The voli manifest needs the hash of the artifact as published, and it can
175+
# only be written after the upload. Print it so the registry PR is a copy
176+
# and paste rather than a download and hash by hand.
177+
- name: Voli manifest snippet
178+
shell: pwsh
179+
run: |
180+
$v = "${{ steps.package.outputs.version }}"
181+
$h = (Get-FileHash "AgentsBar-x64.zip" -Algorithm SHA256).Hash.ToLower()
182+
Write-Output "Add manifests/a/agentsbar/$v.toml to Topurrra/voli-registry with:"
183+
Write-Output ""
184+
Write-Output "[source.x64]"
185+
Write-Output "url = `"https://github.com/${{ github.repository }}/releases/download/v$v/AgentsBar-x64.zip`""
186+
Write-Output "sha256 = `"$h`""

src-tauri/src/providers/codex.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,8 @@ mod tests {
706706
}
707707

708708
fn scratch(tag: &str) -> PathBuf {
709-
let dir = std::env::temp_dir().join(format!("agentsbar-codex-{tag}-{}", std::process::id()));
709+
let dir =
710+
std::env::temp_dir().join(format!("agentsbar-codex-{tag}-{}", std::process::id()));
710711
let _ = std::fs::remove_dir_all(&dir);
711712
std::fs::create_dir_all(&dir).unwrap();
712713
dir

src-tauri/src/providers/manus.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,9 @@ mod tests {
199199
/// Same, with `now` pinned, so a payload whose reset time is a fixed date does not
200200
/// start failing on the day the wall clock passes it.
201201
fn parse_at(json: &str, now: &str) -> Result<UsageSnapshot, ProviderError> {
202-
let now = DateTime::parse_from_rfc3339(now).unwrap().with_timezone(&Utc);
202+
let now = DateTime::parse_from_rfc3339(now)
203+
.unwrap()
204+
.with_timezone(&Utc);
203205
snapshot(&serde_json::from_str(json).unwrap(), now)
204206
}
205207

src-tauri/src/providers/qwen.rs

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,11 @@ fn percentage_points(ratio: Option<f64>) -> Option<f64> {
153153
/// countdown by hours for anyone outside it.
154154
fn json_date(value: Option<&Value>) -> Option<DateTime<Utc>> {
155155
if let Some(number) = loose_f64(value).filter(|n| *n > 0.0) {
156-
let secs = if number >= 1e12 { number / 1000.0 } else { number };
156+
let secs = if number >= 1e12 {
157+
number / 1000.0
158+
} else {
159+
number
160+
};
157161
return DateTime::from_timestamp(secs as i64, 0);
158162
}
159163
let text = value?.as_str()?.trim();
@@ -218,18 +222,16 @@ fn to_snapshot(
218222
subscription: Option<&str>,
219223
quota_config: Option<&str>,
220224
) -> Result<UsageSnapshot, ProviderError> {
221-
let body = expand(
222-
serde_json::from_str(usage).map_err(|e| ProviderError::Parse(e.to_string()))?,
223-
);
224-
let block = find_object(&body, &["per5HourPercentage", "per1WeekPercentage"]).ok_or_else(
225-
|| {
225+
let body =
226+
expand(serde_json::from_str(usage).map_err(|e| ProviderError::Parse(e.to_string()))?);
227+
let block =
228+
find_object(&body, &["per5HourPercentage", "per1WeekPercentage"]).ok_or_else(|| {
226229
ProviderError::Parse(
227230
"no per5HourPercentage or per1WeekPercentage in the Qwen Cloud token plan usage \
228231
response"
229232
.to_string(),
230233
)
231-
},
232-
)?;
234+
})?;
233235

234236
let five_hour = percentage_points(loose_f64(block.get("per5HourPercentage")));
235237
let weekly = percentage_points(loose_f64(block.get("per1WeekPercentage")));
@@ -267,12 +269,15 @@ fn to_snapshot(
267269
// tier's own ceiling minus the share a lane says is gone. The weekly ceiling is the
268270
// plan's real budget; the 5 hour one is a burst cap inside it, which is why it is only
269271
// the fallback. `credits` reads as "left to spend" everywhere else in the app.
270-
snapshot.credits = [(totals.and_then(|t| t.1), weekly), (totals.and_then(|t| t.0), five_hour)]
271-
.into_iter()
272-
.find_map(|(total, used)| {
273-
let total = total.filter(|t| *t > 0.0)?;
274-
Some(total * (100.0 - used?) / 100.0)
275-
});
272+
snapshot.credits = [
273+
(totals.and_then(|t| t.1), weekly),
274+
(totals.and_then(|t| t.0), five_hour),
275+
]
276+
.into_iter()
277+
.find_map(|(total, used)| {
278+
let total = total.filter(|t| *t > 0.0)?;
279+
Some(total * (100.0 - used?) / 100.0)
280+
});
276281
Ok(snapshot)
277282
}
278283

@@ -282,9 +287,14 @@ fn to_snapshot(
282287
/// shell when the session is dead, so the status code alone cannot tell us.
283288
fn looks_like_login_page(html: &str) -> bool {
284289
let lower = html.to_ascii_lowercase();
285-
["passport.alibabacloud.com", "signin.aliyun.com", "account.alibabacloud.com/login", "login.qwencloud.com"]
286-
.iter()
287-
.any(|marker| lower.contains(marker))
290+
[
291+
"passport.alibabacloud.com",
292+
"signin.aliyun.com",
293+
"account.alibabacloud.com/login",
294+
"login.qwencloud.com",
295+
]
296+
.iter()
297+
.any(|marker| lower.contains(marker))
288298
|| (lower.contains("login") && lower.contains("password") && lower.contains("sign in"))
289299
}
290300

@@ -309,7 +319,10 @@ fn extract_token(html: &str) -> Option<String> {
309319
fn quoted_after_assignment(text: &str) -> Option<String> {
310320
let after_key = text.trim_start();
311321
let after_key = after_key.strip_prefix(['"', '\'']).unwrap_or(after_key);
312-
let body = after_key.trim_start().strip_prefix([':', '='])?.trim_start();
322+
let body = after_key
323+
.trim_start()
324+
.strip_prefix([':', '='])?
325+
.trim_start();
313326
let quote = body.chars().next().filter(|c| *c == '"' || *c == '\'')?;
314327
let body = &body[quote.len_utf8()..];
315328
let value = body[..body.find(quote)?].trim();
@@ -320,7 +333,8 @@ fn quoted_after_assignment(text: &str) -> Option<String> {
320333
fn cookie_value(header: &str, name: &str) -> Option<String> {
321334
header.split(';').find_map(|pair| {
322335
let (key, value) = pair.trim().split_once('=')?;
323-
(key.trim().eq_ignore_ascii_case(name) && !value.is_empty()).then(|| value.trim().to_string())
336+
(key.trim().eq_ignore_ascii_case(name) && !value.is_empty())
337+
.then(|| value.trim().to_string())
324338
})
325339
}
326340

@@ -452,7 +466,9 @@ async fn call_api(
452466
if let Some(csrf) =
453467
cookie_value(cookie, "login_aliyunid_csrf").or_else(|| cookie_value(cookie, "csrf"))
454468
{
455-
req = req.header("x-xsrf-token", &csrf).header("x-csrf-token", &csrf);
469+
req = req
470+
.header("x-xsrf-token", &csrf)
471+
.header("x-csrf-token", &csrf);
456472
}
457473
web_send(req, SIGNIN_HINT).await
458474
}
@@ -644,7 +660,10 @@ mod tests {
644660
at(r#""2033-01-01 00:00:00""#).unwrap().timestamp(),
645661
1_988_150_400
646662
);
647-
assert_eq!(at(r#""2033-01-01 00:00""#).unwrap().timestamp(), 1_988_150_400);
663+
assert_eq!(
664+
at(r#""2033-01-01 00:00""#).unwrap().timestamp(),
665+
1_988_150_400
666+
);
648667
assert_eq!(at(r#""2033-01-01""#).unwrap().timestamp(), 1_988_150_400);
649668
assert_eq!(at("0"), None);
650669
assert_eq!(at(r#""whenever""#), None);
@@ -734,7 +753,10 @@ mod tests {
734753
);
735754
assert!(lane.used_percent.is_none_or(|p| (0.0..=100.0).contains(&p)));
736755
}
737-
println!("Qwen Cloud: plan {:?}, credits {:?}", snap.plan, snap.credits);
756+
println!(
757+
"Qwen Cloud: plan {:?}, credits {:?}",
758+
snap.plan, snap.credits
759+
);
738760
assert!(
739761
snap.primary.is_some() || snap.secondary.is_some(),
740762
"no lane at all"

0 commit comments

Comments
 (0)