5 Commits

Author SHA1 Message Date
forust a582913269 chore: release v1.1.3
release / verify (push) Successful in 25s
ci / rust (push) Successful in 47s
ci / lint-yaml (push) Successful in 5s
release / linux-amd64 (push) Successful in 36s
2026-07-03 23:49:01 +02:00
forust 911aaca355 fix: expose gitea token in release workflow
ci / rust (push) Successful in 46s
ci / lint-yaml (push) Successful in 5s
2026-07-03 23:44:22 +02:00
forust 189ae75a01 chore: release v1.1.2
ci / rust (push) Successful in 43s
ci / lint-yaml (push) Successful in 4s
release / linux-amd64 (push) Failing after 29s
2026-07-03 23:39:47 +02:00
forust c39fa10c9c fix: use explicit gitea release api
ci / rust (push) Successful in 42s
ci / lint-yaml (push) Successful in 6s
2026-07-03 16:00:10 +02:00
forust 5c9a349114 docs: add agent instructions
ci / rust (push) Successful in 47s
ci / lint-yaml (push) Successful in 5s
2026-07-03 15:53:22 +02:00
8 changed files with 393 additions and 37 deletions
+36 -5
View File
@@ -10,8 +10,38 @@ permissions:
contents: write contents: write
jobs: jobs:
linux-amd64: verify:
runs-on: [self-hosted, linux, arch, homelab] runs-on: [self-hosted, linux, arch, homelab]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check formatting
shell: bash
run: cargo fmt --check
- name: Run tests
shell: bash
run: cargo test --locked
- name: Run clippy
shell: bash
run: cargo clippy --locked -- -D warnings
- name: Lint YAML
shell: bash
run: |
docker run --rm \
-v "$PWD:/work" \
-w /work \
cytopia/yamllint:latest \
-c .yamllint .
linux-amd64:
needs: verify
runs-on: [self-hosted, linux, arch, homelab]
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -35,7 +65,7 @@ jobs:
shell: bash shell: bash
run: | run: |
tag="${GITHUB_REF##*/}" tag="${GITHUB_REF##*/}"
release_url="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases" release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases"
existing="$(curl -fsS \ existing="$(curl -fsS \
"${release_url}/tags/${tag}" \ "${release_url}/tags/${tag}" \
-H "Authorization: token ${GITEA_TOKEN}" || true)" -H "Authorization: token ${GITEA_TOKEN}" || true)"
@@ -68,20 +98,21 @@ jobs:
- name: Upload Gitea release assets - name: Upload Gitea release assets
shell: bash shell: bash
run: | run: |
release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases"
release_id="${{ steps.release.outputs.release_id }}" release_id="${{ steps.release.outputs.release_id }}"
for asset in dist/*.tar.gz dist/*.sha256; do for asset in dist/*.tar.gz dist/*.sha256; do
name="$(basename "$asset")" name="$(basename "$asset")"
release="$(curl -fsS \ release="$(curl -fsS \
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ "${release_url}/${release_id}" \
-H "Authorization: token ${GITEA_TOKEN}")" -H "Authorization: token ${GITEA_TOKEN}")"
asset_id="$(printf '%s' "$release" | jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' | head -n 1)" asset_id="$(printf '%s' "$release" | jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' | head -n 1)"
if [ -n "$asset_id" ]; then if [ -n "$asset_id" ]; then
curl -fsS \ curl -fsS \
-X DELETE "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets/${asset_id}" \ -X DELETE "${release_url}/${release_id}/assets/${asset_id}" \
-H "Authorization: token ${GITEA_TOKEN}" -H "Authorization: token ${GITEA_TOKEN}"
fi fi
curl -fsS \ curl -fsS \
-X POST "${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?name=${name}" \ -X POST "${release_url}/${release_id}/assets?name=${name}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${asset}" -F "attachment=@${asset}"
done done
+143
View File
@@ -0,0 +1,143 @@
# AGENTS.md
Instructions for coding agents working in this repository.
## Project Shape
`gosleep-timer` is a Rust-first, single-binary Linux terminal app.
- Main code: `src/main.rs`
- Package metadata: `Cargo.toml`
- Locked dependency graph: `Cargo.lock`
- Version files: `Cargo.toml`, `Cargo.lock`, `VERSION`, `CHANGELOG.md`
- CI: `.gitea/workflows/ci.yaml`
- Release workflow: `.gitea/workflows/release.yaml`
- Release docs: `RELEASING.md`
Do not reintroduce Go, Python, Node, Docker packaging, or multi-language runtime dependencies unless the user explicitly asks for that migration.
## Required Checks
Run these before committing code changes:
```bash
cargo fmt --check
cargo test --locked
cargo clippy --locked -- -D warnings
cargo build --release --locked
```
Run YAML lint when editing workflow or YAML files:
```bash
yamllint -c .yamllint .
```
If `yamllint` is not installed, the CI-compatible fallback is:
```bash
docker run --rm -v "$PWD:/work" -w /work cytopia/yamllint:latest -c .yamllint .
```
## Local Smoke Tests
Use a temporary config path so local tests do not mutate the user's real config:
```bash
tmpdir="$(mktemp -d)"
cargo run -- --config "$tmpdir/config.yaml" init
cargo run -- --config "$tmpdir/config.yaml" preview
```
For `run` smoke tests, disable actions first so the command does not call `playerctl`, `systemctl`, `pkill`, workspace tools, or custom shell commands.
## Versioning
The project uses semantic versioning. Tags must be annotated and formatted as `vMAJOR.MINOR.PATCH`.
When bumping a version, update all of:
- `Cargo.toml`
- `Cargo.lock`
- `VERSION`
- `CHANGELOG.md`
Verify version consistency with:
```bash
test "$(cat VERSION)" = "$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')"
```
## Releases
Release workflow is tag-based:
```bash
git tag -a v1.1.1 -m "gosleep-timer v1.1.1"
git push origin main
git push origin v1.1.1
```
The Gitea release workflow:
- builds `target/release/gosleep-timer`
- creates `gosleep-timer-<version>-linux-amd64.tar.gz`
- writes a `.sha256`
- creates or reuses the matching Gitea release
- replaces release assets with matching names
The workflow uses the built-in `GITEA_TOKEN` and declares `permissions: contents: write`.
## Git Remote
Expected upstream:
```text
origin ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git
```
HTTPS remote URL:
```text
https://gitea.forust.xyz/forust/gosleep.git
```
Before pushing, confirm:
```bash
git status --short --branch
git branch -vv
git remote -v
```
## Repository Hygiene
- Keep `target/`, `dist/`, `.env*`, logs, temp files, and local artifacts out of git.
- Keep `Cargo.lock` committed; this is an application, not a library.
- Prefer small, direct Rust changes in `src/main.rs` until the code genuinely needs splitting.
- Do not add broad abstractions just to prepare for hypothetical modules.
- Do not add shelling out during tests unless the test fully controls the command.
- Keep docs synchronized with actual behavior and command names.
- Keep release assets and generated archives out of the repo.
## TUI Notes
The TUI uses Ratatui and Crossterm. Be careful with terminal-size assumptions.
For layout changes, check both:
- narrow terminal behavior, where settings and preview stack vertically
- wide terminal behavior, where settings and preview are side by side
Avoid truncating command previews without a visible indication. Preview wrapping is expected behavior.
## Safety
The app can run destructive or disruptive post-countdown commands:
- `pkill`
- `systemctl poweroff`
- `systemctl reboot`
- arbitrary custom shell commands
Never run `gosleep-timer run` against the real user config during verification unless the user explicitly asks for it. Use an isolated config and disable actions for smoke tests.
+16
View File
@@ -4,6 +4,20 @@ All notable changes to this project are documented here.
The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`. The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`.
## [1.1.3] - 2026-07-03
### Fixed
- Made the tag-based release workflow fail closed: lint and test gates now run before build and publish.
- Exposed Gitea Actions' built-in `${{ secrets.GITEA_TOKEN }}` to the release API steps so release creation and asset upload authenticate correctly.
## [1.1.2] - 2026-07-03
### Fixed
- Suppressed all background `stdout`/`stderr` writes while the Ratatui screen is active, including child command output from timer actions.
- Split `time left` out of the gauge label and reduced the gauge label to a compact percentage for narrow terminal safety.
## [1.1.1] - 2026-07-03 ## [1.1.1] - 2026-07-03
### Changed ### Changed
@@ -13,4 +27,6 @@ The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`
- Added YAML configuration, CLI `init`, `preview`, and `run` commands. - Added YAML configuration, CLI `init`, `preview`, and `run` commands.
- Added Gitea CI and tag-based release workflows. - Added Gitea CI and tag-based release workflows.
[1.1.3]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.3
[1.1.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.2
[1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1 [1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1
Generated
+1 -1
View File
@@ -298,7 +298,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "gosleep-timer" name = "gosleep-timer"
version = "1.1.1" version = "1.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gosleep-timer" name = "gosleep-timer"
version = "1.1.1" version = "1.1.3"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI" description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI"
+8 -6
View File
@@ -40,7 +40,7 @@ https://gitea.forust.xyz/forust/gosleep.git
- `sha256sum` - `sha256sum`
- Docker, if using the YAML lint job as written - Docker, if using the YAML lint job as written
4. Confirm Actions job token permissions allow `contents: write`. 4. Confirm Actions job token permissions allow `contents: write`.
The release workflow uses Gitea Actions' built-in `GITEA_TOKEN`; no extra repository secret is required. The release workflow uses Gitea Actions' built-in `GITEA_TOKEN`, exposed in the workflow as `${{ secrets.GITEA_TOKEN }}`; no extra repository secret is required.
## Version Bump Checklist ## Version Bump Checklist
@@ -78,13 +78,15 @@ git push origin v1.1.2
The `.gitea/workflows/release.yaml` workflow will: The `.gitea/workflows/release.yaml` workflow will:
1. Build `target/release/gosleep-timer`. 1. Re-run release gates: `cargo fmt --check`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and YAML lint.
2. Package `gosleep-timer-<version>-linux-amd64.tar.gz`. 2. Build `target/release/gosleep-timer`.
3. Generate a SHA-256 checksum. 3. Package `gosleep-timer-<version>-linux-amd64.tar.gz`.
4. Create a Gitea release. 4. Generate a SHA-256 checksum.
5. Upload the archive and checksum as release assets. 5. Create a Gitea release.
6. Upload the archive and checksum as release assets.
The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names. The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names.
If any lint or test step fails, the build and release job will not run.
## Manual Asset Build ## Manual Asset Build
+1 -1
View File
@@ -1 +1 @@
1.1.1 1.1.3
+187 -23
View File
@@ -2,7 +2,7 @@ use std::{
fs, fs,
io::{self, Write}, io::{self, Write},
path::PathBuf, path::PathBuf,
process::Command, process::{Command, Stdio},
sync::{ sync::{
Arc, Arc,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -199,7 +199,13 @@ fn main() -> Result<()> {
if let Some(duration) = duration { if let Some(duration) = duration {
config.duration = duration; config.duration = duration;
} }
run_timer(&config, &Arc::new(AtomicBool::new(false))) let mut stdout = io::stdout();
run_timer(
&config,
&Arc::new(AtomicBool::new(false)),
RunMode::Cli,
Some(&mut stdout),
)
} }
Some(Commands::Preview) => { Some(Commands::Preview) => {
for command in preview_commands(&config) { for command in preview_commands(&config) {
@@ -467,42 +473,86 @@ fn preview_commands(config: &Config) -> Vec<String> {
.collect() .collect()
} }
fn run_timer(config: &Config, stop: &Arc<AtomicBool>) -> Result<()> { #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RunMode {
Cli,
Tui,
}
fn write_timer_line(output: &mut dyn Write, message: &str) {
writeln!(output, "{message}").ok();
}
fn write_timer_progress(output: &mut dyn Write, message: &str) {
write!(output, "\r{message}").ok();
output.flush().ok();
}
fn run_timer(
config: &Config,
stop: &Arc<AtomicBool>,
mode: RunMode,
mut output: Option<&mut dyn Write>,
) -> Result<()> {
validate_config(config)?; validate_config(config)?;
let duration = parse_duration(&config.duration)?; let duration = parse_duration(&config.duration)?;
let started = Instant::now(); let started = Instant::now();
println!("timer started: {}", config.duration); if let Some(output) = output.as_deref_mut() {
write_timer_line(output, &format!("timer started: {}", config.duration));
}
loop { loop {
if stop.load(Ordering::Relaxed) { if stop.load(Ordering::Relaxed) {
println!("\nstopped"); if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "stopped");
}
bail!("timer stopped"); bail!("timer stopped");
} }
let elapsed = started.elapsed(); let elapsed = started.elapsed();
if elapsed >= duration { if elapsed >= duration {
break; break;
} }
print!("\rremaining: {}", format_duration(duration - elapsed)); if let Some(output) = output.as_deref_mut() {
io::stdout().flush().ok(); write_timer_progress(
output,
&format!("remaining: {}", format_duration(duration - elapsed)),
);
}
thread::sleep(Duration::from_millis(250)); thread::sleep(Duration::from_millis(250));
} }
println!("\nfinished"); if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "finished");
}
for command in build_action_commands(config) { for command in build_action_commands(config) {
println!("run: {}", command.display()); if let Some(output) = output.as_deref_mut() {
run_command(&command).with_context(|| format!("{} failed", command.label))?; write_timer_line(output, &format!("run: {}", command.display()));
}
run_command(&command, mode).with_context(|| format!("{} failed", command.label))?;
} }
Ok(()) Ok(())
} }
fn run_command(command: &ActionCommand) -> Result<()> { fn prepare_command_stdio(process: &mut Command, mode: RunMode) {
if mode == RunMode::Tui {
process.stdout(Stdio::null()).stderr(Stdio::null());
}
}
fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> {
let status = if let Some(shell) = &command.shell { let status = if let Some(shell) = &command.shell {
Command::new("sh").arg("-c").arg(shell).status()? let mut process = Command::new("sh");
process.arg("-c").arg(shell);
prepare_command_stdio(&mut process, mode);
process.status()?
} else { } else {
let Some(program) = command.args.first() else { let Some(program) = command.args.first() else {
return Ok(()); return Ok(());
}; };
Command::new(program).args(&command.args[1..]).status()? let mut process = Command::new(program);
process.args(&command.args[1..]);
prepare_command_stdio(&mut process, mode);
process.status()?
}; };
if !status.success() { if !status.success() {
bail!("command exited with {status}"); bail!("command exited with {status}");
@@ -601,7 +651,7 @@ impl App {
fn draw(&mut self, frame: &mut Frame) { fn draw(&mut self, frame: &mut Frame) {
let area = frame.area(); let area = frame.area();
if area.width < 36 || area.height < 10 { if area.width < 36 || area.height < 11 {
frame.render_widget(Paragraph::new("terminal too small"), area); frame.render_widget(Paragraph::new("terminal too small"), area);
return; return;
} }
@@ -609,7 +659,7 @@ impl App {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(4), Constraint::Length(5),
Constraint::Min(5), Constraint::Min(5),
Constraint::Length(1), Constraint::Length(1),
]) ])
@@ -633,11 +683,6 @@ impl App {
fn draw_header(&self, frame: &mut Frame, area: Rect) { fn draw_header(&self, frame: &mut Frame, area: Rect) {
let (_, remaining, progress) = self.timer_snapshot(); let (_, remaining, progress) = self.timer_snapshot();
let label = if self.timer.is_some() {
format!("time left: {}", format_duration(remaining))
} else {
"timer not started".to_string()
};
let header = Layout::default() let header = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@@ -645,6 +690,7 @@ impl App {
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1),
]) ])
.split(area); .split(area);
frame.render_widget( frame.render_widget(
@@ -664,12 +710,21 @@ impl App {
Paragraph::new(self.status.clone()).style(Style::default().fg(Color::Yellow)), Paragraph::new(self.status.clone()).style(Style::default().fg(Color::Yellow)),
header[2], header[2],
); );
frame.render_widget(
Paragraph::new(format_time_left_text(
self.timer.as_ref(),
remaining,
header[3].width,
))
.style(Style::default().fg(Color::White)),
header[3],
);
frame.render_widget( frame.render_widget(
Gauge::default() Gauge::default()
.gauge_style(Style::default().fg(Color::Magenta)) .gauge_style(Style::default().fg(Color::Magenta))
.label(format!("{label} {:>3}%", (progress * 100.0).round() as u16)) .label(format_gauge_label(progress))
.ratio(progress), .ratio(progress),
header[3], header[4],
); );
} }
@@ -847,7 +902,7 @@ impl App {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop); let thread_stop = Arc::clone(&stop);
let config = self.config.clone(); let config = self.config.clone();
let handle = thread::spawn(move || run_timer(&config, &thread_stop)); let handle = thread::spawn(move || run_timer(&config, &thread_stop, RunMode::Tui, None));
self.timer = Some(TimerState { self.timer = Some(TimerState {
started: Instant::now(), started: Instant::now(),
duration, duration,
@@ -1090,6 +1145,40 @@ fn format_duration(duration: Duration) -> String {
} }
} }
fn format_compact_duration(duration: Duration) -> String {
let total_seconds = duration.as_secs();
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
if hours > 0 {
format!("{hours}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes:02}:{seconds:02}")
}
}
fn format_time_left_text(timer: Option<&TimerState>, remaining: Duration, width: u16) -> String {
if timer.is_none() {
return "time left: --".to_string();
}
let full = format!("time left: {}", format_duration(remaining));
if full.len() <= width as usize {
return full;
}
let compact = format!("left: {}", format_compact_duration(remaining));
if compact.len() <= width as usize {
return compact;
}
format_compact_duration(remaining)
}
fn format_gauge_label(progress: f64) -> String {
format!("{:>3}%", (progress * 100.0).round() as u16)
}
fn wrap_command(command: &str, width: usize) -> Vec<String> { fn wrap_command(command: &str, width: usize) -> Vec<String> {
let width = width.max(8); let width = width.max(8);
let mut lines = Vec::new(); let mut lines = Vec::new();
@@ -1125,6 +1214,22 @@ fn split_list(value: &str, separator: char) -> Vec<String> {
mod tests { mod tests {
use super::*; use super::*;
fn quiet_config(duration: &str) -> Config {
let mut config = Config {
duration: duration.to_string(),
..Config::default()
};
config.actions.workspace.enabled = false;
config.actions.media.enabled = false;
config.actions.brightness.enabled = false;
config.actions.mute.enabled = false;
config.actions.lock.enabled = false;
config.actions.kill.enabled = false;
config.actions.power.mode = "none".to_string();
config.actions.custom.enabled = false;
config
}
#[test] #[test]
fn parse_duration_accepts_go_style_values() { fn parse_duration_accepts_go_style_values() {
assert!(parse_duration("25m").is_ok()); assert!(parse_duration("25m").is_ok());
@@ -1169,4 +1274,63 @@ mod tests {
vec!["firefox", "telegram-desktop"] vec!["firefox", "telegram-desktop"]
); );
} }
#[test]
fn run_timer_quiet_mode_writes_nothing() {
let stop = Arc::new(AtomicBool::new(false));
let config = quiet_config("1ns");
run_timer(&config, &stop, RunMode::Tui, None).unwrap();
}
#[test]
fn run_timer_cli_mode_writes_status_lines() {
let stop = Arc::new(AtomicBool::new(false));
let config = quiet_config("10ms");
let mut output = Vec::new();
run_timer(&config, &stop, RunMode::Cli, Some(&mut output)).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("timer started: 10ms"));
assert!(text.contains("remaining:"));
assert!(text.contains("finished"));
}
#[test]
fn timer_header_text_compacts_for_narrow_widths() {
let remaining = Duration::from_secs(65);
assert_eq!(format_time_left_text(None, remaining, 20), "time left: --");
assert_eq!(
format_time_left_text(Some(&dummy_timer_state()), remaining, 40),
"time left: 1m5s"
);
assert_eq!(
format_time_left_text(Some(&dummy_timer_state()), remaining, 12),
"left: 01:05"
);
assert_eq!(
format_time_left_text(Some(&dummy_timer_state()), remaining, 5),
"01:05"
);
}
#[test]
fn gauge_label_is_short_percent_only() {
assert_eq!(format_gauge_label(0.0), " 0%");
assert_eq!(format_gauge_label(0.58), " 58%");
assert_eq!(format_gauge_label(1.0), "100%");
}
fn dummy_timer_state() -> TimerState {
let stop = Arc::new(AtomicBool::new(false));
let handle = thread::spawn(|| Ok(()));
TimerState {
started: Instant::now(),
duration: Duration::from_secs(1),
stop,
handle,
}
}
} }