├── images └── install.png ├── activitywatch-ls ├── README.md ├── Cargo.toml └── src │ └── main.rs ├── Cargo.toml ├── dist-workspace.toml ├── .gitignore ├── LICENSE ├── extension.toml ├── README.md ├── src └── lib.rs └── .github └── workflows └── release.yml /images/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sachk/aw-watcher-zed/HEAD/images/install.png -------------------------------------------------------------------------------- /activitywatch-ls/README.md: -------------------------------------------------------------------------------- 1 | # activitywatch-ls 2 | 3 | A language server for sending [ActivityWatch](https://activitywatch.net/) heartbeats. Intended to be used for editors with no other way of supporting a watcher. 4 | -------------------------------------------------------------------------------- /activitywatch-ls/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "activitywatch-ls" 3 | version.workspace = true 4 | edition = "2021" 5 | repository = "https://github.com/sachk/aw-watcher-zed" 6 | 7 | 8 | [dependencies] 9 | chrono = "0.4" 10 | clap = "4.5" 11 | serde = "1.0" 12 | serde_json = "1.0" 13 | tokio = { version = "1.44", features = ["rt", "io-std", "macros" ] } 14 | tower-lsp = "0.20" 15 | url = "2.5" 16 | aw-client-rust = { git = "https://github.com/sachk/aw-server-rust", branch = "master" } 17 | arc-swap = "1.7" 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "aw-watcher-zed" 3 | version.workspace = true 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = ["cdylib"] 8 | 9 | [dependencies] 10 | zed_extension_api = "0.0.6" 11 | serde = { version = "1.0", features = ["derive"] } 12 | 13 | [workspace] 14 | members = ["activitywatch-ls"] 15 | 16 | [workspace.package] 17 | version = "0.1.2" 18 | 19 | [profile.release] 20 | lto = "thin" 21 | strip = true # Automatically strip symbols from the binary. 22 | opt-level = "s" # Optimize for size. 23 | 24 | [profile.dist] 25 | inherits = "release" 26 | 27 | [package.metadata.dist] 28 | dist = false 29 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:activitywatch-ls"] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.27.0" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = [] 12 | # Target platforms to build apps for (Rust target-triple syntax) 13 | targets = [ 14 | "aarch64-apple-darwin", 15 | "aarch64-pc-windows-msvc", 16 | "aarch64-unknown-linux-gnu", 17 | "x86_64-apple-darwin", 18 | "x86_64-unknown-linux-gnu", 19 | "x86_64-pc-windows-msvc", 20 | ] 21 | unix-archive = ".zip" 22 | windows-archive = ".zip" 23 | 24 | [workspace.metadata.dist.dependencies.apt] 25 | gcc-aarch64-linux-gnu = { version = '*', targets = [ 26 | "aarch64-unknown-linux-gnu", 27 | ] } 28 | 29 | [target.aarch64-unknown-linux-gnu] 30 | linker = "aarch64-linux-gnu-gcc" 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | #.idea/ 22 | 23 | # Zed's generated extension.wasm 24 | *.wasm 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sacha Korban 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extension.toml: -------------------------------------------------------------------------------- 1 | id = "activitywatch" 2 | name = "activitywatch" 3 | description = "ActivityWatch watcher support" 4 | version = "0.1.2" 5 | schema_version = 1 6 | authors = ["Sacha Korban "] 7 | repository = "https://github.com/sachk/aw-watcher-zed" 8 | 9 | [language_servers.activitywatch] 10 | name = "activitywatch" 11 | languages = [ 12 | "AsciiDoc", 13 | "Astro", 14 | "Bash", 15 | "Biome", 16 | "C", 17 | "C++", 18 | "Clojure", 19 | "CSharp", 20 | "CSS", 21 | "CSV", 22 | "D", 23 | "Dart", 24 | "Elixir", 25 | "Elm", 26 | "Erlang", 27 | "Fish", 28 | "FSharp", 29 | "GDScript", 30 | "Gleam", 31 | "Go", 32 | "GraphQL", 33 | "Haskell", 34 | "HEEX", 35 | "HTML", 36 | "Hy", 37 | "Idris", 38 | "Java", 39 | "JavaScript", 40 | "JSON", 41 | "JSONC", 42 | "Julia", 43 | "Kotlin", 44 | "Lua", 45 | "Markdown", 46 | "Nim", 47 | "Nix", 48 | "OCaml", 49 | "PureScript", 50 | "Python", 51 | "Racket", 52 | "Roc", 53 | "Ruby", 54 | "Rust", 55 | "Scheme", 56 | "SCSS", 57 | "Shell Script", 58 | "SQL", 59 | "Swift", 60 | "TOML", 61 | "TSX", 62 | "TypeScript", 63 | "WIT", 64 | "XML", 65 | "YAML", 66 | "Zig", 67 | ] 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aw-watcher-zed 2 | This extension allows [ActivityWatch](https://activitywatch.net/), the free and open-source time tracker, to track what you are doing when using the [Zed](https://zed.dev/) editor. 3 | 4 | ## Installation 5 | Search for activitywatch on Zed's extension page, (accessible from the command palette) and press install 6 | ![Plugin install page with the search box containing "activity"](./images/install.png) 7 | 8 | ## Configuration 9 | This extension defaults to connecting to an ActivityWatch server at `http://127.0.0.1:5600`. To connect to a different server specify it by adding this to your Zed configuration. 10 | ```json 11 | "lsp": { 12 | "activitywatch": { 13 | "settings": { 14 | "host": "192.168.0.10" 15 | "port": 5609 16 | } 17 | } 18 | } 19 | ``` 20 | ## Implementation Details 21 | Uses the [activitywatch-ls](https://github.com/sachk/aw-watcher-zed/tree/main/activitywatch-ls) to receive edit events from Zed and send hearbeats to an ActivityWatch server using the [aw-client-rust](https://github.com/ActivityWatch/aw-server-rust/tree/master/aw-client-rust) library. 22 | 23 | This is a not very nice hacky approach with a few issues, such as langauges not hardcoded into the extension in extension.toml not working and time looking at a file before changes not being counted without a complex workaround. 24 | 25 | This plugin is heavily based on [the WakaTime plugin](https://github.com/wakatime/zed-wakatime) for Zed, thanks to them for making this a lot easier to figure out. 26 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use serde::Deserialize; 4 | use zed_extension_api::{ 5 | self as zed, serde_json, settings::LspSettings, Command, LanguageServerId, Result, Worktree, 6 | }; 7 | 8 | #[derive(Deserialize)] 9 | struct Configuration { 10 | host: Option, 11 | port: Option, 12 | } 13 | struct ActivityWatchExtension { 14 | cached_ls_binary_path: Option, 15 | } 16 | 17 | impl ActivityWatchExtension { 18 | fn target_triple(&self) -> Result { 19 | let (platform, arch) = zed::current_platform(); 20 | let (arch, os) = { 21 | let arch = match arch { 22 | zed::Architecture::Aarch64 => "aarch64", 23 | zed::Architecture::X8664 => "x86_64", 24 | _ => return Err(format!("unsupported architecture: {arch:?}")), 25 | }; 26 | 27 | let os = match platform { 28 | zed::Os::Mac => "apple-darwin", 29 | zed::Os::Linux => "unknown-linux-gnu", 30 | zed::Os::Windows => "pc-windows-msvc", 31 | }; 32 | 33 | (arch, os) 34 | }; 35 | 36 | Ok(format!("activitywatch-ls-{arch}-{os}")) 37 | } 38 | 39 | fn download(&self, language_server_id: &LanguageServerId, repo: &str) -> Result { 40 | let release = zed::latest_github_release( 41 | repo, 42 | zed::GithubReleaseOptions { 43 | require_assets: true, 44 | pre_release: false, 45 | }, 46 | )?; 47 | 48 | let target_triple = self.target_triple()?; 49 | 50 | let asset_name = format!("{target_triple}.zip"); 51 | let asset = release 52 | .assets 53 | .iter() 54 | .find(|asset| asset.name == asset_name) 55 | .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?; 56 | 57 | let version_dir = format!("activitywatch-ls-{}", release.version); 58 | let binary_path = format!("{version_dir}/activitywatch-ls"); 59 | if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) { 60 | zed::set_language_server_installation_status( 61 | language_server_id, 62 | &zed::LanguageServerInstallationStatus::Downloading, 63 | ); 64 | 65 | zed::download_file( 66 | &asset.download_url, 67 | &version_dir, 68 | zed::DownloadedFileType::Zip, 69 | ) 70 | .map_err(|err| format!("failed to download file: {err}"))?; 71 | 72 | // Delete old versions 73 | // TODO: investigate why this seems to not be working locally 74 | let entries = fs::read_dir(".") 75 | .map_err(|err| format!("failed to list working directory {err}"))?; 76 | for entry in entries { 77 | let entry = entry.map_err(|err| format!("failed to load directory entry {err}"))?; 78 | if let Some(file_name) = entry.file_name().to_str() { 79 | if file_name.starts_with("activitywatch-ls") && file_name != version_dir { 80 | fs::remove_dir_all(entry.path()).ok(); 81 | } 82 | } 83 | } 84 | } 85 | 86 | zed::make_file_executable(&binary_path)?; 87 | 88 | Ok(binary_path) 89 | } 90 | 91 | fn language_server_binary_path( 92 | &mut self, 93 | language_server_id: &LanguageServerId, 94 | worktree: &Worktree, 95 | ) -> Result { 96 | zed::set_language_server_installation_status( 97 | language_server_id, 98 | &zed::LanguageServerInstallationStatus::CheckingForUpdate, 99 | ); 100 | 101 | if let Some(path) = worktree.which("activitywatch-ls") { 102 | return Ok(path.clone()); 103 | } 104 | 105 | let target_triple = self.target_triple()?; 106 | if let Some(path) = worktree.which(&target_triple) { 107 | return Ok(path.clone()); 108 | } 109 | 110 | if let Some(path) = &self.cached_ls_binary_path { 111 | if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { 112 | return Ok(path.clone()); 113 | } 114 | } 115 | 116 | let binary_path = self.download(language_server_id, "sachk/aw-watcher-zed")?; 117 | 118 | self.cached_ls_binary_path = Some(binary_path.clone()); 119 | 120 | Ok(binary_path) 121 | } 122 | } 123 | 124 | impl zed::Extension for ActivityWatchExtension { 125 | fn new() -> Self { 126 | Self { 127 | cached_ls_binary_path: None, 128 | } 129 | } 130 | 131 | fn language_server_command( 132 | &mut self, 133 | language_server_id: &LanguageServerId, 134 | worktree: &Worktree, 135 | ) -> Result { 136 | let lsp_settings = 137 | LspSettings::for_worktree(language_server_id.to_string().as_str(), worktree)?; 138 | 139 | let mut args = Vec::new(); 140 | if let Some(settings) = lsp_settings.settings { 141 | match serde_json::from_value::(settings) { 142 | Ok(config) => { 143 | if let Some(host) = config.host { 144 | args.push("--host".to_string()); 145 | args.push(host); 146 | } 147 | if let Some(port) = config.port { 148 | args.push("--port".to_string()); 149 | args.push(port.to_string()); 150 | } 151 | } 152 | Err(e) => { 153 | return Err(format!("Error pasrsing settings (make sure port is a number and host is a string): {e:#?}")); 154 | } 155 | }; 156 | }; 157 | 158 | let ls_binary_path = self.language_server_binary_path(language_server_id, worktree)?; 159 | 160 | Ok(Command { 161 | args, 162 | command: ls_binary_path, 163 | env: worktree.shell_env(), 164 | }) 165 | } 166 | } 167 | 168 | zed::register_extension!(ActivityWatchExtension); 169 | -------------------------------------------------------------------------------- /activitywatch-ls/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc}; 2 | 3 | use arc_swap::ArcSwap; 4 | use aw_client_rust::AwClient; 5 | use chrono::{DateTime, Local, TimeDelta}; 6 | use clap::{value_parser, Arg, Command}; 7 | use serde_json::Value; 8 | use tokio::sync::Mutex; 9 | use tower_lsp::{jsonrpc, lsp_types::*, Client, LanguageServer, LspService, Server}; 10 | 11 | #[derive(Default, Debug)] 12 | struct Event { 13 | uri: String, 14 | is_write: bool, 15 | language: Option, 16 | } 17 | 18 | #[derive(Debug)] 19 | struct CurrentFile { 20 | uri: String, 21 | timestamp: DateTime, 22 | } 23 | 24 | struct ActivityWatchLanguageServer { 25 | client: Client, 26 | current_file: Mutex, 27 | aw_client: AwClient, 28 | bucket_id: String, 29 | file_languages: Mutex>, 30 | project: ArcSwap>, 31 | } 32 | 33 | impl ActivityWatchLanguageServer { 34 | async fn send(&self, event: Event) { 35 | // if isWrite is false, and file has not changed since last heartbeat, 36 | // and it has been less than 1 second since the last heartbeat do nothing 37 | const INTERVAL: TimeDelta = TimeDelta::seconds(1); 38 | 39 | let mut current_file = self.current_file.lock().await; 40 | let now = Local::now(); 41 | 42 | if event.uri == current_file.uri 43 | && now - current_file.timestamp < INTERVAL 44 | && event.is_write 45 | { 46 | return; 47 | } 48 | 49 | let mut data = serde_json::Map::new(); 50 | data.insert("file".to_string(), Value::String(event.uri.clone())); 51 | let language = match event.language { 52 | Some(l) => Some(l), 53 | None => self.file_languages.lock().await.get(&event.uri).cloned(), 54 | }; 55 | 56 | if let Some(project) = (**self.project.load()).as_ref() { 57 | data.insert("project".to_string(), Value::String(project.clone())); 58 | } 59 | 60 | if let Some(language) = language { 61 | data.insert("language".to_string(), Value::String(language)); 62 | } 63 | 64 | // Duration 0 because heartbeats https://docs.activitywatch.net/en/latest/buckets-and-events.html#id7 65 | // https://github.com/ActivityWatch/aw-watcher-vscode/blob/36093d4ac133f04363f144bdfefa4523f8e8f25f/src/extension.ts#L139 66 | let aw_event = aw_client_rust::Event::new(now.to_utc(), TimeDelta::zero(), data); 67 | 68 | const PULSETIME: f64 = 60_f64; 69 | if let Err(e) = self 70 | .aw_client 71 | .heartbeat(&self.bucket_id, &aw_event, PULSETIME) 72 | .await 73 | { 74 | eprintln!("Received error trying to send a heartbeat to the server: {e:?}"); 75 | } 76 | 77 | current_file.uri = event.uri; 78 | current_file.timestamp = now; 79 | } 80 | } 81 | 82 | #[tower_lsp::async_trait] 83 | impl LanguageServer for ActivityWatchLanguageServer { 84 | async fn initialize(&self, params: InitializeParams) -> jsonrpc::Result { 85 | if let Some(folders) = params.workspace_folders { 86 | if let Some(folder) = folders.get(0) { 87 | let path = folder.uri.path().to_string(); 88 | self.project.swap(Arc::new(Some(path))); 89 | } 90 | } 91 | Ok(InitializeResult { 92 | server_info: Some(ServerInfo { 93 | name: env!("CARGO_PKG_NAME").to_string(), 94 | version: Some(env!("CARGO_PKG_VERSION").to_string()), 95 | }), 96 | capabilities: ServerCapabilities { 97 | text_document_sync: Some(TextDocumentSyncCapability::Kind( 98 | TextDocumentSyncKind::INCREMENTAL, 99 | )), 100 | ..Default::default() 101 | }, 102 | }) 103 | } 104 | 105 | async fn initialized(&self, _params: InitializedParams) { 106 | self.client 107 | .log_message( 108 | MessageType::INFO, 109 | "ActivityWatch language server initialized", 110 | ) 111 | .await; 112 | } 113 | 114 | async fn shutdown(&self) -> jsonrpc::Result<()> { 115 | Ok(()) 116 | } 117 | 118 | // Note that zed (and probably other editors) do this not when a file is in the foreground 119 | // but as soon as it is opened, which makes sense but is annoying for us. 120 | // Reporting the time between when a file is foregrounded and a change is made would require 121 | // us to look at a whole bunch of other events or something bleh. 122 | async fn did_open(&self, params: DidOpenTextDocumentParams) { 123 | let event = Event { 124 | uri: params.text_document.uri[url::Position::BeforeUsername..].to_string(), 125 | is_write: false, 126 | language: Some(params.text_document.language_id.clone()), 127 | }; 128 | 129 | // This is a minor memory leak and ideally we'd look for close events 130 | // to remove entries 131 | self.file_languages 132 | .lock() 133 | .await 134 | .insert(event.uri.clone(), params.text_document.language_id); 135 | 136 | // TODO: keep tabs on whether or not to do this 137 | // self.send(event).await; 138 | } 139 | 140 | async fn did_change(&self, params: DidChangeTextDocumentParams) { 141 | let event = Event { 142 | uri: params.text_document.uri[url::Position::BeforeUsername..].to_string(), 143 | is_write: false, 144 | language: None, 145 | }; 146 | 147 | self.send(event).await; 148 | } 149 | 150 | async fn did_save(&self, params: DidSaveTextDocumentParams) { 151 | let event = Event { 152 | uri: params.text_document.uri[url::Position::BeforeUsername..].to_string(), 153 | is_write: true, 154 | language: None, 155 | }; 156 | 157 | self.send(event).await; 158 | } 159 | } 160 | 161 | #[tokio::main(flavor = "current_thread")] 162 | async fn main() { 163 | let matches = Command::new("activitywatch_ls") 164 | .version(env!("CARGO_PKG_VERSION")) 165 | .author("Sacha Korban ") 166 | .about("A simple ActivityWatch language server watcher") 167 | .arg( 168 | Arg::new("host") 169 | .short('a') 170 | .long("host") 171 | .help("The host of the ActivityWatch server to connect to") 172 | .required(false) 173 | .default_value("localhost"), 174 | ) 175 | .arg( 176 | Arg::new("port") 177 | .value_parser(value_parser!(u16)) 178 | .short('p') 179 | .long("port") 180 | .help("The ActivityWatch server port to connect to on the host") 181 | .required(false) 182 | .default_value("5600"), 183 | ) 184 | .get_matches(); 185 | 186 | // Note that AwClient does not support https 187 | // TODO: this sucks and i hate the alternatives too lol 188 | let host: &String = matches.get_one("host").unwrap(); 189 | let port: &u16 = matches.get_one("port").unwrap(); 190 | 191 | const CLIENT_NAME: &str = "aw-watcher-zed"; 192 | let aw_client = match AwClient::new(host, *port, CLIENT_NAME) { 193 | Ok(c) => c, 194 | Err(e) => { 195 | eprintln!("Could not connect to ActivityWatch Server, received error {e:?}"); 196 | return; 197 | } 198 | }; 199 | 200 | let bucket_id = format!("{CLIENT_NAME}-bucket_{}", aw_client.hostname); 201 | if let Err(e) = aw_client 202 | .create_bucket_simple(&bucket_id, "app.editor.activity") 203 | .await 204 | { 205 | eprintln!("Could not create ActivityWatch bucket, received error {e:?}"); 206 | return; 207 | }; 208 | 209 | let stdin = tokio::io::stdin(); 210 | let stdout = tokio::io::stdout(); 211 | 212 | let (service, socket) = LspService::new(|client| { 213 | Arc::new(ActivityWatchLanguageServer { 214 | client, 215 | current_file: Mutex::new(CurrentFile { 216 | uri: String::new(), 217 | timestamp: Local::now(), 218 | }), 219 | aw_client, 220 | bucket_id, 221 | file_languages: Mutex::new(HashMap::new()), 222 | project: ArcSwap::from_pointee(None), 223 | }) 224 | }); 225 | 226 | Server::new(stdin, stdout, socket).serve(service).await; 227 | } 228 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ 2 | # 3 | # Copyright 2022-2024, axodotdev 4 | # SPDX-License-Identifier: MIT or Apache-2.0 5 | # 6 | # CI that: 7 | # 8 | # * checks for a Git Tag that looks like a release 9 | # * builds artifacts with dist (archives, installers, hashes) 10 | # * uploads those artifacts to temporary workflow zip 11 | # * on success, uploads the artifacts to a GitHub Release 12 | # 13 | # Note that the GitHub Release will be created with a generated 14 | # title/body based on your changelogs. 15 | 16 | name: Release 17 | permissions: 18 | "contents": "write" 19 | 20 | # This task will run whenever you push a git tag that looks like a version 21 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 22 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 23 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 24 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 25 | # 26 | # If PACKAGE_NAME is specified, then the announcement will be for that 27 | # package (erroring out if it doesn't have the given version or isn't dist-able). 28 | # 29 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 30 | # (dist-able) packages in the workspace with that version (this mode is 31 | # intended for workspaces with only one dist-able package, or with all dist-able 32 | # packages versioned/released in lockstep). 33 | # 34 | # If you push multiple tags at once, separate instances of this workflow will 35 | # spin up, creating an independent announcement for each one. However, GitHub 36 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 37 | # mistake. 38 | # 39 | # If there's a prerelease-style suffix to the version, then the release(s) 40 | # will be marked as a prerelease. 41 | on: 42 | pull_request: 43 | push: 44 | tags: 45 | - '**[0-9]+.[0-9]+.[0-9]+*' 46 | 47 | jobs: 48 | # Run 'dist plan' (or host) to determine what tasks we need to do 49 | plan: 50 | runs-on: "ubuntu-20.04" 51 | outputs: 52 | val: ${{ steps.plan.outputs.manifest }} 53 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 54 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 55 | publishing: ${{ !github.event.pull_request }} 56 | env: 57 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | with: 61 | submodules: recursive 62 | - name: Install dist 63 | # we specify bash to get pipefail; it guards against the `curl` command 64 | # failing. otherwise `sh` won't catch that `curl` returned non-0 65 | shell: bash 66 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.27.0/cargo-dist-installer.sh | sh" 67 | - name: Cache dist 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: cargo-dist-cache 71 | path: ~/.cargo/bin/dist 72 | # sure would be cool if github gave us proper conditionals... 73 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 74 | # functionality based on whether this is a pull_request, and whether it's from a fork. 75 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 76 | # but also really annoying to build CI around when it needs secrets to work right.) 77 | - id: plan 78 | run: | 79 | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 80 | echo "dist ran successfully" 81 | cat plan-dist-manifest.json 82 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 83 | - name: "Upload dist-manifest.json" 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: artifacts-plan-dist-manifest 87 | path: plan-dist-manifest.json 88 | 89 | # Build and packages all the platform-specific things 90 | build-local-artifacts: 91 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 92 | # Let the initial task tell us to not run (currently very blunt) 93 | needs: 94 | - plan 95 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 96 | strategy: 97 | fail-fast: false 98 | # Target platforms/runners are computed by dist in create-release. 99 | # Each member of the matrix has the following arguments: 100 | # 101 | # - runner: the github runner 102 | # - dist-args: cli flags to pass to dist 103 | # - install-dist: expression to run to install dist on the runner 104 | # 105 | # Typically there will be: 106 | # - 1 "global" task that builds universal installers 107 | # - N "local" tasks that build each platform's binaries and platform-specific installers 108 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 109 | runs-on: ${{ matrix.runner }} 110 | container: ${{ matrix.container && matrix.container.image || null }} 111 | env: 112 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 113 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 114 | steps: 115 | - name: enable windows longpaths 116 | run: | 117 | git config --global core.longpaths true 118 | - uses: actions/checkout@v4 119 | with: 120 | submodules: recursive 121 | - name: Install Rust non-interactively if not already installed 122 | if: ${{ matrix.container }} 123 | run: | 124 | if ! command -v cargo > /dev/null 2>&1; then 125 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 126 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 127 | fi 128 | - name: Install dist 129 | run: ${{ matrix.install_dist.run }} 130 | # Get the dist-manifest 131 | - name: Fetch local artifacts 132 | uses: actions/download-artifact@v4 133 | with: 134 | pattern: artifacts-* 135 | path: target/distrib/ 136 | merge-multiple: true 137 | - name: Install dependencies 138 | run: | 139 | ${{ matrix.packages_install }} 140 | - name: Build artifacts 141 | run: | 142 | # Actually do builds and make zips and whatnot 143 | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 144 | echo "dist ran successfully" 145 | - id: cargo-dist 146 | name: Post-build 147 | # We force bash here just because github makes it really hard to get values up 148 | # to "real" actions without writing to env-vars, and writing to env-vars has 149 | # inconsistent syntax between shell and powershell. 150 | shell: bash 151 | run: | 152 | # Parse out what we just built and upload it to scratch storage 153 | echo "paths<> "$GITHUB_OUTPUT" 154 | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" 155 | echo "EOF" >> "$GITHUB_OUTPUT" 156 | 157 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 158 | - name: "Upload artifacts" 159 | uses: actions/upload-artifact@v4 160 | with: 161 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 162 | path: | 163 | ${{ steps.cargo-dist.outputs.paths }} 164 | ${{ env.BUILD_MANIFEST_NAME }} 165 | 166 | # Build and package all the platform-agnostic(ish) things 167 | build-global-artifacts: 168 | needs: 169 | - plan 170 | - build-local-artifacts 171 | runs-on: "ubuntu-20.04" 172 | env: 173 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 174 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 175 | steps: 176 | - uses: actions/checkout@v4 177 | with: 178 | submodules: recursive 179 | - name: Install cached dist 180 | uses: actions/download-artifact@v4 181 | with: 182 | name: cargo-dist-cache 183 | path: ~/.cargo/bin/ 184 | - run: chmod +x ~/.cargo/bin/dist 185 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 186 | - name: Fetch local artifacts 187 | uses: actions/download-artifact@v4 188 | with: 189 | pattern: artifacts-* 190 | path: target/distrib/ 191 | merge-multiple: true 192 | - id: cargo-dist 193 | shell: bash 194 | run: | 195 | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 196 | echo "dist ran successfully" 197 | 198 | # Parse out what we just built and upload it to scratch storage 199 | echo "paths<> "$GITHUB_OUTPUT" 200 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 201 | echo "EOF" >> "$GITHUB_OUTPUT" 202 | 203 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 204 | - name: "Upload artifacts" 205 | uses: actions/upload-artifact@v4 206 | with: 207 | name: artifacts-build-global 208 | path: | 209 | ${{ steps.cargo-dist.outputs.paths }} 210 | ${{ env.BUILD_MANIFEST_NAME }} 211 | # Determines if we should publish/announce 212 | host: 213 | needs: 214 | - plan 215 | - build-local-artifacts 216 | - build-global-artifacts 217 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 218 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 219 | env: 220 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 221 | runs-on: "ubuntu-20.04" 222 | outputs: 223 | val: ${{ steps.host.outputs.manifest }} 224 | steps: 225 | - uses: actions/checkout@v4 226 | with: 227 | submodules: recursive 228 | - name: Install cached dist 229 | uses: actions/download-artifact@v4 230 | with: 231 | name: cargo-dist-cache 232 | path: ~/.cargo/bin/ 233 | - run: chmod +x ~/.cargo/bin/dist 234 | # Fetch artifacts from scratch-storage 235 | - name: Fetch artifacts 236 | uses: actions/download-artifact@v4 237 | with: 238 | pattern: artifacts-* 239 | path: target/distrib/ 240 | merge-multiple: true 241 | - id: host 242 | shell: bash 243 | run: | 244 | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 245 | echo "artifacts uploaded and released successfully" 246 | cat dist-manifest.json 247 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 248 | - name: "Upload dist-manifest.json" 249 | uses: actions/upload-artifact@v4 250 | with: 251 | # Overwrite the previous copy 252 | name: artifacts-dist-manifest 253 | path: dist-manifest.json 254 | # Create a GitHub Release while uploading all files to it 255 | - name: "Download GitHub Artifacts" 256 | uses: actions/download-artifact@v4 257 | with: 258 | pattern: artifacts-* 259 | path: artifacts 260 | merge-multiple: true 261 | - name: Cleanup 262 | run: | 263 | # Remove the granular manifests 264 | rm -f artifacts/*-dist-manifest.json 265 | - name: Create GitHub Release 266 | env: 267 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 268 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 269 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 270 | RELEASE_COMMIT: "${{ github.sha }}" 271 | run: | 272 | # Write and read notes from a file to avoid quoting breaking things 273 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 274 | 275 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 276 | 277 | announce: 278 | needs: 279 | - plan 280 | - host 281 | # use "always() && ..." to allow us to wait for all publish jobs while 282 | # still allowing individual publish jobs to skip themselves (for prereleases). 283 | # "host" however must run to completion, no skipping allowed! 284 | if: ${{ always() && needs.host.result == 'success' }} 285 | runs-on: "ubuntu-20.04" 286 | env: 287 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 288 | steps: 289 | - uses: actions/checkout@v4 290 | with: 291 | submodules: recursive 292 | --------------------------------------------------------------------------------