Re-re-fork (#23)

This commit is contained in:
Miles Wirht
2026-03-22 12:21:07 -04:00
committed by GitHub
parent 18aeb44a54
commit de080b0773
246 changed files with 34208 additions and 11176 deletions
+54
View File
@@ -0,0 +1,54 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "gpui-ce-macros"
version = "0.1.0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "gpui-ce-macros"
version = "0.1.0"
edition = "2024"
publish = false
license = "Apache-2.0"
description = "Macros used by gpui"
[features]
inspector = []
[lib]
path = "src/gpui_macros.rs"
proc-macro = true
doctest = true
[dependencies]
heck = "0.5.0"
proc-macro2 = "1.0.106"
quote = "1.0.45"
syn = "2.0.117"
+211
View File
@@ -0,0 +1,211 @@
use crate::register_action::generate_register_action;
use proc_macro::TokenStream;
use proc_macro2::Ident;
use quote::quote;
use syn::{Data, DeriveInput, LitStr, Token, parse::ParseStream};
pub(crate) fn derive_action(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(input as DeriveInput);
let struct_name = &input.ident;
let mut name_argument = None;
let mut deprecated_aliases = Vec::new();
let mut no_json = false;
let mut no_register = false;
let mut namespace = None;
let mut deprecated = None;
let mut doc_str: Option<String> = None;
/*
*
* #[action()]
* Struct Foo {
* bar: bool // is bar considered an attribute
}
*/
for attr in &input.attrs {
if attr.path().is_ident("action") {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
if name_argument.is_some() {
return Err(meta.error("'name' argument specified multiple times"));
}
meta.input.parse::<Token![=]>()?;
let lit: LitStr = meta.input.parse()?;
name_argument = Some(lit.value());
} else if meta.path.is_ident("namespace") {
if namespace.is_some() {
return Err(meta.error("'namespace' argument specified multiple times"));
}
meta.input.parse::<Token![=]>()?;
let ident: Ident = meta.input.parse()?;
namespace = Some(ident.to_string());
} else if meta.path.is_ident("no_json") {
if no_json {
return Err(meta.error("'no_json' argument specified multiple times"));
}
no_json = true;
} else if meta.path.is_ident("no_register") {
if no_register {
return Err(meta.error("'no_register' argument specified multiple times"));
}
no_register = true;
} else if meta.path.is_ident("deprecated_aliases") {
if !deprecated_aliases.is_empty() {
return Err(
meta.error("'deprecated_aliases' argument specified multiple times")
);
}
meta.input.parse::<Token![=]>()?;
// Parse array of string literals
let content;
syn::bracketed!(content in meta.input);
let aliases = content.parse_terminated(
|input: ParseStream| input.parse::<LitStr>(),
Token![,],
)?;
deprecated_aliases.extend(aliases.into_iter().map(|lit| lit.value()));
} else if meta.path.is_ident("deprecated") {
if deprecated.is_some() {
return Err(meta.error("'deprecated' argument specified multiple times"));
}
meta.input.parse::<Token![=]>()?;
let lit: LitStr = meta.input.parse()?;
deprecated = Some(lit.value());
} else {
return Err(meta.error(format!(
"'{:?}' argument not recognized, expected \
'namespace', 'no_json', 'no_register, 'deprecated_aliases', or 'deprecated'",
meta.path
)));
}
Ok(())
})
.unwrap_or_else(|e| panic!("in #[action] attribute: {}", e));
} else if attr.path().is_ident("doc") {
use syn::{Expr::Lit, ExprLit, Lit::Str, Meta, MetaNameValue};
if let Meta::NameValue(MetaNameValue {
value:
Lit(ExprLit {
lit: Str(ref lit_str),
..
}),
..
}) = attr.meta
{
let doc = lit_str.value();
let doc_str = doc_str.get_or_insert_default();
doc_str.push_str(doc.trim());
doc_str.push('\n');
}
}
}
let name = name_argument.unwrap_or_else(|| struct_name.to_string());
if name.contains("::") {
panic!(
"in #[action] attribute: `name = \"{name}\"` must not contain `::`, \
also specify `namespace` instead"
);
}
let full_name = if let Some(namespace) = namespace {
format!("{namespace}::{name}")
} else {
name
};
let is_unit_struct = matches!(&input.data, Data::Struct(data) if data.fields.is_empty());
let build_fn_body = if no_json {
let error_msg = format!("{} cannot be built from JSON", full_name);
quote! { Err(gpui::private::anyhow::anyhow!(#error_msg)) }
} else if is_unit_struct {
quote! { Ok(Box::new(Self)) }
} else {
quote! { Ok(Box::new(gpui::private::serde_json::from_value::<Self>(_value)?)) }
};
let json_schema_fn_body = if no_json || is_unit_struct {
quote! { None }
} else {
quote! { Some(<Self as gpui::private::schemars::JsonSchema>::json_schema(_generator)) }
};
let deprecated_aliases_fn_body = if deprecated_aliases.is_empty() {
quote! { &[] }
} else {
let aliases = deprecated_aliases.iter();
quote! { &[#(#aliases),*] }
};
let deprecation_fn_body = if let Some(message) = deprecated {
quote! { Some(#message) }
} else {
quote! { None }
};
let documentation_fn_body = if let Some(doc) = doc_str {
let doc = doc.trim();
quote! { Some(#doc) }
} else {
quote! { None }
};
let registration = if no_register {
quote! {}
} else {
generate_register_action(struct_name)
};
TokenStream::from(quote! {
#registration
impl gpui::Action for #struct_name {
fn name(&self) -> &'static str {
#full_name
}
fn name_for_type() -> &'static str
where
Self: Sized
{
#full_name
}
fn partial_eq(&self, action: &dyn gpui::Action) -> bool {
action
.as_any()
.downcast_ref::<Self>()
.map_or(false, |a| self == a)
}
fn boxed_clone(&self) -> Box<dyn gpui::Action> {
Box::new(self.clone())
}
fn build(_value: gpui::private::serde_json::Value) -> gpui::Result<Box<dyn gpui::Action>> {
#build_fn_body
}
fn action_json_schema(
_generator: &mut gpui::private::schemars::SchemaGenerator,
) -> Option<gpui::private::schemars::Schema> {
#json_schema_fn_body
}
fn deprecated_aliases() -> &'static [&'static str] {
#deprecated_aliases_fn_body
}
fn deprecation_message() -> Option<&'static str> {
#deprecation_fn_body
}
fn documentation() -> Option<&'static str> {
#documentation_fn_body
}
}
})
}
+110
View File
@@ -0,0 +1,110 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{DeriveInput, parse_macro_input};
use crate::get_simple_attribute_field;
pub fn derive_app_context(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let Some(app_variable) = get_simple_attribute_field(&ast, "app") else {
return quote! {
compile_error!("Derive must have an #[app] attribute to detect the &mut App field");
}
.into();
};
let type_name = &ast.ident;
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
let r#gen = quote! {
impl #impl_generics gpui::AppContext for #type_name #type_generics
#where_clause
{
fn new<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut gpui::Context<'_, T>) -> T,
) -> gpui::Entity<T> {
self.#app_variable.new(build_entity)
}
fn reserve_entity<T: 'static>(&mut self) -> gpui::Reservation<T> {
self.#app_variable.reserve_entity()
}
fn insert_entity<T: 'static>(
&mut self,
reservation: gpui::Reservation<T>,
build_entity: impl FnOnce(&mut gpui::Context<'_, T>) -> T,
) -> gpui::Entity<T> {
self.#app_variable.insert_entity(reservation, build_entity)
}
fn update_entity<T, R>(
&mut self,
handle: &gpui::Entity<T>,
update: impl FnOnce(&mut T, &mut gpui::Context<'_, T>) -> R,
) -> R
where
T: 'static,
{
self.#app_variable.update_entity(handle, update)
}
fn as_mut<'y, 'z, T>(
&'y mut self,
handle: &'z gpui::Entity<T>,
) -> gpui::GpuiBorrow<'y, T>
where
T: 'static,
{
self.#app_variable.as_mut(handle)
}
fn read_entity<T, R>(
&self,
handle: &gpui::Entity<T>,
read: impl FnOnce(&T, &gpui::App) -> R,
) -> R
where
T: 'static,
{
self.#app_variable.read_entity(handle, read)
}
fn update_window<T, F>(&mut self, window: gpui::AnyWindowHandle, f: F) -> gpui::Result<T>
where
F: FnOnce(gpui::AnyView, &mut gpui::Window, &mut gpui::App) -> T,
{
self.#app_variable.update_window(window, f)
}
fn read_window<T, R>(
&self,
window: &gpui::WindowHandle<T>,
read: impl FnOnce(gpui::Entity<T>, &gpui::App) -> R,
) -> gpui::Result<R>
where
T: 'static,
{
self.#app_variable.read_window(window, read)
}
fn background_spawn<R>(&self, future: impl std::future::Future<Output = R> + Send + 'static) -> gpui::Task<R>
where
R: Send + 'static,
{
self.#app_variable.background_spawn(future)
}
fn read_global<G, R>(&self, callback: impl FnOnce(&G, &gpui::App) -> R) -> R
where
G: gpui::Global,
{
self.#app_variable.read_global(callback)
}
}
};
r#gen.into()
}
@@ -0,0 +1,305 @@
//! Implements `#[derive_inspector_reflection]` macro to provide runtime access to trait methods
//! that have the shape `fn method(self) -> Self`. This code was generated using Zed Agent with Claude Opus 4.
use heck::ToSnakeCase as _;
use proc_macro::TokenStream;
use proc_macro2::{Span, TokenStream as TokenStream2};
use quote::quote;
use syn::{
Attribute, Expr, FnArg, Ident, Item, ItemTrait, Lit, Meta, Path, ReturnType, TraitItem, Type,
parse_macro_input, parse_quote,
visit_mut::{self, VisitMut},
};
pub fn derive_inspector_reflection(_args: TokenStream, input: TokenStream) -> TokenStream {
let mut item = parse_macro_input!(input as Item);
// First, expand any macros in the trait
match &mut item {
Item::Trait(trait_item) => {
let mut expander = MacroExpander;
expander.visit_item_trait_mut(trait_item);
}
_ => {
return syn::Error::new_spanned(
quote!(#item),
"#[derive_inspector_reflection] can only be applied to traits",
)
.to_compile_error()
.into();
}
}
// Now process the expanded trait
match item {
Item::Trait(trait_item) => generate_reflected_trait(trait_item),
_ => unreachable!(),
}
}
fn generate_reflected_trait(trait_item: ItemTrait) -> TokenStream {
let trait_name = &trait_item.ident;
let vis = &trait_item.vis;
// Determine if we're being called from within the gpui crate
let call_site = Span::call_site();
let inspector_reflection_path = if is_called_from_gpui_crate(call_site) {
quote! { crate::inspector_reflection }
} else {
quote! { ::gpui::inspector_reflection }
};
// Collect method information for methods of form fn name(self) -> Self or fn name(mut self) -> Self
let mut method_infos = Vec::new();
for item in &trait_item.items {
if let TraitItem::Fn(method) = item {
let method_name = &method.sig.ident;
// Check if method has self or mut self receiver
let has_valid_self_receiver = method
.sig
.inputs
.iter()
.any(|arg| matches!(arg, FnArg::Receiver(r) if r.reference.is_none()));
// Check if method returns Self
let returns_self = match &method.sig.output {
ReturnType::Type(_, ty) => {
matches!(**ty, Type::Path(ref path) if path.path.is_ident("Self"))
}
ReturnType::Default => false,
};
// Check if method has exactly one parameter (self or mut self)
let param_count = method.sig.inputs.len();
// Include methods of form fn name(self) -> Self or fn name(mut self) -> Self
// This includes methods with default implementations
if has_valid_self_receiver && returns_self && param_count == 1 {
// Extract documentation and cfg attributes
let doc = extract_doc_comment(&method.attrs);
let cfg_attrs = extract_cfg_attributes(&method.attrs);
method_infos.push((method_name.clone(), doc, cfg_attrs));
}
}
}
// Generate the reflection module name
let reflection_mod_name = Ident::new(
&format!("{}_reflection", trait_name.to_string().to_snake_case()),
trait_name.span(),
);
// Generate wrapper functions for each method
// These wrappers use type erasure to allow runtime invocation
let wrapper_functions = method_infos.iter().map(|(method_name, _doc, cfg_attrs)| {
let wrapper_name = Ident::new(
&format!("__wrapper_{}", method_name),
method_name.span(),
);
quote! {
#(#cfg_attrs)*
fn #wrapper_name<T: #trait_name + 'static>(value: Box<dyn std::any::Any>) -> Box<dyn std::any::Any> {
if let Ok(concrete) = value.downcast::<T>() {
Box::new(concrete.#method_name())
} else {
panic!("Type mismatch in reflection wrapper");
}
}
}
});
// Generate method info entries
let method_info_entries = method_infos.iter().map(|(method_name, doc, cfg_attrs)| {
let method_name_str = method_name.to_string();
let wrapper_name = Ident::new(&format!("__wrapper_{}", method_name), method_name.span());
let doc_expr = match doc {
Some(doc_str) => quote! { Some(#doc_str) },
None => quote! { None },
};
quote! {
#(#cfg_attrs)*
#inspector_reflection_path::FunctionReflection {
name: #method_name_str,
function: #wrapper_name::<T>,
documentation: #doc_expr,
_type: ::std::marker::PhantomData,
}
}
});
// Generate the complete output
let output = quote! {
#trait_item
/// Implements function reflection
#vis mod #reflection_mod_name {
use super::*;
#(#wrapper_functions)*
/// Get all reflectable methods for a concrete type implementing the trait
pub fn methods<T: #trait_name + 'static>() -> Vec<#inspector_reflection_path::FunctionReflection<T>> {
vec![
#(#method_info_entries),*
]
}
/// Find a method by name for a concrete type implementing the trait
pub fn find_method<T: #trait_name + 'static>(name: &str) -> Option<#inspector_reflection_path::FunctionReflection<T>> {
methods::<T>().into_iter().find(|m| m.name == name)
}
}
};
TokenStream::from(output)
}
fn extract_doc_comment(attrs: &[Attribute]) -> Option<String> {
let mut doc_lines = Vec::new();
for attr in attrs {
if attr.path().is_ident("doc")
&& let Meta::NameValue(meta) = &attr.meta
&& let Expr::Lit(expr_lit) = &meta.value
&& let Lit::Str(lit_str) = &expr_lit.lit
{
let line = lit_str.value();
let line = line.strip_prefix(' ').unwrap_or(&line);
doc_lines.push(line.to_string());
}
}
if doc_lines.is_empty() {
None
} else {
Some(doc_lines.join("\n"))
}
}
fn extract_cfg_attributes(attrs: &[Attribute]) -> Vec<Attribute> {
attrs
.iter()
.filter(|attr| attr.path().is_ident("cfg"))
.cloned()
.collect()
}
fn is_called_from_gpui_crate(_span: Span) -> bool {
// Check if we're being called from within the gpui crate by examining the call site
// This is a heuristic approach - we check if the current crate name is "gpui"
std::env::var("CARGO_PKG_NAME").is_ok_and(|name| name == "gpui")
}
struct MacroExpander;
impl VisitMut for MacroExpander {
fn visit_item_trait_mut(&mut self, trait_item: &mut ItemTrait) {
let mut expanded_items = Vec::new();
let mut items_to_keep = Vec::new();
for item in trait_item.items.drain(..) {
match item {
TraitItem::Macro(macro_item) => {
// Try to expand known macros
if let Some(expanded) = try_expand_macro(&macro_item) {
expanded_items.extend(expanded);
} else {
// Keep unknown macros as-is
items_to_keep.push(TraitItem::Macro(macro_item));
}
}
other => {
items_to_keep.push(other);
}
}
}
// Rebuild the items list with expanded content first, then original items
trait_item.items = expanded_items;
trait_item.items.extend(items_to_keep);
// Continue visiting
visit_mut::visit_item_trait_mut(self, trait_item);
}
}
fn try_expand_macro(macro_item: &syn::TraitItemMacro) -> Option<Vec<TraitItem>> {
let path = &macro_item.mac.path;
// Check if this is one of our known style macros
let macro_name = path_to_string(path);
// Handle the known macros by calling their implementations
match macro_name.as_str() {
"gpui_macros::style_helpers" | "style_helpers" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::style_helpers(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
"gpui_macros::visibility_style_methods" | "visibility_style_methods" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::visibility_style_methods(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
"gpui_macros::margin_style_methods" | "margin_style_methods" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::margin_style_methods(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
"gpui_macros::padding_style_methods" | "padding_style_methods" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::padding_style_methods(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
"gpui_macros::position_style_methods" | "position_style_methods" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::position_style_methods(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
"gpui_macros::overflow_style_methods" | "overflow_style_methods" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::overflow_style_methods(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
"gpui_macros::cursor_style_methods" | "cursor_style_methods" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::cursor_style_methods(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
"gpui_macros::border_style_methods" | "border_style_methods" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::border_style_methods(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
"gpui_macros::box_shadow_style_methods" | "box_shadow_style_methods" => {
let tokens = macro_item.mac.tokens.clone();
let expanded = crate::styles::box_shadow_style_methods(TokenStream::from(tokens));
parse_expanded_items(expanded)
}
_ => None,
}
}
fn path_to_string(path: &Path) -> String {
path.segments
.iter()
.map(|seg| seg.ident.to_string())
.collect::<Vec<_>>()
.join("::")
}
fn parse_expanded_items(expanded: TokenStream) -> Option<Vec<TraitItem>> {
let tokens = TokenStream2::from(expanded);
// Try to parse the expanded tokens as trait items
// We need to wrap them in a dummy trait to parse properly
let dummy_trait: ItemTrait = parse_quote! {
trait Dummy {
#tokens
}
};
Some(dummy_trait.items)
}
+24
View File
@@ -0,0 +1,24 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{DeriveInput, parse_macro_input};
pub fn derive_into_element(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let type_name = &ast.ident;
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
let r#gen = quote! {
impl #impl_generics gpui::IntoElement for #type_name #type_generics
#where_clause
{
type Element = gpui::Component<Self>;
#[track_caller]
fn into_element(self) -> Self::Element {
gpui::Component::new(self)
}
}
};
r#gen.into()
}
+21
View File
@@ -0,0 +1,21 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{DeriveInput, parse_macro_input};
pub fn derive_render(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let type_name = &ast.ident;
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
let r#gen = quote! {
impl #impl_generics gpui::Render for #type_name #type_generics
#where_clause
{
fn render(&mut self, _window: &mut gpui::Window, _cx: &mut gpui::Context<Self>) -> impl gpui::Element {
gpui::Empty
}
}
};
r#gen.into()
}
@@ -0,0 +1,73 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{DeriveInput, parse_macro_input};
use super::get_simple_attribute_field;
pub fn derive_visual_context(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let Some(window_variable) = get_simple_attribute_field(&ast, "window") else {
return quote! {
compile_error!("Derive must have a #[window] attribute to detect the &mut Window field");
}
.into();
};
let Some(app_variable) = get_simple_attribute_field(&ast, "app") else {
return quote! {
compile_error!("Derive must have a #[app] attribute to detect the &mut App field");
}
.into();
};
let type_name = &ast.ident;
let (impl_generics, type_generics, where_clause) = ast.generics.split_for_impl();
let r#gen = quote! {
impl #impl_generics gpui::VisualContext for #type_name #type_generics
#where_clause
{
type Result<T> = T;
fn window_handle(&self) -> gpui::AnyWindowHandle {
self.#window_variable.window_handle()
}
fn update_window_entity<T: 'static, R>(
&mut self,
entity: &gpui::Entity<T>,
update: impl FnOnce(&mut T, &mut gpui::Window, &mut gpui::Context<T>) -> R,
) -> R {
gpui::AppContext::update_entity(self.#app_variable, entity, |entity, cx| update(entity, self.#window_variable, cx))
}
fn new_window_entity<T: 'static>(
&mut self,
build_entity: impl FnOnce(&mut gpui::Window, &mut gpui::Context<'_, T>) -> T,
) -> gpui::Entity<T> {
gpui::AppContext::new(self.#app_variable, |cx| build_entity(self.#window_variable, cx))
}
fn replace_root_view<V>(
&mut self,
build_view: impl FnOnce(&mut gpui::Window, &mut gpui::Context<V>) -> V,
) -> gpui::Entity<V>
where
V: 'static + gpui::Render,
{
self.#window_variable.replace_root(self.#app_variable, build_view)
}
fn focus<V>(&mut self, entity: &gpui::Entity<V>)
where
V: gpui::Focusable,
{
let focus_handle = gpui::Focusable::focus_handle(entity, self.#app_variable);
self.#window_variable.focus(&focus_handle, self.#app_variable);
}
}
};
r#gen.into()
}
+297
View File
@@ -0,0 +1,297 @@
mod derive_action;
mod derive_app_context;
mod derive_into_element;
mod derive_render;
mod derive_visual_context;
mod property_test;
mod register_action;
mod styles;
mod test;
#[cfg(any(feature = "inspector", debug_assertions))]
mod derive_inspector_reflection;
use proc_macro::TokenStream;
use syn::{DeriveInput, Ident};
/// `Action` derive macro - see the trait documentation for details.
#[proc_macro_derive(Action, attributes(action))]
pub fn derive_action(input: TokenStream) -> TokenStream {
derive_action::derive_action(input)
}
/// This can be used to register an action with the GPUI runtime when you want to manually implement
/// the `Action` trait. Typically you should use the `Action` derive macro or `actions!` macro
/// instead.
#[proc_macro]
pub fn register_action(ident: TokenStream) -> TokenStream {
register_action::register_action(ident)
}
/// #[derive(IntoElement)] is used to create a Component out of anything that implements
/// the `RenderOnce` trait.
#[proc_macro_derive(IntoElement)]
pub fn derive_into_element(input: TokenStream) -> TokenStream {
derive_into_element::derive_into_element(input)
}
#[proc_macro_derive(Render)]
#[doc(hidden)]
pub fn derive_render(input: TokenStream) -> TokenStream {
derive_render::derive_render(input)
}
/// #[derive(AppContext)] is used to create a context out of anything that holds a `&mut App`
/// Note that a `#[app]` attribute is required to identify the variable holding the &mut App.
///
/// Failure to add the attribute causes a compile error:
///
/// ```compile_fail
/// # #[macro_use] extern crate gpui_macros;
/// # #[macro_use] extern crate gpui;
/// #[derive(AppContext)]
/// struct MyContext<'a> {
/// app: &'a mut gpui::App
/// }
/// ```
#[proc_macro_derive(AppContext, attributes(app))]
pub fn derive_app_context(input: TokenStream) -> TokenStream {
derive_app_context::derive_app_context(input)
}
/// #[derive(VisualContext)] is used to create a visual context out of anything that holds a `&mut Window` and
/// implements `AppContext`
/// Note that a `#[app]` and a `#[window]` attribute are required to identify the variables holding the &mut App,
/// and &mut Window respectively.
///
/// Failure to add both attributes causes a compile error:
///
/// ```compile_fail
/// # #[macro_use] extern crate gpui_macros;
/// # #[macro_use] extern crate gpui;
/// #[derive(VisualContext)]
/// struct MyContext<'a, 'b> {
/// #[app]
/// app: &'a mut gpui::App,
/// window: &'b mut gpui::Window
/// }
/// ```
///
/// ```compile_fail
/// # #[macro_use] extern crate gpui_macros;
/// # #[macro_use] extern crate gpui;
/// #[derive(VisualContext)]
/// struct MyContext<'a, 'b> {
/// app: &'a mut gpui::App,
/// #[window]
/// window: &'b mut gpui::Window
/// }
/// ```
#[proc_macro_derive(VisualContext, attributes(window, app))]
pub fn derive_visual_context(input: TokenStream) -> TokenStream {
derive_visual_context::derive_visual_context(input)
}
/// Used by GPUI to generate the style helpers.
#[proc_macro]
#[doc(hidden)]
pub fn style_helpers(input: TokenStream) -> TokenStream {
styles::style_helpers(input)
}
/// Generates methods for visibility styles.
#[proc_macro]
pub fn visibility_style_methods(input: TokenStream) -> TokenStream {
styles::visibility_style_methods(input)
}
/// Generates methods for margin styles.
#[proc_macro]
pub fn margin_style_methods(input: TokenStream) -> TokenStream {
styles::margin_style_methods(input)
}
/// Generates methods for padding styles.
#[proc_macro]
pub fn padding_style_methods(input: TokenStream) -> TokenStream {
styles::padding_style_methods(input)
}
/// Generates methods for position styles.
#[proc_macro]
pub fn position_style_methods(input: TokenStream) -> TokenStream {
styles::position_style_methods(input)
}
/// Generates methods for overflow styles.
#[proc_macro]
pub fn overflow_style_methods(input: TokenStream) -> TokenStream {
styles::overflow_style_methods(input)
}
/// Generates methods for cursor styles.
#[proc_macro]
pub fn cursor_style_methods(input: TokenStream) -> TokenStream {
styles::cursor_style_methods(input)
}
/// Generates methods for border styles.
#[proc_macro]
pub fn border_style_methods(input: TokenStream) -> TokenStream {
styles::border_style_methods(input)
}
/// Generates methods for box shadow styles.
#[proc_macro]
pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream {
styles::box_shadow_style_methods(input)
}
/// `#[gpui::test]` can be used to annotate test functions that run with GPUI support.
///
/// It supports both synchronous and asynchronous tests, and can provide you with
/// as many `TestAppContext` instances as you need.
/// The output contains a `#[test]` annotation so this can be used with any existing
/// test harness (`cargo test` or `cargo-nextest`).
///
/// ```
/// #[gpui::test]
/// async fn test_foo(mut cx: &TestAppContext) { }
/// ```
///
/// In addition to passing a TestAppContext, you can also ask for a `StdRnd` instance.
/// this will be seeded with the `SEED` environment variable and is used internally by
/// the ForegroundExecutor and BackgroundExecutor to run tasks deterministically in tests.
/// Using the same `StdRng` for behavior in your test will allow you to exercise a wide
/// variety of scenarios and interleavings just by changing the seed.
///
/// # Arguments
///
/// - `#[gpui::test]` with no arguments runs once with the seed `0` or `SEED` env var if set.
/// - `#[gpui::test(seed = 10)]` runs once with the seed `10`.
/// - `#[gpui::test(seeds(10, 20, 30))]` runs three times with seeds `10`, `20`, and `30`.
/// - `#[gpui::test(iterations = 5)]` runs five times, providing as seed the values in the range `0..5`.
/// - `#[gpui::test(retries = 3)]` runs up to four times if it fails to try and make it pass.
/// - `#[gpui::test(on_failure = "crate::test::report_failure")]` will call the specified function after the
/// tests fail so that you can write out more detail about the failure.
///
/// You can combine `iterations = ...` with `seeds(...)`:
/// - `#[gpui::test(iterations = 5, seed = 10)]` is equivalent to `#[gpui::test(seeds(0, 1, 2, 3, 4, 10))]`.
/// - `#[gpui::test(iterations = 5, seeds(10, 20, 30)]` is equivalent to `#[gpui::test(seeds(0, 1, 2, 3, 4, 10, 20, 30))]`.
/// - `#[gpui::test(seeds(10, 20, 30), iterations = 5]` is equivalent to `#[gpui::test(seeds(0, 1, 2, 3, 4, 10, 20, 30))]`.
///
/// # Environment Variables
///
/// - `SEED`: sets a seed for the first run
/// - `ITERATIONS`: forces the value of the `iterations` argument
#[proc_macro_attribute]
pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
test::test(args, function)
}
/// A variant of `#[gpui::test]` that supports property-based testing.
///
/// A property test, much like a standard GPUI randomized test, allows testing
/// claims of the form "for any possible X, Y should hold". For example:
/// ```
/// #[gpui::property_test]
/// fn test_arithmetic(x: i32, y: i32) {
/// assert!(x == y || x < y || x > y);
/// }
/// ```
/// Standard GPUI randomized tests provide you with an instance of `StdRng` to
/// generate random data in a controlled manner. Property-based tests have some
/// advantages, however:
/// - Shrinking - the harness also understands a notion of the "complexity" of a
/// particular value. This allows it to find the "simplest possible value that
/// causes the test to fail".
/// - Ergonomics/clarity - the property-testing harness will automatically
/// generate values, removing the need to fill the test body with generation
/// logic.
/// - Failure persistence - if a failing seed is identified, it is stored in a
/// file, which can be checked in, and future runs will check these cases before
/// future cases.
///
/// Property tests work best when all inputs can be generated up-front and kept
/// in a simple data structure. Sometimes, this isn't possible - for example, if
/// a test needs to make a random decision based on the current state of some
/// structure. In this case, a standard GPUI randomized test may be more
/// suitable.
///
/// ## Customizing random values
///
/// This macro is based on the [`#[proptest::property_test]`] macro, but handles
/// some of the same GPUI-specific arguments as `#[gpui::test]`. Specifically,
/// `&{mut,} TestAppContext` and `BackgroundExecutor` work as normal. `StdRng`
/// arguments are **explicitly forbidden**, since they break shrinking, and are
/// a common footgun.
///
/// All other arguments are forwarded to the underlying proptest macro.
///
/// Note: much of the following is copied from the proptest docs, specifically the
/// [`#[proptest::property_test]`] macro docs.
///
/// Random values of type `T` are generated by a `Strategy<Value = T>` object.
/// Some types have a canonical `Strategy` - these types also implement
/// `Arbitrary`. Parameters to a `#[gpui::property_test]`, by default, use a
/// type's `Arbitrary` implementation. If you'd like to provide a custom
/// strategy, you can use `#[strategy = ...]` on the argument:
/// ```
/// #[gpui::property_test]
/// fn int_test(#[strategy = 1..10] x: i32, #[strategy = "[a-zA-Z0-9]{20}"] s: String) {
/// assert!(s.len() > (x as usize));
/// }
/// ```
///
/// For more information on writing custom `Strategy` and `Arbitrary`
/// implementations, see [the proptest book][book], and the [`Strategy`] trait.
///
/// ## Scheduler
///
/// Similar to `#[gpui::test]`, this macro will choose random seeds for the test
/// scheduler. It uses `.no_shrink()` to tell proptest that all seeds are
/// roughly equivalent in terms of "complexity". If `$SEED` is set, it will
/// affect **ONLY** the seed passed to the scheduler. To control other values,
/// use custom `Strategy`s.
///
/// [`#[proptest::property_test]`]: https://docs.rs/proptest/latest/proptest/attr.property_test.html
/// [book]: https://proptest-rs.github.io/proptest/intro.html
/// [`Strategy`]: https://docs.rs/proptest/latest/proptest/strategy/trait.Strategy.html
#[proc_macro_attribute]
pub fn property_test(args: TokenStream, function: TokenStream) -> TokenStream {
property_test::test(args.into(), function.into()).into()
}
/// When added to a trait, `#[derive_inspector_reflection]` generates a module which provides
/// enumeration and lookup by name of all methods that have the shape `fn method(self) -> Self`.
/// This is used by the inspector so that it can use the builder methods in `Styled` and
/// `StyledExt`.
///
/// The generated module will have the name `<snake_case_trait_name>_reflection` and contain the
/// following functions:
///
/// ```ignore
/// pub fn methods::<T: TheTrait + 'static>() -> Vec<gpui::inspector_reflection::FunctionReflection<T>>;
///
/// pub fn find_method::<T: TheTrait + 'static>() -> Option<gpui::inspector_reflection::FunctionReflection<T>>;
/// ```
///
/// The `invoke` method on `FunctionReflection` will run the method. `FunctionReflection` also
/// provides the method's documentation.
#[cfg(any(feature = "inspector", debug_assertions))]
#[proc_macro_attribute]
pub fn derive_inspector_reflection(_args: TokenStream, input: TokenStream) -> TokenStream {
derive_inspector_reflection::derive_inspector_reflection(_args, input)
}
pub(crate) fn get_simple_attribute_field(ast: &DeriveInput, name: &'static str) -> Option<Ident> {
match &ast.data {
syn::Data::Struct(data_struct) => data_struct
.fields
.iter()
.find(|field| field.attrs.iter().any(|attr| attr.path().is_ident(name)))
.map(|field| field.ident.clone().unwrap()),
syn::Data::Enum(_) => None,
syn::Data::Union(_) => None,
}
}
+199
View File
@@ -0,0 +1,199 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote, quote_spanned};
use syn::{
FnArg, Ident, ItemFn, Type, parse2, punctuated::Punctuated, spanned::Spanned, token::Comma,
};
pub fn test(args: TokenStream, item: TokenStream) -> TokenStream {
let item_span = item.span();
let Ok(func) = parse2::<ItemFn>(item) else {
return quote_spanned! { item_span =>
compile_error!("#[gpui::property_test] must be placed on a function");
};
};
let test_name = func.sig.ident.clone();
let inner_fn_name = format_ident!("__{test_name}");
let parsed_args = parse_args(func.sig.inputs, &test_name);
let inner_body = func.block;
let inner_arg_decls = parsed_args.inner_fn_decl_args;
let asyncness = func.sig.asyncness;
let inner_fn = quote! {
let #inner_fn_name = #asyncness move |#inner_arg_decls| #inner_body;
};
let arg_errors = parsed_args.errors;
let proptest_args = parsed_args.proptest_args;
let inner_args = parsed_args.inner_fn_args;
let cx_vars = parsed_args.cx_vars;
let cx_teardowns = parsed_args.cx_teardowns;
let proptest_args = quote! {
#[strategy = ::gpui::seed_strategy()] __seed: u64,
#proptest_args
};
let run_test_body = match &asyncness {
None => quote! {
#cx_vars
#inner_fn_name(#inner_args);
#cx_teardowns
},
Some(_) => quote! {
let foreground_executor = gpui::ForegroundExecutor::new(std::sync::Arc::new(dispatcher.clone()));
#cx_vars
foreground_executor.block_test(#inner_fn_name(#inner_args));
#cx_teardowns
},
};
quote! {
#arg_errors
#[::gpui::proptest::property_test(proptest_path = "::gpui::proptest", #args)]
fn #test_name(#proptest_args) {
#inner_fn
::gpui::run_test_once(
__seed,
Box::new(move |dispatcher| {
#run_test_body
}),
)
}
}
}
#[derive(Default)]
struct ParsedArgs {
cx_vars: TokenStream,
cx_teardowns: TokenStream,
proptest_args: TokenStream,
errors: TokenStream,
// exprs passed at the call-site
inner_fn_args: TokenStream,
// args in the declaration
inner_fn_decl_args: TokenStream,
}
fn parse_args(args: Punctuated<FnArg, Comma>, test_name: &Ident) -> ParsedArgs {
let mut parsed = ParsedArgs::default();
let mut args = args.into_iter().collect();
remove_cxs(&mut parsed, &mut args, test_name);
remove_std_rng(&mut parsed, &mut args);
remove_background_executor(&mut parsed, &mut args);
// all remaining args forwarded to proptest's macro
parsed.proptest_args = quote!( #(#args),* );
parsed
}
fn remove_cxs(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>, test_name: &Ident) {
let mut ix = 0;
args.retain_mut(|arg| {
if !is_test_cx(arg) {
return true;
}
let cx_varname = format_ident!("cx_{ix}");
ix += 1;
parsed.cx_vars.extend(quote!(
let mut #cx_varname = gpui::TestAppContext::build(
dispatcher.clone(),
Some(stringify!(#test_name)),
);
));
parsed.cx_teardowns.extend(quote!(
dispatcher.run_until_parked();
#cx_varname.executor().forbid_parking();
#cx_varname.quit();
dispatcher.run_until_parked();
));
parsed.inner_fn_decl_args.extend(quote!(#arg,));
parsed.inner_fn_args.extend(quote!(&mut #cx_varname,));
false
});
}
fn remove_std_rng(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>) {
args.retain_mut(|arg| {
if !is_std_rng(arg) {
return true;
}
parsed.errors.extend(quote_spanned! { arg.span() =>
compile_error!("`StdRng` is not allowed in a property test. Consider implementing `Arbitrary`, or implementing a custom `Strategy`. https://altsysrq.github.io/proptest-book/proptest/tutorial/strategy-basics.html");
});
false
});
}
fn remove_background_executor(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>) {
args.retain_mut(|arg| {
if !is_background_executor(arg) {
return true;
}
parsed.inner_fn_decl_args.extend(quote!(#arg,));
parsed
.inner_fn_args
.extend(quote!(gpui::BackgroundExecutor::new(std::sync::Arc::new(
dispatcher.clone()
)),));
false
});
}
// Matches `&TestAppContext` or `&foo::bar::baz::TestAppContext`
fn is_test_cx(arg: &FnArg) -> bool {
let FnArg::Typed(arg) = arg else {
return false;
};
let Type::Reference(ty) = &*arg.ty else {
return false;
};
let Type::Path(ty) = &*ty.elem else {
return false;
};
ty.path
.segments
.last()
.is_some_and(|seg| seg.ident == "TestAppContext")
}
fn is_std_rng(arg: &FnArg) -> bool {
is_path_with_last_segment(arg, "StdRng")
}
fn is_background_executor(arg: &FnArg) -> bool {
is_path_with_last_segment(arg, "BackgroundExecutor")
}
fn is_path_with_last_segment(arg: &FnArg, last_segment: &str) -> bool {
let FnArg::Typed(arg) = arg else {
return false;
};
let Type::Path(ty) = &*arg.ty else {
return false;
};
ty.path
.segments
.last()
.is_some_and(|seg| seg.ident == last_segment)
}
+47
View File
@@ -0,0 +1,47 @@
use proc_macro::TokenStream;
use proc_macro2::{Ident, TokenStream as TokenStream2};
use quote::{format_ident, quote};
use syn::parse_macro_input;
pub(crate) fn register_action(ident: TokenStream) -> TokenStream {
let name = parse_macro_input!(ident as Ident);
let registration = generate_register_action(&name);
TokenStream::from(quote! {
#registration
})
}
pub(crate) fn generate_register_action(type_name: &Ident) -> TokenStream2 {
let action_builder_fn_name = format_ident!(
"__gpui_actions_builder_{}",
type_name.to_string().to_lowercase()
);
quote! {
impl #type_name {
/// This is an auto generated function, do not use.
#[automatically_derived]
#[doc(hidden)]
fn __autogenerated() {
/// This is an auto generated function, do not use.
#[doc(hidden)]
fn #action_builder_fn_name() -> gpui::MacroActionData {
gpui::MacroActionData {
name: <#type_name as gpui::Action>::name_for_type(),
type_id: ::std::any::TypeId::of::<#type_name>(),
build: <#type_name as gpui::Action>::build,
json_schema: <#type_name as gpui::Action>::action_json_schema,
deprecated_aliases: <#type_name as gpui::Action>::deprecated_aliases(),
deprecation_message: <#type_name as gpui::Action>::deprecation_message(),
documentation: <#type_name as gpui::Action>::documentation(),
}
}
gpui::private::inventory::submit! {
gpui::MacroActionBuilder(#action_builder_fn_name)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
use proc_macro::TokenStream;
use proc_macro2::Ident;
use quote::{format_ident, quote};
use std::mem;
use syn::{
self, Expr, ExprLit, FnArg, ItemFn, Lit, Meta, MetaList, PathSegment, Token, Type,
parse::{Parse, ParseStream},
parse_quote,
punctuated::Punctuated,
spanned::Spanned,
};
struct Args {
seeds: Vec<u64>,
max_retries: usize,
max_iterations: usize,
on_failure_fn_name: proc_macro2::TokenStream,
}
impl Parse for Args {
fn parse(input: ParseStream) -> Result<Self, syn::Error> {
let mut seeds = Vec::<u64>::new();
let mut max_retries = 0;
let mut max_iterations = 1;
let mut on_failure_fn_name = quote!(None);
let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
for meta in metas {
let ident = {
let meta_path = match &meta {
Meta::NameValue(meta) => &meta.path,
Meta::List(list) => &list.path,
Meta::Path(path) => {
return Err(syn::Error::new(path.span(), "invalid path argument"));
}
};
let Some(ident) = meta_path.get_ident() else {
return Err(syn::Error::new(meta_path.span(), "unexpected path"));
};
ident.to_string()
};
match (&meta, ident.as_str()) {
(Meta::NameValue(meta), "retries") => {
max_retries = parse_usize_from_expr(&meta.value)?
}
(Meta::NameValue(meta), "iterations") => {
max_iterations = parse_usize_from_expr(&meta.value)?
}
(Meta::NameValue(meta), "on_failure") => {
let Expr::Lit(ExprLit {
lit: Lit::Str(name),
..
}) = &meta.value
else {
return Err(syn::Error::new(
meta.value.span(),
"on_failure argument must be a string",
));
};
let segments = name
.value()
.split("::")
.map(|part| PathSegment::from(Ident::new(part, name.span())))
.collect();
let path = syn::Path {
leading_colon: None,
segments,
};
on_failure_fn_name = quote!(Some(#path));
}
(Meta::NameValue(meta), "seed") => {
seeds = vec![parse_usize_from_expr(&meta.value)? as u64]
}
(Meta::List(list), "seeds") => seeds = parse_u64_array(list)?,
(Meta::Path(_), _) => {
return Err(syn::Error::new(meta.span(), "invalid path argument"));
}
(_, _) => {
return Err(syn::Error::new(meta.span(), "invalid argument name"));
}
}
}
Ok(Args {
seeds,
max_retries,
max_iterations,
on_failure_fn_name,
})
}
}
pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
let args = syn::parse_macro_input!(args as Args);
let mut inner_fn = match syn::parse::<ItemFn>(function) {
Ok(f) => f,
Err(err) => return error_to_stream(err),
};
let inner_fn_attributes = mem::take(&mut inner_fn.attrs);
let inner_fn_name = format_ident!("__{}", inner_fn.sig.ident);
let outer_fn_name = mem::replace(&mut inner_fn.sig.ident, inner_fn_name.clone());
let result = generate_test_function(
args,
inner_fn,
inner_fn_attributes,
inner_fn_name,
outer_fn_name,
);
match result {
Ok(tokens) => tokens,
Err(tokens) => tokens,
}
}
fn generate_test_function(
args: Args,
inner_fn: ItemFn,
inner_fn_attributes: Vec<syn::Attribute>,
inner_fn_name: Ident,
outer_fn_name: Ident,
) -> Result<TokenStream, TokenStream> {
let seeds = &args.seeds;
let max_retries = args.max_retries;
let num_iterations = args.max_iterations;
let on_failure_fn_name = &args.on_failure_fn_name;
let seeds = quote!( #(#seeds),* );
let mut outer_fn: ItemFn = if inner_fn.sig.asyncness.is_some() {
// Pass to the test function the number of app contexts that it needs,
// based on its parameter list.
let mut cx_vars = proc_macro2::TokenStream::new();
let mut cx_teardowns = proc_macro2::TokenStream::new();
let mut inner_fn_args = proc_macro2::TokenStream::new();
for (ix, arg) in inner_fn.sig.inputs.iter().enumerate() {
if let FnArg::Typed(arg) = arg {
if let Type::Path(ty) = &*arg.ty {
let last_segment = ty.path.segments.last();
match last_segment.map(|s| s.ident.to_string()).as_deref() {
Some("StdRng") => {
inner_fn_args.extend(quote!(rand::SeedableRng::seed_from_u64(_seed),));
continue;
}
Some("BackgroundExecutor") => {
inner_fn_args.extend(quote!(gpui::BackgroundExecutor::new(
std::sync::Arc::new(dispatcher.clone()),
),));
continue;
}
_ => {}
}
} else if let Type::Reference(ty) = &*arg.ty
&& let Type::Path(ty) = &*ty.elem
{
let last_segment = ty.path.segments.last();
if let Some("TestAppContext") =
last_segment.map(|s| s.ident.to_string()).as_deref()
{
let cx_varname = format_ident!("cx_{}", ix);
cx_vars.extend(quote!(
let mut #cx_varname = gpui::TestAppContext::build(
dispatcher.clone(),
Some(stringify!(#outer_fn_name)),
);
let _entity_refcounts = #cx_varname.app.borrow().ref_counts_drop_handle();
));
cx_teardowns.extend(quote!(
#cx_varname.run_until_parked();
#cx_varname.update(|cx| { cx.background_executor().forbid_parking(); cx.quit(); });
#cx_varname.run_until_parked();
drop(#cx_varname);
));
inner_fn_args.extend(quote!(&mut #cx_varname,));
continue;
}
}
}
return Err(error_with_message("invalid function signature", arg));
}
parse_quote! {
#[test]
fn #outer_fn_name() {
#inner_fn
gpui::run_test(
#num_iterations,
&[#seeds],
#max_retries,
&mut |dispatcher, _seed| {
let exec = std::sync::Arc::new(dispatcher.clone());
#cx_vars
gpui::ForegroundExecutor::new(exec.clone()).block_test(#inner_fn_name(#inner_fn_args));
drop(exec);
#cx_teardowns
// Ideally we would only drop cancelled tasks, that way we could detect leaks due to task <-> entity
// cycles as cancelled tasks will be dropped properly once the runnable gets run again
//
// async-task does not give us the power to do this just yet though
dispatcher.drain_tasks();
drop(dispatcher);
},
#on_failure_fn_name
);
}
}
} else {
// Pass to the test function the number of app contexts that it needs,
// based on its parameter list.
let mut cx_vars = proc_macro2::TokenStream::new();
let mut cx_teardowns = proc_macro2::TokenStream::new();
let mut inner_fn_args = proc_macro2::TokenStream::new();
for (ix, arg) in inner_fn.sig.inputs.iter().enumerate() {
if let FnArg::Typed(arg) = arg {
if let Type::Path(ty) = &*arg.ty {
let last_segment = ty.path.segments.last();
if let Some("StdRng") = last_segment.map(|s| s.ident.to_string()).as_deref() {
inner_fn_args.extend(quote!(rand::SeedableRng::seed_from_u64(_seed),));
continue;
}
} else if let Type::Reference(ty) = &*arg.ty
&& let Type::Path(ty) = &*ty.elem
{
let last_segment = ty.path.segments.last();
match last_segment.map(|s| s.ident.to_string()).as_deref() {
Some("App") => {
let cx_varname = format_ident!("cx_{}", ix);
let cx_varname_lock = format_ident!("cx_{}_lock", ix);
cx_vars.extend(quote!(
let mut #cx_varname = gpui::TestAppContext::build(
dispatcher.clone(),
Some(stringify!(#outer_fn_name))
);
let mut #cx_varname_lock = #cx_varname.app.borrow_mut();
let _entity_refcounts = #cx_varname_lock.ref_counts_drop_handle();
));
inner_fn_args.extend(quote!(&mut #cx_varname_lock,));
cx_teardowns.extend(quote!(
drop(#cx_varname_lock);
#cx_varname.run_until_parked();
#cx_varname.update(|cx| { cx.background_executor().forbid_parking(); cx.quit(); });
#cx_varname.run_until_parked();
drop(#cx_varname);
));
continue;
}
Some("TestAppContext") => {
let cx_varname = format_ident!("cx_{}", ix);
cx_vars.extend(quote!(
let mut #cx_varname = gpui::TestAppContext::build(
dispatcher.clone(),
Some(stringify!(#outer_fn_name))
);
let _entity_refcounts = #cx_varname.app.borrow().ref_counts_drop_handle();
));
cx_teardowns.extend(quote!(
#cx_varname.run_until_parked();
#cx_varname.update(|cx| { cx.background_executor().forbid_parking(); cx.quit(); });
#cx_varname.run_until_parked();
drop(#cx_varname);
));
inner_fn_args.extend(quote!(&mut #cx_varname,));
continue;
}
_ => {}
}
}
}
return Err(error_with_message("invalid function signature", arg));
}
parse_quote! {
#[test]
fn #outer_fn_name() {
#inner_fn
gpui::run_test(
#num_iterations,
&[#seeds],
#max_retries,
&mut |dispatcher, _seed| {
#cx_vars
#inner_fn_name(#inner_fn_args);
#cx_teardowns
// Ideally we would only drop cancelled tasks, that way we could detect leaks due to task <-> entity
// cycles as cancelled tasks will be dropped properly once they runnable gets run again
//
// async-task does not give us the power to do this just yet though
dispatcher.drain_tasks();
drop(dispatcher);
},
#on_failure_fn_name,
);
}
}
};
outer_fn.attrs.extend(inner_fn_attributes);
Ok(TokenStream::from(quote!(#outer_fn)))
}
fn parse_usize_from_expr(expr: &Expr) -> Result<usize, syn::Error> {
let Expr::Lit(ExprLit {
lit: Lit::Int(int), ..
}) = expr
else {
return Err(syn::Error::new(expr.span(), "expected an integer"));
};
int.base10_parse()
.map_err(|_| syn::Error::new(int.span(), "failed to parse integer"))
}
fn parse_u64_array(meta_list: &MetaList) -> Result<Vec<u64>, syn::Error> {
let mut result = Vec::new();
let tokens = &meta_list.tokens;
let parser = |input: ParseStream| {
let exprs = Punctuated::<Expr, Token![,]>::parse_terminated(input)?;
for expr in exprs {
if let Expr::Lit(ExprLit {
lit: Lit::Int(int), ..
}) = expr
{
let value: usize = int.base10_parse()?;
result.push(value as u64);
} else {
return Err(syn::Error::new(expr.span(), "expected an integer"));
}
}
Ok(())
};
syn::parse::Parser::parse2(parser, tokens.clone())?;
Ok(result)
}
fn error_with_message(message: &str, spanned: impl Spanned) -> TokenStream {
error_to_stream(syn::Error::new(spanned.span(), message))
}
fn error_to_stream(err: syn::Error) -> TokenStream {
TokenStream::from(err.into_compile_error())
}
+13
View File
@@ -0,0 +1,13 @@
#[test]
fn test_derive_context() {
use gpui::{App, Window};
use gpui_macros::{AppContext, VisualContext};
#[derive(AppContext, VisualContext)]
struct _MyCustomContext<'a, 'b> {
#[app]
app: &'a mut App,
#[window]
window: &'b mut Window,
}
}
@@ -0,0 +1,133 @@
//! This code was generated using Zed Agent with Claude Opus 4.
// gate on rust-analyzer so rust-analyzer never needs to expand this macro, it takes up to 10 seconds to expand due to inefficiencies in rust-analyzers proc-macro srv
#[cfg_attr(not(rust_analyzer), gpui_macros::derive_inspector_reflection)]
trait Transform: Clone {
/// Doubles the value
fn double(self) -> Self;
/// Triples the value
fn triple(self) -> Self;
/// Increments the value by one
///
/// This method has a default implementation
fn increment(self) -> Self {
// Default implementation
self.add_one()
}
/// Quadruples the value by doubling twice
fn quadruple(self) -> Self {
// Default implementation with mut self
self.double().double()
}
// These methods will be filtered out:
#[allow(dead_code)]
fn add(&self, other: &Self) -> Self;
#[allow(dead_code)]
fn set_value(&mut self, value: i32);
#[allow(dead_code)]
fn get_value(&self) -> i32;
/// Adds one to the value
fn add_one(self) -> Self;
}
#[derive(Debug, Clone, PartialEq)]
struct Number(i32);
impl Transform for Number {
fn double(self) -> Self {
Number(self.0 * 2)
}
fn triple(self) -> Self {
Number(self.0 * 3)
}
fn add(&self, other: &Self) -> Self {
Number(self.0 + other.0)
}
fn set_value(&mut self, value: i32) {
self.0 = value;
}
fn get_value(&self) -> i32 {
self.0
}
fn add_one(self) -> Self {
Number(self.0 + 1)
}
}
#[test]
fn test_derive_inspector_reflection() {
use transform_reflection::*;
// Get all methods that match the pattern fn(self) -> Self or fn(mut self) -> Self
let methods = methods::<Number>();
assert_eq!(methods.len(), 5);
let method_names: Vec<_> = methods.iter().map(|m| m.name).collect();
assert!(method_names.contains(&"double"));
assert!(method_names.contains(&"triple"));
assert!(method_names.contains(&"increment"));
assert!(method_names.contains(&"quadruple"));
assert!(method_names.contains(&"add_one"));
// Invoke methods by name
let num = Number(5);
let doubled = find_method::<Number>("double").unwrap().invoke(num.clone());
assert_eq!(doubled, Number(10));
let tripled = find_method::<Number>("triple").unwrap().invoke(num.clone());
assert_eq!(tripled, Number(15));
let incremented = find_method::<Number>("increment")
.unwrap()
.invoke(num.clone());
assert_eq!(incremented, Number(6));
let quadrupled = find_method::<Number>("quadruple").unwrap().invoke(num);
assert_eq!(quadrupled, Number(20));
// Try to invoke a non-existent method
let result = find_method::<Number>("nonexistent");
assert!(result.is_none());
// Chain operations
let num = Number(10);
let result = find_method::<Number>("double")
.map(|m| m.invoke(num))
.and_then(|n| find_method::<Number>("increment").map(|m| m.invoke(n)))
.and_then(|n| find_method::<Number>("triple").map(|m| m.invoke(n)));
assert_eq!(result, Some(Number(63))); // (10 * 2 + 1) * 3 = 63
// Test documentationumentation capture
let double_method = find_method::<Number>("double").unwrap();
assert_eq!(double_method.documentation, Some("Doubles the value"));
let triple_method = find_method::<Number>("triple").unwrap();
assert_eq!(triple_method.documentation, Some("Triples the value"));
let increment_method = find_method::<Number>("increment").unwrap();
assert_eq!(
increment_method.documentation,
Some("Increments the value by one\n\nThis method has a default implementation")
);
let quadruple_method = find_method::<Number>("quadruple").unwrap();
assert_eq!(
quadruple_method.documentation,
Some("Quadruples the value by doubling twice")
);
let add_one_method = find_method::<Number>("add_one").unwrap();
assert_eq!(add_one_method.documentation, Some("Adds one to the value"));
}
+7
View File
@@ -0,0 +1,7 @@
#[test]
fn test_derive_render() {
use gpui_macros::Render;
#[derive(Render)]
struct _Element;
}
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "perf"
version = "0.1.0"
publish = false
edition.workspace = true
license = "Apache-2.0"
description = "A tool for measuring GPUI test performance"
[lib]
# Some personal lint preferences :3
[lints.rust]
missing_docs = "warn"
[lints.clippy]
needless_continue = "allow" # For a convenience macro
all = "warn"
pedantic = "warn"
style = "warn"
missing_docs_in_private_items = "warn"
as_underscore = "deny"
allow_attributes = "deny"
allow_attributes_without_reason = "deny" # This covers `expect` also, since we deny `allow`
let_underscore_must_use = "forbid"
undocumented_unsafe_blocks = "forbid"
missing_safety_doc = "forbid"
disallowed_methods = { level = "allow", priority = 1}
[dependencies]
collections.workspace = true
serde.workspace = true
serde_json.workspace = true
+1
View File
@@ -0,0 +1 @@
../../LICENSE-APACHE
+450
View File
@@ -0,0 +1,450 @@
//! The implementation of the this crate is kept in a separate module
//! so that it is easy to publish this crate as part of GPUI's dependencies
use collections::HashMap;
use serde::{Deserialize, Serialize};
use std::{num::NonZero, time::Duration};
pub mod consts {
//! Preset identifiers and constants so that the profiler and proc macro agree
//! on their communication protocol.
/// The suffix on the actual test function.
pub const SUF_NORMAL: &str = "__ZED_PERF_FN";
/// The suffix on an extra function which prints metadata about a test to stdout.
pub const SUF_MDATA: &str = "__ZED_PERF_MDATA";
/// The env var in which we pass the iteration count to our tests.
pub const ITER_ENV_VAR: &str = "ZED_PERF_ITER";
/// The prefix printed on all benchmark test metadata lines, to distinguish it from
/// possible output by the test harness itself.
pub const MDATA_LINE_PREF: &str = "ZED_MDATA_";
/// The version number for the data returned from the test metadata function.
/// Increment on non-backwards-compatible changes.
pub const MDATA_VER: u32 = 0;
/// The default weight, if none is specified.
pub const WEIGHT_DEFAULT: u8 = 50;
/// How long a test must have run to be assumed to be reliable-ish.
pub const NOISE_CUTOFF: std::time::Duration = std::time::Duration::from_millis(250);
/// Identifier for the iteration count of a test metadata.
pub const ITER_COUNT_LINE_NAME: &str = "iter_count";
/// Identifier for the weight of a test metadata.
pub const WEIGHT_LINE_NAME: &str = "weight";
/// Identifier for importance in test metadata.
pub const IMPORTANCE_LINE_NAME: &str = "importance";
/// Identifier for the test metadata version.
pub const VERSION_LINE_NAME: &str = "version";
/// Where to save json run information.
pub const RUNS_DIR: &str = ".perf-runs";
}
/// How relevant a benchmark is.
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Importance {
/// Regressions shouldn't be accepted without good reason.
Critical = 4,
/// Regressions should be paid extra attention.
Important = 3,
/// No extra attention should be paid to regressions, but they might still
/// be indicative of something happening.
#[default]
Average = 2,
/// Unclear if regressions are likely to be meaningful, but still worth keeping
/// an eye on. Lowest level that's checked by default by the profiler.
Iffy = 1,
/// Regressions are likely to be spurious or don't affect core functionality.
/// Only relevant if a lot of them happen, or as supplemental evidence for a
/// higher-importance benchmark regressing. Not checked by default.
Fluff = 0,
}
impl std::fmt::Display for Importance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Importance::Critical => f.write_str("critical"),
Importance::Important => f.write_str("important"),
Importance::Average => f.write_str("average"),
Importance::Iffy => f.write_str("iffy"),
Importance::Fluff => f.write_str("fluff"),
}
}
}
/// Why or when did this test fail?
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum FailKind {
/// Failed while triaging it to determine the iteration count.
Triage,
/// Failed while profiling it.
Profile,
/// Failed due to an incompatible version for the test.
VersionMismatch,
/// Could not parse metadata for a test.
BadMetadata,
/// Skipped due to filters applied on the perf run.
Skipped,
}
impl std::fmt::Display for FailKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FailKind::Triage => f.write_str("errored in triage"),
FailKind::Profile => f.write_str("errored while profiling"),
FailKind::VersionMismatch => f.write_str("test version mismatch"),
FailKind::BadMetadata => f.write_str("bad test metadata"),
FailKind::Skipped => f.write_str("skipped"),
}
}
}
/// Information about a given perf test.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestMdata {
/// A version number for when the test was generated. If this is greater
/// than the version this test handler expects, one of the following will
/// happen in an unspecified manner:
/// - The test is skipped silently.
/// - The handler exits with an error message indicating the version mismatch
/// or inability to parse the metadata.
///
/// INVARIANT: If `version` <= `MDATA_VER`, this tool *must* be able to
/// correctly parse the output of this test.
pub version: u32,
/// How many iterations to pass this test if this is preset, or how many
/// iterations a test ended up running afterwards if determined at runtime.
pub iterations: Option<NonZero<usize>>,
/// The importance of this particular test. See the docs on `Importance` for
/// details.
pub importance: Importance,
/// The weight of this particular test within its importance category. Used
/// when comparing across runs.
pub weight: u8,
}
/// The actual timings of a test, as measured by Hyperfine.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Timings {
/// Mean runtime for `self.iter_total` runs of this test.
pub mean: Duration,
/// Standard deviation for the above.
pub stddev: Duration,
}
impl Timings {
/// How many iterations does this test seem to do per second?
#[expect(
clippy::cast_precision_loss,
reason = "We only care about a couple sig figs anyways"
)]
#[must_use]
pub fn iters_per_sec(&self, total_iters: NonZero<usize>) -> f64 {
(1000. / self.mean.as_millis() as f64) * total_iters.get() as f64
}
}
/// Aggregate results, meant to be used for a given importance category. Each
/// test name corresponds to its benchmark results, iteration count, and weight.
type CategoryInfo = HashMap<String, (Timings, NonZero<usize>, u8)>;
/// Aggregate output of all tests run by this handler.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Output {
/// A list of test outputs. Format is `(test_name, mdata, timings)`.
/// The latter being `Ok(_)` indicates the test succeeded.
///
/// INVARIANT: If the test succeeded, the second field is `Some(mdata)` and
/// `mdata.iterations` is `Some(_)`.
tests: Vec<(String, Option<TestMdata>, Result<Timings, FailKind>)>,
}
impl Output {
/// Instantiates an empty "output". Useful for merging.
#[must_use]
pub fn blank() -> Self {
Output { tests: Vec::new() }
}
/// Reports a success and adds it to this run's `Output`.
pub fn success(
&mut self,
name: impl AsRef<str>,
mut mdata: TestMdata,
iters: NonZero<usize>,
timings: Timings,
) {
mdata.iterations = Some(iters);
self.tests
.push((name.as_ref().to_string(), Some(mdata), Ok(timings)));
}
/// Reports a failure and adds it to this run's `Output`. If this test was tried
/// with some number of iterations (i.e. this was not a version mismatch or skipped
/// test), it should be reported also.
///
/// Using the `fail!()` macro is usually more convenient.
pub fn failure(
&mut self,
name: impl AsRef<str>,
mut mdata: Option<TestMdata>,
attempted_iters: Option<NonZero<usize>>,
kind: FailKind,
) {
if let Some(ref mut mdata) = mdata {
mdata.iterations = attempted_iters;
}
self.tests
.push((name.as_ref().to_string(), mdata, Err(kind)));
}
/// True if no tests executed this run.
#[must_use]
pub fn is_empty(&self) -> bool {
self.tests.is_empty()
}
/// Sorts the runs in the output in the order that we want them printed.
pub fn sort(&mut self) {
self.tests.sort_unstable_by(|a, b| match (a, b) {
// Tests where we got no metadata go at the end.
((_, Some(_), _), (_, None, _)) => std::cmp::Ordering::Greater,
((_, None, _), (_, Some(_), _)) => std::cmp::Ordering::Less,
// Then sort by importance, then weight.
((_, Some(a_mdata), _), (_, Some(b_mdata), _)) => {
let c = a_mdata.importance.cmp(&b_mdata.importance);
if matches!(c, std::cmp::Ordering::Equal) {
a_mdata.weight.cmp(&b_mdata.weight)
} else {
c
}
}
// Lastly by name.
((a_name, ..), (b_name, ..)) => a_name.cmp(b_name),
});
}
/// Merges the output of two runs, appending a prefix to the results of the new run.
/// To be used in conjunction with `Output::blank()`, or else only some tests will have
/// a prefix set.
pub fn merge<'a>(&mut self, other: Self, pref_other: impl Into<Option<&'a str>>) {
let pref = if let Some(pref) = pref_other.into() {
"crates/".to_string() + pref + "::"
} else {
String::new()
};
self.tests = std::mem::take(&mut self.tests)
.into_iter()
.chain(
other
.tests
.into_iter()
.map(|(name, md, tm)| (pref.clone() + &name, md, tm)),
)
.collect();
}
/// Evaluates the performance of `self` against `baseline`. The latter is taken
/// as the comparison point, i.e. a positive resulting `PerfReport` means that
/// `self` performed better.
///
/// # Panics
/// `self` and `baseline` are assumed to have the iterations field on all
/// `TestMdata`s set to `Some(_)` if the `TestMdata` is present itself.
#[must_use]
pub fn compare_perf(self, baseline: Self) -> PerfReport {
let self_categories = self.collapse();
let mut other_categories = baseline.collapse();
let deltas = self_categories
.into_iter()
.filter_map(|(cat, self_data)| {
// Only compare categories where both meow
// runs have data. /
let mut other_data = other_categories.remove(&cat)?;
let mut max = f64::MIN;
let mut min = f64::MAX;
// Running totals for averaging out tests.
let mut r_total_numerator = 0.;
let mut r_total_denominator = 0;
// Yeah this is O(n^2), but realistically it'll hardly be a bottleneck.
for (name, (s_timings, s_iters, weight)) in self_data {
// Only use the new weights if they conflict.
let Some((o_timings, o_iters, _)) = other_data.remove(&name) else {
continue;
};
let shift =
(o_timings.iters_per_sec(o_iters) / s_timings.iters_per_sec(s_iters)) - 1.;
if shift > max {
max = shift;
}
if shift < min {
min = shift;
}
r_total_numerator += shift * f64::from(weight);
r_total_denominator += u32::from(weight);
}
// There were no runs here!
if r_total_denominator == 0 {
None
} else {
let mean = r_total_numerator / f64::from(r_total_denominator);
// TODO: also aggregate standard deviation? That's harder to keep
// meaningful, though, since we dk which tests are correlated.
Some((cat, PerfDelta { max, mean, min }))
}
})
.collect();
PerfReport { deltas }
}
/// Collapses the `PerfReport` into a `HashMap` over `Importance`, with
/// each importance category having its tests contained.
fn collapse(self) -> HashMap<Importance, CategoryInfo> {
let mut categories = HashMap::<Importance, HashMap<String, _>>::default();
for entry in self.tests {
if let Some(mdata) = entry.1
&& let Ok(timings) = entry.2
{
if let Some(handle) = categories.get_mut(&mdata.importance) {
handle.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight));
} else {
let mut new = HashMap::default();
new.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight));
categories.insert(mdata.importance, new);
}
}
}
categories
}
}
impl std::fmt::Display for Output {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Don't print the header for an empty run.
if self.tests.is_empty() {
return Ok(());
}
// We want to print important tests at the top, then alphabetical.
let mut sorted = self.clone();
sorted.sort();
// Markdown header for making a nice little table :>
writeln!(
f,
"| Command | Iter/sec | Mean [ms] | SD [ms] | Iterations | Importance (weight) |",
)?;
writeln!(f, "|:---|---:|---:|---:|---:|---:|")?;
for (name, metadata, timings) in &sorted.tests {
match metadata {
Some(metadata) => match timings {
// Happy path.
Ok(timings) => {
// If the test succeeded, then metadata.iterations is Some(_).
writeln!(
f,
"| {} | {:.2} | {} | {:.2} | {} | {} ({}) |",
name,
timings.iters_per_sec(metadata.iterations.unwrap()),
{
// Very small mean runtimes will give inaccurate
// results. Should probably also penalise weight.
let mean = timings.mean.as_secs_f64() * 1000.;
if mean < consts::NOISE_CUTOFF.as_secs_f64() * 1000. / 8. {
format!("{mean:.2} (unreliable)")
} else {
format!("{mean:.2}")
}
},
timings.stddev.as_secs_f64() * 1000.,
metadata.iterations.unwrap(),
metadata.importance,
metadata.weight,
)?;
}
// We have (some) metadata, but the test errored.
Err(err) => writeln!(
f,
"| ({}) {} | N/A | N/A | N/A | {} | {} ({}) |",
err,
name,
metadata
.iterations
.map_or_else(|| "N/A".to_owned(), |i| format!("{i}")),
metadata.importance,
metadata.weight
)?,
},
// No metadata, couldn't even parse the test output.
None => writeln!(
f,
"| ({}) {} | N/A | N/A | N/A | N/A | N/A |",
timings.as_ref().unwrap_err(),
name
)?,
}
}
Ok(())
}
}
/// The difference in performance between two runs within a given importance
/// category.
struct PerfDelta {
/// The biggest improvement / least bad regression.
max: f64,
/// The weighted average change in test times.
mean: f64,
/// The worst regression / smallest improvement.
min: f64,
}
/// Shim type for reporting all performance deltas across importance categories.
pub struct PerfReport {
/// Inner (group, diff) pairing.
deltas: HashMap<Importance, PerfDelta>,
}
impl std::fmt::Display for PerfReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.deltas.is_empty() {
return write!(f, "(no matching tests)");
}
let sorted = self.deltas.iter().collect::<Vec<_>>();
writeln!(f, "| Category | Max | Mean | Min |")?;
// We don't want to print too many newlines at the end, so handle newlines
// a little jankily like this.
write!(f, "|:---|---:|---:|---:|")?;
for (cat, delta) in sorted.into_iter().rev() {
const SIGN_POS: &str = "↑";
const SIGN_NEG: &str = "↓";
const SIGN_NEUTRAL_POS: &str = "±↑";
const SIGN_NEUTRAL_NEG: &str = "±↓";
let prettify = |time: f64| {
let sign = if time > 0.05 {
SIGN_POS
} else if time > 0. {
SIGN_NEUTRAL_POS
} else if time > -0.05 {
SIGN_NEUTRAL_NEG
} else {
SIGN_NEG
};
format!("{} {:.1}%", sign, time.abs() * 100.)
};
// Pretty-print these instead of just using the float display impl.
write!(
f,
"\n| {cat} | {} | {} | {} |",
prettify(delta.max),
prettify(delta.mean),
prettify(delta.min)
)?;
}
Ok(())
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Some constants and datatypes used in the Zed perf profiler. Should only be
//! consumed by the crate providing the matching macros.
//!
//! For usage documentation, see the docs on this crate's binary.
mod implementation;
pub use implementation::*;
+582
View File
@@ -0,0 +1,582 @@
//! Perf profiler for Zed tests. Outputs timings of tests marked with the `#[perf]`
//! attribute to stdout in Markdown. See the documentation of `util_macros::perf`
//! for usage details on the actual attribute.
//!
//! # Setup
//! Make sure `hyperfine` is installed and in the shell path.
//!
//! # Usage
//! Calling this tool rebuilds the targeted crate(s) with some cfg flags set for the
//! perf proc macro *and* enables optimisations (`release-fast` profile), so expect
//! it to take a little while.
//!
//! To test an individual crate, run:
//! ```sh
//! cargo perf-test -p $CRATE
//! ```
//!
//! To test everything (which will be **VERY SLOW**), run:
//! ```sh
//! cargo perf-test --workspace
//! ```
//!
//! Some command-line parameters are also recognised by this profiler. To filter
//! out all tests below a certain importance (e.g. `important`), run:
//! ```sh
//! cargo perf-test $WHATEVER -- --important
//! ```
//!
//! Similarly, to skip outputting progress to the command line, pass `-- --quiet`.
//! These flags can be combined.
//!
//! ## Comparing runs
//! Passing `--json=ident` will save per-crate run files in `.perf-runs`, e.g.
//! `cargo perf-test -p gpui -- --json=blah` will result in `.perf-runs/blah.gpui.json`
//! being created (unless no tests were run). These results can be automatically
//! compared. To do so, run `cargo perf-compare new-ident old-ident`.
//!
//! To save the markdown output to a file instead, run `cargo perf-compare --save=$FILE
//! new-ident old-ident`.
//!
//! NB: All files matching `.perf-runs/ident.*.json` will be considered when
//! doing this comparison, so ensure there aren't leftover files in your `.perf-runs`
//! directory that might match that!
//!
//! # Notes
//! This should probably not be called manually unless you're working on the profiler
//! itself; use the `cargo perf-test` alias (after building this crate) instead.
mod implementation;
use implementation::{FailKind, Importance, Output, TestMdata, Timings, consts};
use std::{
fs::OpenOptions,
io::{Read, Write},
num::NonZero,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
/// How many iterations to attempt the first time a test is run.
const DEFAULT_ITER_COUNT: NonZero<usize> = NonZero::new(3).unwrap();
/// Multiplier for the iteration count when a test doesn't pass the noise cutoff.
const ITER_COUNT_MUL: NonZero<usize> = NonZero::new(4).unwrap();
/// Do we keep stderr empty while running the tests?
static QUIET: AtomicBool = AtomicBool::new(false);
/// Report a failure into the output and skip an iteration.
macro_rules! fail {
($output:ident, $name:expr, $kind:expr) => {{
$output.failure($name, None, None, $kind);
continue;
}};
($output:ident, $name:expr, $mdata:expr, $kind:expr) => {{
$output.failure($name, Some($mdata), None, $kind);
continue;
}};
($output:ident, $name:expr, $mdata:expr, $count:expr, $kind:expr) => {{
$output.failure($name, Some($mdata), Some($count), $kind);
continue;
}};
}
/// How does this perf run return its output?
enum OutputKind<'a> {
/// Print markdown to the terminal.
Markdown,
/// Save JSON to a file.
Json(&'a Path),
}
impl OutputKind<'_> {
/// Logs the output of a run as per the `OutputKind`.
fn log(&self, output: &Output, t_bin: &str) {
match self {
OutputKind::Markdown => println!("{output}"),
OutputKind::Json(ident) => {
// We're going to be in tooling/perf/$whatever.
let wspace_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("..")
.join("..");
let runs_dir = PathBuf::from(&wspace_dir).join(consts::RUNS_DIR);
std::fs::create_dir_all(&runs_dir).unwrap();
assert!(
!ident.to_string_lossy().is_empty(),
"FATAL: Empty filename specified!"
);
// Get the test binary's crate's name; a path like
// target/release-fast/deps/gpui-061ff76c9b7af5d7
// would be reduced to just "gpui".
let test_bin_stripped = Path::new(t_bin)
.file_name()
.unwrap()
.to_str()
.unwrap()
.rsplit_once('-')
.unwrap()
.0;
let mut file_path = runs_dir.join(ident);
file_path
.as_mut_os_string()
.push(format!(".{test_bin_stripped}.json"));
let mut out_file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&file_path)
.unwrap();
out_file
.write_all(&serde_json::to_vec(&output).unwrap())
.unwrap();
if !QUIET.load(Ordering::Relaxed) {
eprintln!("JSON output written to {}", file_path.display());
}
}
}
}
}
/// Runs a given metadata-returning function from a test handler, parsing its
/// output into a `TestMdata`.
fn parse_mdata(t_bin: &str, mdata_fn: &str) -> Result<TestMdata, FailKind> {
let mut cmd = Command::new(t_bin);
cmd.args([mdata_fn, "--exact", "--nocapture"]);
let out = cmd
.output()
.expect("FATAL: Could not run test binary {t_bin}");
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
let mut version = None;
let mut iterations = None;
let mut importance = Importance::default();
let mut weight = consts::WEIGHT_DEFAULT;
for line in stdout
.lines()
.filter_map(|l| l.strip_prefix(consts::MDATA_LINE_PREF))
{
let mut items = line.split_whitespace();
// For v0, we know the ident always comes first, then one field.
match items.next().ok_or(FailKind::BadMetadata)? {
consts::VERSION_LINE_NAME => {
let v = items
.next()
.ok_or(FailKind::BadMetadata)?
.parse::<u32>()
.map_err(|_| FailKind::BadMetadata)?;
if v > consts::MDATA_VER {
return Err(FailKind::VersionMismatch);
}
version = Some(v);
}
consts::ITER_COUNT_LINE_NAME => {
// This should never be zero!
iterations = Some(
items
.next()
.ok_or(FailKind::BadMetadata)?
.parse::<usize>()
.map_err(|_| FailKind::BadMetadata)?
.try_into()
.map_err(|_| FailKind::BadMetadata)?,
);
}
consts::IMPORTANCE_LINE_NAME => {
importance = match items.next().ok_or(FailKind::BadMetadata)? {
"critical" => Importance::Critical,
"important" => Importance::Important,
"average" => Importance::Average,
"iffy" => Importance::Iffy,
"fluff" => Importance::Fluff,
_ => return Err(FailKind::BadMetadata),
};
}
consts::WEIGHT_LINE_NAME => {
weight = items
.next()
.ok_or(FailKind::BadMetadata)?
.parse::<u8>()
.map_err(|_| FailKind::BadMetadata)?;
}
_ => unreachable!(),
}
}
Ok(TestMdata {
version: version.ok_or(FailKind::BadMetadata)?,
// Iterations may be determined by us and thus left unspecified.
iterations,
// In principle this should always be set, but just for the sake of
// stability allow the potentially-breaking change of not reporting the
// importance without erroring. Maybe we want to change this.
importance,
// Same with weight.
weight,
})
}
/// Compares the perf results of two profiles as per the arguments passed in.
fn compare_profiles(args: &[String]) {
let mut save_to = None;
let mut ident_idx = 0;
args.first().inspect(|a| {
if a.starts_with("--save") {
save_to = Some(
a.strip_prefix("--save=")
.expect("FATAL: save param formatted incorrectly"),
);
ident_idx = 1;
}
});
let ident_new = args
.get(ident_idx)
.expect("FATAL: missing identifier for new run");
let ident_old = args
.get(ident_idx + 1)
.expect("FATAL: missing identifier for old run");
let wspace_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let runs_dir = PathBuf::from(&wspace_dir)
.join("..")
.join("..")
.join(consts::RUNS_DIR);
// Use the blank outputs initially, so we can merge into these with prefixes.
let mut outputs_new = Output::blank();
let mut outputs_old = Output::blank();
for e in runs_dir.read_dir().unwrap() {
let Ok(entry) = e else {
continue;
};
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_file() {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
// A little helper to avoid code duplication. Reads the `output` from
// a json file, then merges it into what we have so far.
let read_into = |output: &mut Output| {
let mut elems = name.split('.').skip(1);
let prefix = elems.next().unwrap();
assert_eq!("json", elems.next().unwrap());
assert!(elems.next().is_none());
let mut buffer = Vec::new();
let _ = OpenOptions::new()
.read(true)
.open(entry.path())
.unwrap()
.read_to_end(&mut buffer)
.unwrap();
let o_other: Output = serde_json::from_slice(&buffer).unwrap();
output.merge(o_other, prefix);
};
if name.starts_with(ident_old) {
read_into(&mut outputs_old);
} else if name.starts_with(ident_new) {
read_into(&mut outputs_new);
}
}
}
let res = outputs_new.compare_perf(outputs_old);
if let Some(filename) = save_to {
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)
.expect("FATAL: couldn't save run results to file");
file.write_all(format!("{res}").as_bytes()).unwrap();
} else {
println!("{res}");
}
}
/// Runs a test binary, filtering out tests which aren't marked for perf triage
/// and giving back the list of tests we care about.
///
/// The output of this is an iterator over `test_fn_name, test_mdata_name`.
fn get_tests(t_bin: &str) -> impl ExactSizeIterator<Item = (String, String)> {
let mut cmd = Command::new(t_bin);
// --format=json is nightly-only :(
cmd.args(["--list", "--format=terse"]);
let out = cmd
.output()
.expect("FATAL: Could not run test binary {t_bin}");
assert!(
out.status.success(),
"FATAL: Cannot do perf check - test binary {t_bin} returned an error"
);
if !QUIET.load(Ordering::Relaxed) {
eprintln!("Test binary ran successfully; starting profile...");
}
// Parse the test harness output to look for tests we care about.
let stdout = String::from_utf8_lossy(&out.stdout);
let mut test_list: Vec<_> = stdout
.lines()
.filter_map(|line| {
// This should split only in two; e.g.,
// "app::test::test_arena: test" => "app::test::test_arena:", "test"
let line: Vec<_> = line.split_whitespace().collect();
match line[..] {
// Final byte of t_name is ":", which we need to ignore.
[t_name, kind] => (kind == "test").then(|| &t_name[..t_name.len() - 1]),
_ => None,
}
})
// Exclude tests that aren't marked for perf triage based on suffix.
.filter(|t_name| {
t_name.ends_with(consts::SUF_NORMAL) || t_name.ends_with(consts::SUF_MDATA)
})
.collect();
// Pulling itertools just for .dedup() would be quite a big dependency that's
// not used elsewhere, so do this on a vec instead.
test_list.sort_unstable();
test_list.dedup();
// Tests should come in pairs with their mdata fn!
assert!(
test_list.len().is_multiple_of(2),
"Malformed tests in test binary {t_bin}"
);
let out = test_list
.chunks_exact_mut(2)
.map(|pair| {
// Be resilient against changes to these constants.
if consts::SUF_NORMAL < consts::SUF_MDATA {
(pair[0].to_owned(), pair[1].to_owned())
} else {
(pair[1].to_owned(), pair[0].to_owned())
}
})
.collect::<Vec<_>>();
out.into_iter()
}
/// Runs the specified test `count` times, returning the time taken if the test
/// succeeded.
#[inline]
fn spawn_and_iterate(t_bin: &str, t_name: &str, count: NonZero<usize>) -> Option<Duration> {
let mut cmd = Command::new(t_bin);
cmd.args([t_name, "--exact"]);
cmd.env(consts::ITER_ENV_VAR, format!("{count}"));
// Don't let the child muck up our stdin/out/err.
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
let pre = Instant::now();
// Discard the output beyond ensuring success.
let out = cmd.spawn().unwrap().wait();
let post = Instant::now();
out.iter().find_map(|s| s.success().then_some(post - pre))
}
/// Triage a test to determine the correct number of iterations that it should run.
/// Specifically, repeatedly runs the given test until its execution time exceeds
/// `thresh`, calling `step(iterations)` after every failed run to determine the new
/// iteration count. Returns `None` if the test errored or `step` returned `None`,
/// else `Some(iterations)`.
///
/// # Panics
/// This will panic if `step(usize)` is not monotonically increasing, or if the test
/// binary is invalid.
fn triage_test(
t_bin: &str,
t_name: &str,
thresh: Duration,
mut step: impl FnMut(NonZero<usize>) -> Option<NonZero<usize>>,
) -> Option<NonZero<usize>> {
let mut iter_count = DEFAULT_ITER_COUNT;
// It's possible that the first loop of a test might be an outlier (e.g. it's
// doing some caching), in which case we want to skip it.
let duration_once = spawn_and_iterate(t_bin, t_name, NonZero::new(1).unwrap())?;
loop {
let duration = spawn_and_iterate(t_bin, t_name, iter_count)?;
if duration.saturating_sub(duration_once) > thresh {
break Some(iter_count);
}
let new = step(iter_count)?;
assert!(
new > iter_count,
"FATAL: step must be monotonically increasing"
);
iter_count = new;
}
}
/// Try to find the hyperfine binary the user has installed.
fn hyp_binary() -> Option<Command> {
const HYP_PATH: &str = "hyperfine";
const HYP_HOME: &str = "~/.cargo/bin/hyperfine";
if Command::new(HYP_PATH).output().is_err() {
if Command::new(HYP_HOME).output().is_err() {
None
} else {
Some(Command::new(HYP_HOME))
}
} else {
Some(Command::new(HYP_PATH))
}
}
/// Profiles a given test with hyperfine, returning the mean and standard deviation
/// for its runtime. If the test errors, returns `None` instead.
fn hyp_profile(t_bin: &str, t_name: &str, iterations: NonZero<usize>) -> Option<Timings> {
let mut perf_cmd = hyp_binary().expect("Couldn't find the Hyperfine binary on the system");
// Warm up the cache and print markdown output to stdout, which we parse.
perf_cmd.args([
"--style",
"none",
"--warmup",
"1",
"--export-markdown",
"-",
// Parse json instead...
"--time-unit",
"millisecond",
&format!("{t_bin} --exact {t_name}"),
]);
perf_cmd.env(consts::ITER_ENV_VAR, format!("{iterations}"));
let p_out = perf_cmd.output().unwrap();
if !p_out.status.success() {
return None;
}
let cmd_output = String::from_utf8_lossy(&p_out.stdout);
// Can't use .last() since we have a trailing newline. Sigh.
let results_line = cmd_output.lines().nth(3).unwrap();
// Grab the values out of the pretty-print.
// TODO: Parse json instead.
let mut res_iter = results_line.split_whitespace();
// Durations are given in milliseconds, so account for that.
let mean = Duration::from_secs_f64(res_iter.nth(5).unwrap().parse::<f64>().unwrap() / 1000.);
let stddev = Duration::from_secs_f64(res_iter.nth(1).unwrap().parse::<f64>().unwrap() / 1000.);
Some(Timings { mean, stddev })
}
fn main() {
let args = std::env::args().collect::<Vec<_>>();
// We get passed the test we need to run as the 1st argument after our own name.
let t_bin = args
.get(1)
.expect("FATAL: No test binary or command; this shouldn't be manually invoked!");
// We're being asked to compare two results, not run the profiler.
if t_bin == "compare" {
compare_profiles(&args[2..]);
return;
}
// Minimum test importance we care about this run.
let mut thresh = Importance::Iffy;
// Where to print the output of this run.
let mut out_kind = OutputKind::Markdown;
for arg in args.iter().skip(2) {
match arg.as_str() {
"--critical" => thresh = Importance::Critical,
"--important" => thresh = Importance::Important,
"--average" => thresh = Importance::Average,
"--iffy" => thresh = Importance::Iffy,
"--fluff" => thresh = Importance::Fluff,
"--quiet" => QUIET.store(true, Ordering::Relaxed),
s if s.starts_with("--json") => {
out_kind = OutputKind::Json(Path::new(
s.strip_prefix("--json=")
.expect("FATAL: Invalid json parameter; pass --json=ident"),
));
}
_ => (),
}
}
if !QUIET.load(Ordering::Relaxed) {
eprintln!("Starting perf check");
}
let mut output = Output::default();
// Spawn and profile an instance of each perf-sensitive test, via hyperfine.
// Each test is a pair of (test, metadata-returning-fn), so grab both. We also
// know the list is sorted.
let i = get_tests(t_bin);
let len = i.len();
for (idx, (ref t_name, ref t_mdata)) in i.enumerate() {
if !QUIET.load(Ordering::Relaxed) {
eprint!("\rProfiling test {}/{}", idx + 1, len);
}
// Pretty-printable stripped name for the test.
let t_name_pretty = t_name.replace(consts::SUF_NORMAL, "");
// Get the metadata this test reports for us.
let t_mdata = match parse_mdata(t_bin, t_mdata) {
Ok(mdata) => mdata,
Err(err) => fail!(output, t_name_pretty, err),
};
if t_mdata.importance < thresh {
fail!(output, t_name_pretty, t_mdata, FailKind::Skipped);
}
// Time test execution to see how many iterations we need to do in order
// to account for random noise. This is skipped for tests with fixed
// iteration counts.
let final_iter_count = t_mdata.iterations.or_else(|| {
triage_test(t_bin, t_name, consts::NOISE_CUTOFF, |c| {
if let Some(c) = c.checked_mul(ITER_COUNT_MUL) {
Some(c)
} else {
// This should almost never happen, but maybe..?
eprintln!(
"WARNING: Ran nearly usize::MAX iterations of test {t_name_pretty}; skipping"
);
None
}
})
});
// Don't profile failing tests.
let Some(final_iter_count) = final_iter_count else {
fail!(output, t_name_pretty, t_mdata, FailKind::Triage);
};
// Now profile!
if let Some(timings) = hyp_profile(t_bin, t_name, final_iter_count) {
output.success(t_name_pretty, t_mdata, final_iter_count, timings);
} else {
fail!(
output,
t_name_pretty,
t_mdata,
final_iter_count,
FailKind::Profile
);
}
}
if !QUIET.load(Ordering::Relaxed) {
if output.is_empty() {
eprintln!("Nothing to do.");
} else {
// If stdout and stderr are on the same terminal, move us after the
// output from above.
eprintln!();
}
}
// No need making an empty json file on every empty test bin.
if output.is_empty() {
return;
}
out_kind.log(&output, t_bin);
}
+55
View File
@@ -0,0 +1,55 @@
use gpui::{Action, actions};
use gpui_macros::register_action;
use schemars::JsonSchema;
use serde::Deserialize;
#[test]
fn test_action_macros() {
actions!(
test_only,
[
SomeAction,
/// Documented action
SomeActionWithDocs,
]
);
#[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)]
#[action(namespace = test_only)]
#[serde(deny_unknown_fields)]
struct AnotherAction;
#[derive(PartialEq, Clone, gpui::private::serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RegisterableAction {}
register_action!(RegisterableAction);
impl gpui::Action for RegisterableAction {
fn boxed_clone(&self) -> Box<dyn gpui::Action> {
unimplemented!()
}
fn partial_eq(&self, _action: &dyn gpui::Action) -> bool {
unimplemented!()
}
fn name(&self) -> &'static str {
unimplemented!()
}
fn name_for_type() -> &'static str
where
Self: Sized,
{
unimplemented!()
}
fn build(_value: serde_json::Value) -> anyhow::Result<Box<dyn gpui::Action>>
where
Self: Sized,
{
unimplemented!()
}
}
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "xtask"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[dependencies]
annotate-snippets = "0.12.1"
anyhow.workspace = true
backtrace.workspace = true
cargo_metadata.workspace = true
cargo_toml.workspace = true
clap = { workspace = true, features = ["derive"] }
toml.workspace = true
indoc.workspace = true
indexmap.workspace = true
itertools.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml = "0.9.34"
strum.workspace = true
toml_edit.workspace = true
gh-workflow.workspace = true
+1
View File
@@ -0,0 +1 @@
../../LICENSE-GPL
+43
View File
@@ -0,0 +1,43 @@
mod tasks;
mod workspace;
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "cargo xtask")]
struct Args {
#[command(subcommand)]
command: CliCommand,
}
#[derive(Subcommand)]
enum CliCommand {
/// Runs `cargo clippy`.
Clippy(tasks::clippy::ClippyArgs),
Licenses(tasks::licenses::LicensesArgs),
/// Checks that packages conform to a set of standards.
PackageConformity(tasks::package_conformity::PackageConformityArgs),
/// Publishes GPUI and its dependencies to crates.io.
PublishGpui(tasks::publish_gpui::PublishGpuiArgs),
/// Builds GPUI web examples and serves them.
WebExamples(tasks::web_examples::WebExamplesArgs),
Workflows(tasks::workflows::GenerateWorkflowArgs),
CheckWorkflows(tasks::workflow_checks::WorkflowValidationArgs),
}
fn main() -> Result<()> {
let args = Args::parse();
match args.command {
CliCommand::Clippy(args) => tasks::clippy::run_clippy(args),
CliCommand::Licenses(args) => tasks::licenses::run_licenses(args),
CliCommand::PackageConformity(args) => {
tasks::package_conformity::run_package_conformity(args)
}
CliCommand::PublishGpui(args) => tasks::publish_gpui::run_publish_gpui(args),
CliCommand::WebExamples(args) => tasks::web_examples::run_web_examples(args),
CliCommand::Workflows(args) => tasks::workflows::run_workflows(args),
CliCommand::CheckWorkflows(args) => tasks::workflow_checks::validate(args),
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod clippy;
pub mod licenses;
pub mod package_conformity;
pub mod publish_gpui;
pub mod web_examples;
pub mod workflow_checks;
pub mod workflows;
+64
View File
@@ -0,0 +1,64 @@
#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
use std::process::Command;
use anyhow::{Context as _, Result, bail};
use clap::Parser;
#[derive(Parser)]
pub struct ClippyArgs {
/// Automatically apply lint suggestions (`clippy --fix`).
#[arg(long)]
fix: bool,
/// The package to run Clippy against (`cargo -p <PACKAGE> clippy`).
#[arg(long, short)]
package: Option<String>,
}
pub fn run_clippy(args: ClippyArgs) -> Result<()> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let mut clippy_command = Command::new(&cargo);
clippy_command.arg("clippy");
if let Some(package) = args.package.as_ref() {
clippy_command.args(["--package", package]);
} else {
clippy_command.arg("--workspace");
}
clippy_command
.arg("--release")
.arg("--all-targets")
.arg("--all-features");
if args.fix {
clippy_command.arg("--fix");
}
clippy_command.arg("--");
// Deny all warnings.
clippy_command.args(["--deny", "warnings"]);
eprintln!(
"running: {cargo} {}",
clippy_command
.get_args()
.map(|arg| arg.to_str().unwrap())
.collect::<Vec<_>>()
.join(" ")
);
let exit_status = clippy_command
.spawn()
.context("failed to spawn child process")?
.wait()
.context("failed to wait for child process")?;
if !exit_status.success() {
bail!("clippy failed: {}", exit_status);
}
Ok(())
}
+45
View File
@@ -0,0 +1,45 @@
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use clap::Parser;
use crate::workspace::load_workspace;
#[derive(Parser)]
pub struct LicensesArgs {}
pub fn run_licenses(_args: LicensesArgs) -> Result<()> {
const LICENSE_FILES: &[&str] = &["LICENSE-APACHE", "LICENSE-GPL", "LICENSE-AGPL"];
let workspace = load_workspace()?;
for package in workspace.workspace_packages() {
let crate_dir = package
.manifest_path
.parent()
.with_context(|| format!("no crate directory for {}", package.name))?;
if let Some(license_file) = first_license_file(crate_dir, LICENSE_FILES) {
if !license_file.is_symlink() {
println!("{} is not a symlink", license_file.display());
}
continue;
}
println!("Missing license: {}", package.name);
}
Ok(())
}
fn first_license_file(path: impl AsRef<Path>, license_files: &[&str]) -> Option<PathBuf> {
for license_file in license_files {
let path_to_license = path.as_ref().join(license_file);
if path_to_license.exists() {
return Some(path_to_license);
}
}
None
}
@@ -0,0 +1,75 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use anyhow::{Context as _, Result};
use cargo_toml::{Dependency, Manifest};
use clap::Parser;
use crate::workspace::load_workspace;
#[derive(Parser)]
pub struct PackageConformityArgs {}
pub fn run_package_conformity(_args: PackageConformityArgs) -> Result<()> {
let workspace = load_workspace()?;
let mut non_workspace_dependencies = BTreeMap::new();
for package in workspace.workspace_packages() {
let is_extension = package
.manifest_path
.parent()
.and_then(|parent| parent.parent())
.is_some_and(|grandparent_dir| grandparent_dir.ends_with("extensions"));
let cargo_toml = read_cargo_toml(&package.manifest_path)?;
let is_using_workspace_lints = cargo_toml.lints.is_some_and(|lints| lints.workspace);
if !is_using_workspace_lints {
eprintln!(
"{package:?} is not using workspace lints",
package = package.name
);
}
// Extensions should not use workspace dependencies.
if is_extension || package.name == "zed_extension_api" {
continue;
}
for dependencies in [
&cargo_toml.dependencies,
&cargo_toml.dev_dependencies,
&cargo_toml.build_dependencies,
] {
for (name, dependency) in dependencies {
if let Dependency::Inherited(_) = dependency {
continue;
}
non_workspace_dependencies
.entry(name.to_owned())
.or_insert_with(Vec::new)
.push(package.name.clone());
}
}
}
for (dependency, packages) in non_workspace_dependencies {
eprintln!(
"{dependency} is being used as a non-workspace dependency: {}",
packages.join(", ")
);
}
Ok(())
}
/// Returns the contents of the `Cargo.toml` file at the given path.
fn read_cargo_toml(path: impl AsRef<Path>) -> Result<Manifest> {
let path = path.as_ref();
let cargo_toml_bytes = fs::read(path)?;
Manifest::from_slice(&cargo_toml_bytes)
.with_context(|| format!("reading Cargo.toml at {path:?}"))
}
+442
View File
@@ -0,0 +1,442 @@
#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
use std::io::{self, Write};
use std::process::{Command, Output, Stdio};
use anyhow::{Context as _, Result, bail};
use clap::Parser;
#[derive(Parser)]
pub struct PublishGpuiArgs {
/// Perform a dry-run and wait for user confirmation before each publish
#[arg(long)]
dry_run: bool,
/// Skip to a specific package (by package name or crate name) and start from there
#[arg(long)]
skip_to: Option<String>,
}
pub fn run_publish_gpui(args: PublishGpuiArgs) -> Result<()> {
println!(
"Starting GPUI publish process{}...",
if args.dry_run { " (with dry-run)" } else { "" }
);
let start_time = std::time::Instant::now();
check_workspace_root()?;
if args.skip_to.is_none() {
check_git_clean()?;
} else {
println!("Skipping git clean check due to --skip-to flag");
}
let version = read_gpui_version()?;
println!("Updating GPUI to version: {}", version);
publish_dependencies(&version, args.dry_run, args.skip_to.as_deref())?;
publish_gpui(&version, args.dry_run)?;
println!("GPUI published in {}s", start_time.elapsed().as_secs_f32());
Ok(())
}
fn read_gpui_version() -> Result<String> {
let gpui_cargo_toml_path = "crates/gpui/Cargo.toml";
let contents = std::fs::read_to_string(gpui_cargo_toml_path)
.context("Failed to read crates/gpui/Cargo.toml")?;
let cargo_toml: toml::Value =
toml::from_str(&contents).context("Failed to parse crates/gpui/Cargo.toml")?;
let version = cargo_toml
.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.context("Failed to find version in crates/gpui/Cargo.toml")?;
Ok(version.to_string())
}
fn publish_dependencies(new_version: &str, dry_run: bool, skip_to: Option<&str>) -> Result<()> {
let gpui_dependencies = vec![
("collections", "gpui_collections", "crates"),
("perf", "gpui_perf", "tooling"),
("util_macros", "gpui_util_macros", "crates"),
("util", "gpui_util", "crates"),
("gpui_macros", "gpui-macros", "crates"),
("http_client", "gpui_http_client", "crates"),
(
"derive_refineable",
"gpui_derive_refineable",
"crates/refineable",
),
("refineable", "gpui_refineable", "crates"),
("semantic_version", "gpui_semantic_version", "crates"),
("sum_tree", "gpui_sum_tree", "crates"),
("media", "gpui_media", "crates"),
];
let mut should_skip = skip_to.is_some();
let skip_target = skip_to.unwrap_or("");
for (package_name, crate_name, package_dir) in gpui_dependencies {
if should_skip {
if package_name == skip_target || crate_name == skip_target {
println!("Found skip target: {} ({})", crate_name, package_name);
should_skip = false;
} else {
println!("Skipping: {} ({})", crate_name, package_name);
continue;
}
}
println!(
"Publishing dependency: {} (package: {})",
crate_name, package_name
);
update_crate_cargo_toml(package_name, crate_name, package_dir, new_version)?;
update_workspace_dependency_version(package_name, crate_name, new_version)?;
publish_crate(crate_name, dry_run)?;
}
if should_skip {
bail!(
"Could not find package or crate named '{}' to skip to",
skip_target
);
}
Ok(())
}
fn publish_gpui(new_version: &str, dry_run: bool) -> Result<()> {
update_crate_cargo_toml("gpui", "gpui", "crates", new_version)?;
publish_crate("gpui", dry_run)?;
Ok(())
}
fn update_crate_cargo_toml(
package_name: &str,
crate_name: &str,
package_dir: &str,
new_version: &str,
) -> Result<()> {
let cargo_toml_path = format!("{}/{}/Cargo.toml", package_dir, package_name);
let contents = std::fs::read_to_string(&cargo_toml_path)
.context(format!("Failed to read {}", cargo_toml_path))?;
let updated = update_crate_package_fields(&contents, crate_name, new_version)?;
std::fs::write(&cargo_toml_path, updated)
.context(format!("Failed to write {}", cargo_toml_path))?;
Ok(())
}
fn update_crate_package_fields(
toml_contents: &str,
crate_name: &str,
new_version: &str,
) -> Result<String> {
let mut doc = toml_contents
.parse::<toml_edit::DocumentMut>()
.context("Failed to parse TOML")?;
let package = doc
.get_mut("package")
.and_then(|p| p.as_table_like_mut())
.context("Failed to find [package] section")?;
package.insert("name", toml_edit::value(crate_name));
package.insert("version", toml_edit::value(new_version));
package.insert("publish", toml_edit::value(true));
Ok(doc.to_string())
}
fn publish_crate(crate_name: &str, dry_run: bool) -> Result<()> {
let publish_crate_impl = |crate_name, dry_run| {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let mut command = Command::new(&cargo);
command
.arg("publish")
.arg("--allow-dirty")
.args(["-p", crate_name]);
if dry_run {
command.arg("--dry-run");
}
run_command(&mut command)?;
anyhow::Ok(())
};
if dry_run {
publish_crate_impl(crate_name, true)?;
print!("Press Enter to publish for real (or ctrl-c to abort)...");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
}
publish_crate_impl(crate_name, false)?;
Ok(())
}
fn update_workspace_dependency_version(
package_name: &str,
crate_name: &str,
new_version: &str,
) -> Result<()> {
let workspace_cargo_toml_path = "Cargo.toml";
let contents = std::fs::read_to_string(workspace_cargo_toml_path)
.context("Failed to read workspace Cargo.toml")?;
let mut doc = contents
.parse::<toml_edit::DocumentMut>()
.context("Failed to parse TOML")?;
update_dependency_version_in_doc(&mut doc, package_name, crate_name, new_version)?;
update_profile_override_in_doc(&mut doc, package_name, crate_name)?;
std::fs::write(workspace_cargo_toml_path, doc.to_string())
.context("Failed to write workspace Cargo.toml")?;
Ok(())
}
fn update_dependency_version_in_doc(
doc: &mut toml_edit::DocumentMut,
package_name: &str,
crate_name: &str,
new_version: &str,
) -> Result<()> {
let dependency = doc
.get_mut("workspace")
.and_then(|w| w.get_mut("dependencies"))
.and_then(|d| d.get_mut(package_name))
.context(format!(
"Failed to find {} in workspace dependencies",
package_name
))?;
if let Some(dep_table) = dependency.as_table_like_mut() {
dep_table.insert("version", toml_edit::value(new_version));
dep_table.insert("package", toml_edit::value(crate_name));
} else {
bail!("{} is not a table in workspace dependencies", package_name);
}
Ok(())
}
fn update_profile_override_in_doc(
doc: &mut toml_edit::DocumentMut,
package_name: &str,
crate_name: &str,
) -> Result<()> {
if let Some(profile_dev_package) = doc
.get_mut("profile")
.and_then(|p| p.get_mut("dev"))
.and_then(|d| d.get_mut("package"))
.and_then(|p| p.as_table_like_mut())
{
if let Some(old_entry) = profile_dev_package.get(package_name) {
let old_entry_clone = old_entry.clone();
profile_dev_package.remove(package_name);
profile_dev_package.insert(crate_name, old_entry_clone);
}
}
Ok(())
}
fn check_workspace_root() -> Result<()> {
let cwd = std::env::current_dir().context("Failed to get current directory")?;
// Check if Cargo.toml exists in the current directory
let cargo_toml_path = cwd.join("Cargo.toml");
if !cargo_toml_path.exists() {
bail!(
"Cargo.toml not found in current directory. Please run this command from the workspace root."
);
}
// Check if it's a workspace by looking for [workspace] section
let contents =
std::fs::read_to_string(&cargo_toml_path).context("Failed to read Cargo.toml")?;
if !contents.contains("[workspace]") {
bail!(
"Current directory does not appear to be a workspace root. Please run this command from the workspace root."
);
}
Ok(())
}
fn check_git_clean() -> Result<()> {
let output = run_command(
Command::new("git")
.args(["status", "--porcelain"])
.stdout(Stdio::piped())
.stderr(Stdio::piped()),
)?;
if !output.status.success() {
bail!("git status command failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
bail!(
"Working directory is not clean. Please commit or stash your changes before publishing."
);
}
Ok(())
}
fn run_command(command: &mut Command) -> Result<Output> {
let command_str = {
let program = command.get_program().to_string_lossy();
let args = command
.get_args()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
if args.is_empty() {
program.to_string()
} else {
format!("{} {}", program, args)
}
};
eprintln!("+ {}", command_str);
let output = command
.spawn()
.context("failed to spawn child process")?
.wait_with_output()
.context("failed to wait for child process")?;
if !output.status.success() {
bail!("Command failed with status {}", output.status);
}
Ok(output)
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use super::*;
#[test]
fn test_update_dependency_version_in_toml() {
let input = indoc! {r#"
[workspace]
resolver = "2"
[workspace.dependencies]
# here's a comment
collections = { path = "crates/collections" }
util = { path = "crates/util", package = "zed-util", version = "0.1.0" }
"#};
let mut doc = input.parse::<toml_edit::DocumentMut>().unwrap();
update_dependency_version_in_doc(&mut doc, "collections", "gpui_collections", "0.2.0")
.unwrap();
let result = doc.to_string();
let output = indoc! {r#"
[workspace]
resolver = "2"
[workspace.dependencies]
# here's a comment
collections = { path = "crates/collections" , version = "0.2.0", package = "gpui_collections" }
util = { path = "crates/util", package = "zed-util", version = "0.1.0" }
"#};
assert_eq!(result, output);
}
#[test]
fn test_update_crate_package_fields() {
let input = indoc! {r#"
[package]
name = "collections"
version = "0.1.0"
edition = "2021"
publish = false
# some comment about the license
license = "GPL-3.0-or-later"
[dependencies]
serde = "1.0"
"#};
let result = update_crate_package_fields(input, "gpui_collections", "0.2.0").unwrap();
let output = indoc! {r#"
[package]
name = "gpui_collections"
version = "0.2.0"
edition = "2021"
publish = true
# some comment about the license
license = "GPL-3.0-or-later"
[dependencies]
serde = "1.0"
"#};
assert_eq!(result, output);
}
#[test]
fn test_update_profile_override_in_toml() {
let input = indoc! {r#"
[profile.dev]
split-debuginfo = "unpacked"
[profile.dev.package]
taffy = { opt-level = 3 }
collections = { codegen-units = 256 }
refineable = { codegen-units = 256 }
util = { codegen-units = 256 }
"#};
let mut doc = input.parse::<toml_edit::DocumentMut>().unwrap();
update_profile_override_in_doc(&mut doc, "collections", "gpui_collections").unwrap();
let result = doc.to_string();
let output = indoc! {r#"
[profile.dev]
split-debuginfo = "unpacked"
[profile.dev.package]
taffy = { opt-level = 3 }
refineable = { codegen-units = 256 }
util = { codegen-units = 256 }
gpui_collections = { codegen-units = 256 }
"#};
assert_eq!(result, output);
}
}
+338
View File
@@ -0,0 +1,338 @@
#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
use std::io::Write;
use std::path::Path;
use std::process::Command;
use anyhow::{Context as _, Result, bail};
use clap::Parser;
#[derive(Parser)]
pub struct WebExamplesArgs {
#[arg(long)]
pub release: bool,
#[arg(long, default_value = "8080")]
pub port: u16,
#[arg(long)]
pub no_serve: bool,
}
fn check_program(binary: &str, install_hint: &str) -> Result<()> {
match Command::new(binary).arg("--version").output() {
Ok(output) if output.status.success() => Ok(()),
_ => bail!("`{binary}` not found. Install with: {install_hint}"),
}
}
fn discover_examples() -> Result<Vec<String>> {
let examples_dir = Path::new("crates/gpui/examples");
let mut names = Vec::new();
for entry in std::fs::read_dir(examples_dir).context("failed to read crates/gpui/examples")? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) == Some("rs") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
names.push(stem.to_string());
}
}
}
if names.is_empty() {
bail!("no examples found in crates/gpui/examples");
}
names.sort();
Ok(names)
}
pub fn run_web_examples(args: WebExamplesArgs) -> Result<()> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let profile = if args.release { "release" } else { "debug" };
let out_dir = "target/web-examples";
check_program("wasm-bindgen", "cargo install wasm-bindgen-cli")?;
let examples = discover_examples()?;
eprintln!(
"Building {} example(s) for wasm32-unknown-unknown ({profile})...\n",
examples.len()
);
std::fs::create_dir_all(out_dir).context("failed to create output directory")?;
eprintln!("Building all examples...");
let mut cmd = Command::new(&cargo);
cmd.args([
"build",
"--target",
"wasm32-unknown-unknown",
"-p",
"gpui",
"--keep-going",
]);
// 🙈
cmd.env("RUSTC_BOOTSTRAP", "1");
for name in &examples {
cmd.args(["--example", name]);
}
if args.release {
cmd.arg("--release");
}
let _ = cmd.status().context("failed to run cargo build")?;
// Run wasm-bindgen on each .wasm that was produced.
let mut succeeded: Vec<String> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for name in &examples {
let wasm_path = format!("target/wasm32-unknown-unknown/{profile}/examples/{name}.wasm");
if !Path::new(&wasm_path).exists() {
eprintln!("[{name}] SKIPPED (build failed)");
failed.push(name.clone());
continue;
}
eprintln!("[{name}] Running wasm-bindgen...");
let example_dir = format!("{out_dir}/{name}");
std::fs::create_dir_all(&example_dir)
.with_context(|| format!("failed to create {example_dir}"))?;
let status = Command::new("wasm-bindgen")
.args([
&wasm_path,
"--target",
"web",
"--no-typescript",
"--out-dir",
&example_dir,
"--out-name",
name,
])
// 🙈
.env("RUSTC_BOOTSTRAP", "1")
.status()
.context("failed to run wasm-bindgen")?;
if !status.success() {
eprintln!("[{name}] SKIPPED (wasm-bindgen failed)");
failed.push(name.clone());
continue;
}
// Write per-example index.html.
let html_path = format!("{example_dir}/index.html");
std::fs::File::create(&html_path)
.and_then(|mut file| file.write_all(make_example_html(name).as_bytes()))
.with_context(|| format!("failed to write {html_path}"))?;
eprintln!("[{name}] OK");
succeeded.push(name.clone());
}
if succeeded.is_empty() {
bail!("all {} examples failed to build", examples.len());
}
let example_names: Vec<&str> = succeeded.iter().map(|s| s.as_str()).collect();
let index_path = format!("{out_dir}/index.html");
std::fs::File::create(&index_path)
.and_then(|mut file| file.write_all(make_gallery_html(&example_names).as_bytes()))
.context("failed to write index.html")?;
if args.no_serve {
return Ok(());
}
// Serve with COEP/COOP headers required for WebGPU / SharedArrayBuffer.
eprintln!("Serving on http://127.0.0.1:{}...", args.port);
let server_script = format!(
r#"
import http.server
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory="{out_dir}", **kwargs)
def end_headers(self):
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
super().end_headers()
http.server.HTTPServer(("127.0.0.1", {port}), Handler).serve_forever()
"#,
port = args.port,
);
let status = Command::new("python3")
.args(["-c", &server_script])
.status()
.context("failed to run python3 http server (is python3 installed?)")?;
if !status.success() {
bail!("python3 http server exited with: {status}");
}
Ok(())
}
fn make_example_html(name: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPUI Web: {name}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
html, body {{
width: 100%; height: 100%; overflow: hidden;
background: #1e1e2e; color: #cdd6f4;
font-family: system-ui, -apple-system, sans-serif;
}}
canvas {{ display: block; width: 100%; height: 100%; }}
#loading {{
position: fixed; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1.25rem; opacity: 0.6;
}}
#loading.hidden {{ display: none; }}
</style>
</head>
<body>
<div id="loading">Loading {name}…</div>
<script type="module">
import init from './{name}.js';
await init();
document.getElementById('loading').classList.add('hidden');
</script>
</body>
</html>
"#
)
}
fn make_gallery_html(examples: &[&str]) -> String {
let mut buttons = String::new();
for name in examples {
buttons.push_str(&format!(
" <button class=\"example-btn\" data-name=\"{name}\">{name}</button>\n"
));
}
let first = examples.first().copied().unwrap_or("hello_web");
format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPUI Web Examples</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
html, body {{
width: 100%; height: 100%; overflow: hidden;
background: #1e1e2e; color: #cdd6f4;
font-family: system-ui, -apple-system, sans-serif;
}}
#app {{ display: flex; width: 100%; height: 100%; }}
#sidebar {{
width: 240px; min-width: 240px;
background: #181825;
border-right: 1px solid #313244;
display: flex; flex-direction: column;
}}
#sidebar-header {{
padding: 16px 14px 12px;
font-size: 0.8rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: #a6adc8; border-bottom: 1px solid #313244;
}}
#sidebar-header span {{
font-size: 1rem; text-transform: none; letter-spacing: normal;
color: #cdd6f4; display: block; margin-top: 2px;
}}
#example-list {{
flex: 1; overflow-y: auto; padding: 8px 0;
}}
.example-btn {{
display: block; width: 100%;
padding: 8px 14px; border: none;
background: transparent; color: #bac2de;
font-size: 0.85rem; text-align: left;
cursor: pointer;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}}
.example-btn:hover {{ background: #313244; color: #cdd6f4; }}
.example-btn.active {{ background: #45475a; color: #f5e0dc; font-weight: 600; }}
#main {{ flex: 1; display: flex; flex-direction: column; min-width: 0; }}
#toolbar {{
height: 40px; display: flex; align-items: center;
padding: 0 16px; gap: 12px;
background: #1e1e2e; border-bottom: 1px solid #313244;
font-size: 0.8rem; color: #a6adc8;
}}
#current-name {{
font-weight: 600; color: #cdd6f4;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}}
#open-tab {{
margin-left: auto; padding: 4px 10px;
border: 1px solid #585b70; border-radius: 4px;
background: transparent; color: #a6adc8;
font-size: 0.75rem; cursor: pointer;
text-decoration: none;
}}
#open-tab:hover {{ background: #313244; color: #cdd6f4; }}
#viewer {{ flex: 1; border: none; width: 100%; background: #11111b; }}
</style>
</head>
<body>
<div id="app">
<div id="sidebar">
<div id="sidebar-header">
GPUI Examples
<span>{count} available</span>
</div>
<div id="example-list">
{buttons} </div>
</div>
<div id="main">
<div id="toolbar">
<span id="current-name">{first}</span>
<a id="open-tab" href="./{first}/" target="_blank">Open in new tab ↗</a>
</div>
<iframe id="viewer" src="./{first}/"></iframe>
</div>
</div>
<script>
const buttons = document.querySelectorAll('.example-btn');
const viewer = document.getElementById('viewer');
const nameEl = document.getElementById('current-name');
const openEl = document.getElementById('open-tab');
function select(name) {{
buttons.forEach(b => b.classList.toggle('active', b.dataset.name === name));
viewer.src = './' + name + '/';
nameEl.textContent = name;
openEl.href = './' + name + '/';
history.replaceState(null, '', '#' + name);
}}
buttons.forEach(b => b.addEventListener('click', () => select(b.dataset.name)));
const hash = location.hash.slice(1);
if (hash && [...buttons].some(b => b.dataset.name === hash)) {{
select(hash);
}} else {{
select('{first}');
}}
</script>
</body>
</html>
"##,
count = examples.len(),
)
}
+118
View File
@@ -0,0 +1,118 @@
mod check_run_patterns;
use std::{fs, path::PathBuf};
use annotate_snippets::Renderer;
use anyhow::{Result, anyhow};
use clap::Parser;
use itertools::{Either, Itertools};
use serde_yaml::Value;
use strum::IntoEnumIterator;
use crate::tasks::{
workflow_checks::check_run_patterns::{
RunValidationError, WorkflowFile, WorkflowValidationError,
},
workflows::WorkflowType,
};
pub use check_run_patterns::validate_run_command;
#[derive(Default, Parser)]
pub struct WorkflowValidationArgs {}
pub fn validate(_: WorkflowValidationArgs) -> Result<()> {
let (parsing_errors, file_errors): (Vec<_>, Vec<_>) = get_all_workflow_files()
.map(check_workflow)
.flat_map(Result::err)
.partition_map(|error| match error {
WorkflowError::ParseError(error) => Either::Left(error),
WorkflowError::ValidationError(error) => Either::Right(error),
});
if !parsing_errors.is_empty() {
Err(anyhow!(
"Failed to read or parse some workflow files: {}",
parsing_errors.into_iter().join("\n")
))
} else if !file_errors.is_empty() {
let errors: Vec<_> = file_errors
.iter()
.map(|error| error.annotation_group())
.collect();
let renderer =
Renderer::styled().decor_style(annotate_snippets::renderer::DecorStyle::Ascii);
println!("{}", renderer.render(errors.as_slice()));
Err(anyhow!("Workflow checks failed!"))
} else {
Ok(())
}
}
enum WorkflowError {
ParseError(anyhow::Error),
ValidationError(Box<WorkflowValidationError>),
}
fn get_all_workflow_files() -> impl Iterator<Item = PathBuf> {
WorkflowType::iter()
.map(|workflow_type| workflow_type.folder_path())
.flat_map(|folder_path| {
fs::read_dir(folder_path).into_iter().flat_map(|entries| {
entries
.flat_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
})
})
})
}
fn check_workflow(workflow_file_path: PathBuf) -> Result<(), WorkflowError> {
fn collect_errors(
iter: impl Iterator<Item = Result<(), Vec<RunValidationError>>>,
) -> Result<(), Vec<RunValidationError>> {
Some(iter.flat_map(Result::err).flatten().collect::<Vec<_>>())
.filter(|errors| !errors.is_empty())
.map_or(Ok(()), Err)
}
fn check_recursive(key: &Value, value: &Value) -> Result<(), Vec<RunValidationError>> {
match value {
Value::Mapping(mapping) => collect_errors(
mapping
.into_iter()
.map(|(key, value)| check_recursive(key, value)),
),
Value::Sequence(sequence) => collect_errors(
sequence
.into_iter()
.map(|value| check_recursive(key, value)),
),
Value::String(string) => check_string(key, string).map_err(|error| vec![error]),
Value::Null | Value::Bool(_) | Value::Number(_) | Value::Tagged(_) => Ok(()),
}
}
let file_content =
WorkflowFile::load(&workflow_file_path).map_err(WorkflowError::ParseError)?;
check_recursive(&Value::Null, &file_content.parsed_content).map_err(|errors| {
WorkflowError::ValidationError(Box::new(WorkflowValidationError::new(
errors,
file_content,
workflow_file_path,
)))
})
}
fn check_string(key: &Value, value: &str) -> Result<(), RunValidationError> {
match key {
Value::String(key) if key == "run" => validate_run_command(value),
_ => Ok(()),
}
}
@@ -0,0 +1,124 @@
use annotate_snippets::{AnnotationKind, Group, Level, Snippet};
use anyhow::{Result, anyhow};
use regex::Regex;
use serde_yaml::Value;
use std::{
collections::HashMap,
fs,
ops::Range,
path::{Path, PathBuf},
sync::LazyLock,
};
static GITHUB_INPUT_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"\$\{\{[[:blank:]]*([[:alnum:]]|[[:punct:]])+?[[:blank:]]*\}\}"#)
.expect("Should compile")
});
pub struct WorkflowFile {
raw_content: String,
pub parsed_content: Value,
}
impl WorkflowFile {
pub fn load(workflow_file_path: &Path) -> Result<Self> {
fs::read_to_string(workflow_file_path)
.map_err(|_| {
anyhow!(
"Could not read workflow file at {}",
workflow_file_path.display()
)
})
.and_then(|file_content| {
serde_yaml::from_str(&file_content)
.map(|parsed_content| Self {
raw_content: file_content,
parsed_content,
})
.map_err(|e| anyhow!("Failed to parse workflow file: {e:?}"))
})
}
}
pub struct WorkflowValidationError {
file_path: PathBuf,
contents: WorkflowFile,
errors: Vec<RunValidationError>,
}
impl WorkflowValidationError {
pub fn new(
errors: Vec<RunValidationError>,
contents: WorkflowFile,
file_path: PathBuf,
) -> Self {
Self {
file_path,
contents,
errors,
}
}
pub fn annotation_group<'a>(&'a self) -> Group<'a> {
let raw_content = &self.contents.raw_content;
let mut identical_lines = HashMap::new();
let ranges = self
.errors
.iter()
.flat_map(|error| error.found_injection_patterns.iter())
.map(|(line, pattern_range)| {
let initial_offset = identical_lines
.get(&(line.as_str(), pattern_range.start))
.copied()
.unwrap_or_default();
let line_start = raw_content[initial_offset..]
.find(line.as_str())
.map(|offset| offset + initial_offset)
.unwrap_or_default();
let pattern_start = line_start + pattern_range.start;
let pattern_end = pattern_start + pattern_range.len();
identical_lines.insert((line.as_str(), pattern_range.start), pattern_end);
pattern_start..pattern_end
});
Level::ERROR
.primary_title("Found GitHub input injection in run command")
.element(
Snippet::source(&self.contents.raw_content)
.path(self.file_path.display().to_string())
.annotations(ranges.map(|range| {
AnnotationKind::Primary
.span(range)
.label("This should be passed via an environment variable")
})),
)
}
}
pub struct RunValidationError {
found_injection_patterns: Vec<(String, Range<usize>)>,
}
pub fn validate_run_command(command: &str) -> Result<(), RunValidationError> {
let patterns: Vec<_> = command
.lines()
.flat_map(move |line| {
GITHUB_INPUT_PATTERN
.find_iter(line)
.map(|m| (line.to_owned(), m.range()))
})
.collect();
if patterns.is_empty() {
Ok(())
} else {
Err(RunValidationError {
found_injection_patterns: patterns,
})
}
}
+224
View File
@@ -0,0 +1,224 @@
use anyhow::{Context, Result};
use clap::Parser;
use gh_workflow::Workflow;
use std::fs;
use std::path::{Path, PathBuf};
use crate::tasks::workflow_checks::{self};
mod after_release;
mod autofix_pr;
mod bump_patch_version;
mod cherry_pick;
mod compare_perf;
mod danger;
mod deploy_collab;
mod extension_auto_bump;
mod extension_bump;
mod extension_tests;
mod extension_workflow_rollout;
mod extensions;
mod nix_build;
mod publish_extension_cli;
mod release_nightly;
mod run_bundling;
mod release;
mod run_agent_evals;
mod run_tests;
mod runners;
mod steps;
mod vars;
#[derive(Clone)]
pub(crate) struct GitSha(String);
impl AsRef<str> for GitSha {
fn as_ref(&self) -> &str {
&self.0
}
}
#[allow(
clippy::disallowed_methods,
reason = "This runs only in a CLI environment"
)]
fn parse_ref(value: &str) -> Result<GitSha, String> {
const GIT_SHA_LENGTH: usize = 40;
(value.len() == GIT_SHA_LENGTH)
.then_some(value)
.ok_or_else(|| {
format!(
"Git SHA has wrong length! \
Only SHAs with a full length of {GIT_SHA_LENGTH} are supported, found {len} characters.",
len = value.len()
)
})
.and_then(|value| {
let mut tmp = [0; 4];
value
.chars()
.all(|char| u16::from_str_radix(char.encode_utf8(&mut tmp), 16).is_ok()).then_some(value)
.ok_or_else(|| "Not a valid Git SHA".to_owned())
})
.and_then(|sha| {
std::process::Command::new("git")
.args([
"rev-parse",
"--quiet",
"--verify",
&format!("{sha}^{{commit}}")
])
.output()
.map_err(|_| "Failed to spawn Git command to verify SHA".to_owned())
.and_then(|output|
output
.status.success()
.then_some(sha)
.ok_or_else(|| format!("SHA {sha} is not a valid Git SHA within this repository!")))
}).map(|sha| GitSha(sha.to_owned()))
}
#[derive(Parser)]
pub(crate) struct GenerateWorkflowArgs {
#[arg(value_parser = parse_ref)]
/// The Git SHA to use when invoking this
pub(crate) sha: Option<GitSha>,
}
enum WorkflowSource {
Contextless(fn() -> Workflow),
WithContext(fn(&GenerateWorkflowArgs) -> Workflow),
}
struct WorkflowFile {
source: WorkflowSource,
r#type: WorkflowType,
}
impl WorkflowFile {
fn zed(f: fn() -> Workflow) -> WorkflowFile {
WorkflowFile {
source: WorkflowSource::Contextless(f),
r#type: WorkflowType::Zed,
}
}
fn extension(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile {
WorkflowFile {
source: WorkflowSource::WithContext(f),
r#type: WorkflowType::ExtensionCi,
}
}
fn extension_shared(f: fn(&GenerateWorkflowArgs) -> Workflow) -> WorkflowFile {
WorkflowFile {
source: WorkflowSource::WithContext(f),
r#type: WorkflowType::ExtensionsShared,
}
}
fn generate_file(&self, workflow_args: &GenerateWorkflowArgs) -> Result<()> {
let workflow = match &self.source {
WorkflowSource::Contextless(f) => f(),
WorkflowSource::WithContext(f) => f(workflow_args),
};
let workflow_folder = self.r#type.folder_path();
fs::create_dir_all(&workflow_folder).with_context(|| {
format!("Failed to create directory: {}", workflow_folder.display())
})?;
let workflow_name = workflow
.name
.as_ref()
.expect("Workflow must have a name at this point");
let filename = format!(
"{}.yml",
workflow_name.rsplit("::").next().unwrap_or(workflow_name)
);
let workflow_path = workflow_folder.join(filename);
let content = workflow
.to_string()
.map_err(|e| anyhow::anyhow!("{:?}: {:?}", workflow_path, e))?;
let disclaimer = self.r#type.disclaimer(workflow_name);
let content = [disclaimer, content].join("\n");
fs::write(&workflow_path, content).map_err(Into::into)
}
}
#[derive(PartialEq, Eq, strum::EnumIter)]
pub enum WorkflowType {
/// Workflows living in the Zed repository
Zed,
/// Workflows living in the `zed-extensions/workflows` repository that are
/// required workflows for PRs to the extension organization
ExtensionCi,
/// Workflows living in each of the extensions to perform checks and version
/// bumps until a better, more centralized system for that is in place.
ExtensionsShared,
}
impl WorkflowType {
fn disclaimer(&self, workflow_name: &str) -> String {
format!(
concat!(
"# Generated from xtask::workflows::{}{}\n",
"# Rebuild with `cargo xtask workflows`.",
),
workflow_name,
(*self != WorkflowType::Zed)
.then_some(" within the Zed repository.")
.unwrap_or_default(),
)
}
pub fn folder_path(&self) -> PathBuf {
match self {
WorkflowType::Zed => PathBuf::from(".github/workflows"),
WorkflowType::ExtensionCi => PathBuf::from("extensions/workflows"),
WorkflowType::ExtensionsShared => PathBuf::from("extensions/workflows/shared"),
}
}
}
pub fn run_workflows(args: GenerateWorkflowArgs) -> Result<()> {
if !Path::new("crates/zed/").is_dir() {
anyhow::bail!("xtask workflows must be ran from the project root");
}
let workflows = [
WorkflowFile::zed(after_release::after_release),
WorkflowFile::zed(autofix_pr::autofix_pr),
WorkflowFile::zed(bump_patch_version::bump_patch_version),
WorkflowFile::zed(cherry_pick::cherry_pick),
WorkflowFile::zed(compare_perf::compare_perf),
WorkflowFile::zed(danger::danger),
WorkflowFile::zed(deploy_collab::deploy_collab),
WorkflowFile::zed(extension_bump::extension_bump),
WorkflowFile::zed(extension_auto_bump::extension_auto_bump),
WorkflowFile::zed(extension_tests::extension_tests),
WorkflowFile::zed(extension_workflow_rollout::extension_workflow_rollout),
WorkflowFile::zed(publish_extension_cli::publish_extension_cli),
WorkflowFile::zed(release::release),
WorkflowFile::zed(release_nightly::release_nightly),
WorkflowFile::zed(run_agent_evals::run_agent_evals),
WorkflowFile::zed(run_agent_evals::run_cron_unit_evals),
WorkflowFile::zed(run_agent_evals::run_unit_evals),
WorkflowFile::zed(run_bundling::run_bundling),
WorkflowFile::zed(run_tests::run_tests),
/* workflows used for CI/CD in extension repositories */
WorkflowFile::extension(extensions::run_tests::run_tests),
WorkflowFile::extension_shared(extensions::bump_version::bump_version),
];
for workflow_file in workflows {
workflow_file.generate_file(&args)?;
}
workflow_checks::validate(Default::default())
}
@@ -0,0 +1,185 @@
use gh_workflow::*;
use crate::tasks::workflows::{
release::{self, notify_on_failure},
runners,
steps::{CommonJobConditions, NamedJob, checkout_repo, dependant_job, named},
vars::{self, StepOutput, WorkflowInput},
};
const TAG_NAME: &str = "${{ github.event.release.tag_name || inputs.tag_name }}";
const IS_PRERELEASE: &str = "${{ github.event.release.prerelease || inputs.prerelease }}";
const RELEASE_BODY: &str = "${{ github.event.release.body || inputs.body }}";
pub fn after_release() -> Workflow {
let tag_name = WorkflowInput::string("tag_name", None);
let prerelease = WorkflowInput::bool("prerelease", None);
let body = WorkflowInput::string("body", Some(String::new()));
let refresh_zed_dev = rebuild_releases_page();
let post_to_discord = post_to_discord(&[&refresh_zed_dev]);
let publish_winget = publish_winget();
let create_sentry_release = create_sentry_release();
let notify_on_failure = notify_on_failure(&[
&refresh_zed_dev,
&post_to_discord,
&publish_winget,
&create_sentry_release,
]);
named::workflow()
.on(Event::default()
.release(Release::default().types(vec![ReleaseType::Published]))
.workflow_dispatch(
WorkflowDispatch::default()
.add_input(tag_name.name, tag_name.input())
.add_input(prerelease.name, prerelease.input())
.add_input(body.name, body.input()),
))
.add_job(refresh_zed_dev.name, refresh_zed_dev.job)
.add_job(post_to_discord.name, post_to_discord.job)
.add_job(publish_winget.name, publish_winget.job)
.add_job(create_sentry_release.name, create_sentry_release.job)
.add_job(notify_on_failure.name, notify_on_failure.job)
}
fn rebuild_releases_page() -> NamedJob {
fn refresh_cloud_releases() -> Step<Run> {
named::bash(format!(
"curl -fX POST https://cloud.zed.dev/releases/refresh?expect_tag={TAG_NAME}"
))
}
fn redeploy_zed_dev() -> Step<Run> {
named::bash("./script/redeploy-vercel").add_env(("VERCEL_TOKEN", vars::VERCEL_TOKEN))
}
named::job(
Job::default()
.runs_on(runners::LINUX_SMALL)
.with_repository_owner_guard()
.add_step(refresh_cloud_releases())
.add_step(checkout_repo())
.add_step(redeploy_zed_dev()),
)
}
fn post_to_discord(deps: &[&NamedJob]) -> NamedJob {
fn get_release_url() -> Step<Run> {
named::bash(format!(
r#"if [ "{IS_PRERELEASE}" == "true" ]; then
URL="https://zed.dev/releases/preview"
else
URL="https://zed.dev/releases/stable"
fi
echo "URL=$URL" >> "$GITHUB_OUTPUT"
"#
))
.id("get-release-url")
}
fn get_content() -> Step<Use> {
named::uses(
"2428392",
"gh-truncate-string-action",
"b3ff790d21cf42af3ca7579146eedb93c8fb0757", // v1.4.1
)
.id("get-content")
.add_with((
"stringToTruncate",
format!(
"📣 Zed [{TAG_NAME}](<${{{{ steps.get-release-url.outputs.URL }}}}>) was just released!\n\n{RELEASE_BODY}\n"
),
))
.add_with(("maxLength", 2000))
.add_with(("truncationSymbol", "..."))
}
fn discord_webhook_action() -> Step<Use> {
named::uses(
"tsickert",
"discord-webhook",
"c840d45a03a323fbc3f7507ac7769dbd91bfb164", // v5.3.0
)
.add_with(("webhook-url", vars::DISCORD_WEBHOOK_RELEASE_NOTES))
.add_with(("content", "${{ steps.get-content.outputs.string }}"))
}
let job = dependant_job(deps)
.runs_on(runners::LINUX_SMALL)
.with_repository_owner_guard()
.add_step(get_release_url())
.add_step(get_content())
.add_step(discord_webhook_action());
named::job(job)
}
fn publish_winget() -> NamedJob {
fn sync_winget_pkgs_fork() -> Step<Run> {
named::pwsh(indoc::indoc! {r#"
$headers = @{
"Authorization" = "Bearer $env:WINGET_TOKEN"
"Accept" = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
}
$body = @{ branch = "master" } | ConvertTo-Json
$uri = "https://api.github.com/repos/$env:GITHUB_REPOSITORY_OWNER/winget-pkgs/merge-upstream"
try {
Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body $body -ContentType "application/json"
Write-Host "Successfully synced winget-pkgs fork"
} catch {
Write-Host "Fork sync response: $_"
Write-Host "Continuing anyway - fork may already be up to date"
}
"#})
.add_env(("WINGET_TOKEN", vars::WINGET_TOKEN))
}
fn set_package_name() -> (Step<Run>, StepOutput) {
let script = format!(
r#"if ("{IS_PRERELEASE}" -eq "true") {{
$PACKAGE_NAME = "ZedIndustries.Zed.Preview"
}} else {{
$PACKAGE_NAME = "ZedIndustries.Zed"
}}
echo "PACKAGE_NAME=$PACKAGE_NAME" >> $env:GITHUB_OUTPUT
"#
);
let step = named::pwsh(&script).id("set-package-name");
let output = StepOutput::new(&step, "PACKAGE_NAME");
(step, output)
}
fn winget_releaser(package_name: &StepOutput) -> Step<Use> {
named::uses(
"vedantmgoyal9",
"winget-releaser",
"19e706d4c9121098010096f9c495a70a7518b30f", // v2
)
.add_with(("identifier", package_name.to_string()))
.add_with(("release-tag", TAG_NAME))
.add_with(("max-versions-to-keep", 5))
.add_with(("token", vars::WINGET_TOKEN))
}
let (set_package_name, package_name) = set_package_name();
named::job(
Job::default()
.runs_on(runners::WINDOWS_DEFAULT)
.add_step(sync_winget_pkgs_fork())
.add_step(set_package_name)
.add_step(winget_releaser(&package_name)),
)
}
fn create_sentry_release() -> NamedJob {
let job = Job::default()
.runs_on(runners::LINUX_SMALL)
.with_repository_owner_guard()
.add_step(checkout_repo())
.add_step(release::create_sentry_release());
named::job(job)
}
@@ -0,0 +1,181 @@
use gh_workflow::*;
use crate::tasks::workflows::{
runners,
steps::{self, FluentBuilder, NamedJob, named},
vars::{self, StepOutput, WorkflowInput},
};
pub fn autofix_pr() -> Workflow {
let pr_number = WorkflowInput::string("pr_number", None);
let run_clippy = WorkflowInput::bool("run_clippy", Some(true));
let run_autofix = run_autofix(&pr_number, &run_clippy);
let commit_changes = commit_changes(&pr_number, &run_autofix);
named::workflow()
.run_name(format!("autofix PR #{pr_number}"))
.on(Event::default().workflow_dispatch(
WorkflowDispatch::default()
.add_input(pr_number.name, pr_number.input())
.add_input(run_clippy.name, run_clippy.input()),
))
.concurrency(
Concurrency::new(Expression::new(format!(
"${{{{ github.workflow }}}}-{pr_number}"
)))
.cancel_in_progress(true),
)
.add_job(run_autofix.name.clone(), run_autofix.job)
.add_job(commit_changes.name, commit_changes.job)
}
const PATCH_ARTIFACT_NAME: &str = "autofix-patch";
const PATCH_FILE_PATH: &str = "autofix.patch";
fn upload_patch_artifact() -> Step<Use> {
Step::new(format!("upload artifact {}", PATCH_ARTIFACT_NAME))
.uses(
"actions",
"upload-artifact",
"330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
)
.add_with(("name", PATCH_ARTIFACT_NAME))
.add_with(("path", PATCH_FILE_PATH))
.add_with(("if-no-files-found", "ignore"))
.add_with(("retention-days", "1"))
}
fn download_patch_artifact() -> Step<Use> {
named::uses(
"actions",
"download-artifact",
"018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
)
.add_with(("name", PATCH_ARTIFACT_NAME))
}
fn run_autofix(pr_number: &WorkflowInput, run_clippy: &WorkflowInput) -> NamedJob {
fn checkout_pr(pr_number: &WorkflowInput) -> Step<Run> {
named::bash(r#"gh pr checkout "$PR_NUMBER""#)
.add_env(("PR_NUMBER", pr_number.to_string()))
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
}
fn install_cargo_machete() -> Step<Use> {
named::uses(
"clechasseur",
"rs-cargo",
"8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386", // v2
)
.add_with(("command", "install"))
.add_with(("args", "cargo-machete@0.7.0"))
}
fn run_cargo_fmt() -> Step<Run> {
named::bash("cargo fmt --all")
}
fn run_cargo_fix() -> Step<Run> {
named::bash(
"cargo fix --workspace --release --all-targets --all-features --allow-dirty --allow-staged",
)
}
fn run_cargo_machete_fix() -> Step<Run> {
named::bash("cargo machete --fix")
}
fn run_clippy_fix() -> Step<Run> {
named::bash(
"cargo clippy --workspace --release --all-targets --all-features --fix --allow-dirty --allow-staged",
)
}
fn run_prettier_fix() -> Step<Run> {
named::bash("./script/prettier --write")
}
fn create_patch() -> Step<Run> {
named::bash(indoc::indoc! {r#"
if git diff --quiet; then
echo "No changes to commit"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
git diff > autofix.patch
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
"#})
.id("create-patch")
}
named::job(
Job::default()
.runs_on(runners::LINUX_DEFAULT)
.outputs([(
"has_changes".to_owned(),
"${{ steps.create-patch.outputs.has_changes }}".to_owned(),
)])
.add_step(steps::checkout_repo())
.add_step(checkout_pr(pr_number))
.add_step(steps::setup_cargo_config(runners::Platform::Linux))
.add_step(steps::cache_rust_dependencies_namespace())
.map(steps::install_linux_dependencies)
.add_step(steps::setup_pnpm())
.add_step(install_cargo_machete().if_condition(Expression::new(run_clippy.to_string())))
.add_step(run_cargo_fix().if_condition(Expression::new(run_clippy.to_string())))
.add_step(run_cargo_machete_fix().if_condition(Expression::new(run_clippy.to_string())))
.add_step(run_clippy_fix().if_condition(Expression::new(run_clippy.to_string())))
.add_step(run_prettier_fix())
.add_step(run_cargo_fmt())
.add_step(create_patch())
.add_step(upload_patch_artifact())
.add_step(steps::cleanup_cargo_config(runners::Platform::Linux)),
)
}
fn commit_changes(pr_number: &WorkflowInput, autofix_job: &NamedJob) -> NamedJob {
fn checkout_pr(pr_number: &WorkflowInput, token: &StepOutput) -> Step<Run> {
named::bash(r#"gh pr checkout "$PR_NUMBER""#)
.add_env(("PR_NUMBER", pr_number.to_string()))
.add_env(("GITHUB_TOKEN", token))
}
fn apply_patch() -> Step<Run> {
named::bash("git apply autofix.patch")
}
fn commit_and_push(token: &StepOutput) -> Step<Run> {
named::bash(indoc::indoc! {r#"
git commit -am "Autofix"
git push
"#})
.add_env(("GIT_COMMITTER_NAME", "Zed Zippy"))
.add_env((
"GIT_COMMITTER_EMAIL",
"234243425+zed-zippy[bot]@users.noreply.github.com",
))
.add_env(("GIT_AUTHOR_NAME", "Zed Zippy"))
.add_env((
"GIT_AUTHOR_EMAIL",
"234243425+zed-zippy[bot]@users.noreply.github.com",
))
.add_env(("GITHUB_TOKEN", token))
}
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
Job::default()
.runs_on(runners::LINUX_SMALL)
.needs(vec![autofix_job.name.clone()])
.cond(Expression::new(format!(
"needs.{}.outputs.has_changes == 'true'",
autofix_job.name
)))
.add_step(authenticate)
.add_step(steps::checkout_repo().with_token(&token))
.add_step(checkout_pr(pr_number, &token))
.add_step(download_patch_artifact())
.add_step(apply_patch())
.add_step(commit_and_push(&token)),
)
}
@@ -0,0 +1,78 @@
use gh_workflow::*;
use crate::tasks::workflows::{
runners,
steps::{self, CheckoutStep, named},
vars::{StepOutput, WorkflowInput},
};
pub fn bump_patch_version() -> Workflow {
let branch = WorkflowInput::string("branch", None).description("Branch name to run on");
let bump_patch_version_job = run_bump_patch_version(&branch);
named::workflow()
.on(Event::default()
.workflow_dispatch(WorkflowDispatch::default().add_input(branch.name, branch.input())))
.concurrency(
Concurrency::new(Expression::new(format!(
"${{{{ github.workflow }}}}-{branch}"
)))
.cancel_in_progress(true),
)
.add_job(bump_patch_version_job.name, bump_patch_version_job.job)
}
fn run_bump_patch_version(branch: &WorkflowInput) -> steps::NamedJob {
fn checkout_branch(branch: &WorkflowInput, token: &StepOutput) -> CheckoutStep {
steps::checkout_repo()
.with_token(token)
.with_ref(branch.to_string())
}
fn bump_patch_version(token: &StepOutput) -> Step<Run> {
named::bash(indoc::indoc! {r#"
channel="$(cat crates/zed/RELEASE_CHANNEL)"
tag_suffix=""
case $channel in
stable)
;;
preview)
tag_suffix="-pre"
;;
*)
echo "this must be run on either of stable|preview release branches" >&2
exit 1
;;
esac
which cargo-set-version > /dev/null || cargo install cargo-edit -f --no-default-features --features "set-version"
output="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
git commit -am "Bump to $output for @$GITHUB_ACTOR"
git tag "v${output}${tag_suffix}"
git push origin HEAD "v${output}${tag_suffix}"
"#})
.add_env(("GIT_COMMITTER_NAME", "Zed Zippy"))
.add_env((
"GIT_COMMITTER_EMAIL",
"234243425+zed-zippy[bot]@users.noreply.github.com",
))
.add_env(("GIT_AUTHOR_NAME", "Zed Zippy"))
.add_env((
"GIT_AUTHOR_EMAIL",
"234243425+zed-zippy[bot]@users.noreply.github.com",
))
.add_env(("GITHUB_TOKEN", token))
}
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
Job::default()
.cond(Expression::new(
"github.repository_owner == 'zed-industries'",
))
.runs_on(runners::LINUX_XL)
.add_step(authenticate)
.add_step(checkout_branch(branch, &token))
.add_step(bump_patch_version(&token)),
)
}
@@ -0,0 +1,56 @@
use gh_workflow::*;
use crate::tasks::workflows::{
runners,
steps::{self, NamedJob, named},
vars::{StepOutput, WorkflowInput},
};
pub fn cherry_pick() -> Workflow {
let branch = WorkflowInput::string("branch", None);
let commit = WorkflowInput::string("commit", None);
let channel = WorkflowInput::string("channel", None);
let pr_number = WorkflowInput::string("pr_number", None);
let cherry_pick = run_cherry_pick(&branch, &commit, &channel);
named::workflow()
.run_name(format!("cherry_pick to {channel} #{pr_number}"))
.on(Event::default().workflow_dispatch(
WorkflowDispatch::default()
.add_input(commit.name, commit.input())
.add_input(branch.name, branch.input())
.add_input(channel.name, channel.input())
.add_input(pr_number.name, pr_number.input()),
))
.add_job(cherry_pick.name, cherry_pick.job)
}
fn run_cherry_pick(
branch: &WorkflowInput,
commit: &WorkflowInput,
channel: &WorkflowInput,
) -> NamedJob {
fn cherry_pick(
branch: &WorkflowInput,
commit: &WorkflowInput,
channel: &WorkflowInput,
token: &StepOutput,
) -> Step<Run> {
named::bash(r#"./script/cherry-pick "$BRANCH" "$COMMIT" "$CHANNEL""#)
.add_env(("BRANCH", branch.to_string()))
.add_env(("COMMIT", commit.to_string()))
.add_env(("CHANNEL", channel.to_string()))
.add_env(("GIT_COMMITTER_NAME", "Zed Zippy"))
.add_env(("GIT_COMMITTER_EMAIL", "hi@zed.dev"))
.add_env(("GITHUB_TOKEN", token))
}
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
Job::default()
.runs_on(runners::LINUX_SMALL)
.add_step(steps::checkout_repo())
.add_step(authenticate)
.add_step(cherry_pick(branch, commit, channel, &token)),
)
}
@@ -0,0 +1,69 @@
use gh_workflow::*;
use crate::tasks::workflows::run_bundling::upload_artifact;
use crate::tasks::workflows::steps::FluentBuilder;
use crate::tasks::workflows::{
runners,
steps::{self, NamedJob, named},
vars::WorkflowInput,
};
pub fn compare_perf() -> Workflow {
let head = WorkflowInput::string("head", None);
let base = WorkflowInput::string("base", None);
let crate_name = WorkflowInput::string("crate_name", Some("".to_owned()));
let run_perf = run_perf(&base, &head, &crate_name);
named::workflow()
.on(Event::default().workflow_dispatch(
WorkflowDispatch::default()
.add_input(head.name, head.input())
.add_input(base.name, base.input())
.add_input(crate_name.name, crate_name.input()),
))
.add_job(run_perf.name, run_perf.job)
}
pub fn run_perf(
base: &WorkflowInput,
head: &WorkflowInput,
crate_name: &WorkflowInput,
) -> NamedJob {
fn cargo_perf_test(ref_name: &WorkflowInput, crate_name: &WorkflowInput) -> Step<Run> {
named::bash(
r#"
if [ -n "$CRATE_NAME" ]; then
cargo perf-test -p "$CRATE_NAME" -- --json="$REF_NAME";
else
cargo perf-test -p vim -- --json="$REF_NAME";
fi"#,
)
.add_env(("REF_NAME", ref_name.to_string()))
.add_env(("CRATE_NAME", crate_name.to_string()))
}
fn install_hyperfine() -> Step<Use> {
named::uses("taiki-e", "install-action", "hyperfine")
}
fn compare_runs(head: &WorkflowInput, base: &WorkflowInput) -> Step<Run> {
named::bash(r#"cargo perf-compare --save=results.md "$BASE" "$HEAD""#)
.add_env(("BASE", base.to_string()))
.add_env(("HEAD", head.to_string()))
}
named::job(
Job::default()
.runs_on(runners::LINUX_DEFAULT)
.add_step(steps::checkout_repo())
.add_step(steps::setup_cargo_config(runners::Platform::Linux))
.map(steps::install_linux_dependencies)
.add_step(install_hyperfine())
.add_step(steps::git_checkout(base))
.add_step(cargo_perf_test(base, crate_name))
.add_step(steps::git_checkout(head))
.add_step(cargo_perf_test(head, crate_name))
.add_step(compare_runs(head, base))
.add_step(upload_artifact("results.md"))
.add_step(steps::cleanup_cargo_config(runners::Platform::Linux)),
)
}
@@ -0,0 +1,56 @@
use gh_workflow::*;
use crate::tasks::workflows::steps::{CommonJobConditions, NamedJob, named};
use super::{runners, steps};
/// Generates the danger.yml workflow
pub fn danger() -> Workflow {
let danger = danger_job();
named::workflow()
.on(
Event::default().pull_request(PullRequest::default().add_branch("main").types([
PullRequestType::Opened,
PullRequestType::Synchronize,
PullRequestType::Reopened,
PullRequestType::Edited,
])),
)
.add_job(danger.name, danger.job)
}
fn danger_job() -> NamedJob {
pub fn install_deps() -> Step<Run> {
named::bash("pnpm install --dir script/danger")
}
pub fn run() -> Step<Run> {
named::bash("pnpm run --dir script/danger danger ci")
// This GitHub token is not used, but the value needs to be here to prevent
// Danger from throwing an error.
.add_env(("GITHUB_TOKEN", "not_a_real_token"))
// All requests are instead proxied through a proxy that allows Danger to securely authenticate with GitHub
// while still being able to run on PRs from forks.
.add_env((
"DANGER_GITHUB_API_BASE_URL",
"https://danger-proxy.zed.dev/github",
))
}
NamedJob {
name: "danger".to_string(),
job: Job::default()
.with_repository_owner_guard()
.runs_on(runners::LINUX_SMALL)
.add_step(steps::checkout_repo())
.add_step(steps::setup_pnpm())
.add_step(
steps::setup_node()
.add_with(("cache", "pnpm"))
.add_with(("cache-dependency-path", "script/danger/pnpm-lock.yaml")),
)
.add_step(install_deps())
.add_step(run()),
}
}
@@ -0,0 +1,174 @@
use gh_workflow::{Container, Event, Port, Push, Run, Step, Use, Workflow};
use indoc::indoc;
use crate::tasks::workflows::runners::{self, Platform};
use crate::tasks::workflows::steps::{
self, CommonJobConditions, FluentBuilder as _, NamedJob, dependant_job, named, use_clang,
};
use crate::tasks::workflows::vars;
pub(crate) fn deploy_collab() -> Workflow {
let style = style();
let tests = tests(&[&style]);
let publish = publish(&[&style, &tests]);
let deploy = deploy(&[&publish]);
named::workflow()
.on(Event::default().push(Push::default().add_tag("collab-production")))
.add_env(("DOCKER_BUILDKIT", "1"))
.add_job(style.name, style.job)
.add_job(tests.name, tests.job)
.add_job(publish.name, publish.job)
.add_job(deploy.name, deploy.job)
}
fn style() -> NamedJob {
named::job(use_clang(
dependant_job(&[])
.name("Check formatting and Clippy lints")
.with_repository_owner_guard()
.runs_on(runners::LINUX_XL)
.add_step(steps::checkout_repo().with_full_history())
.add_step(steps::setup_cargo_config(Platform::Linux))
.add_step(steps::cache_rust_dependencies_namespace())
.map(steps::install_linux_dependencies)
.add_step(steps::cargo_fmt())
.add_step(steps::clippy(Platform::Linux, None)),
))
}
fn tests(deps: &[&NamedJob]) -> NamedJob {
fn run_collab_tests() -> Step<Run> {
named::bash("cargo nextest run --package collab --no-fail-fast")
}
named::job(use_clang(
dependant_job(deps)
.name("Run tests")
.runs_on(runners::LINUX_XL)
.add_service(
"postgres",
Container::new("postgres:15")
.add_env(("POSTGRES_HOST_AUTH_METHOD", "trust"))
.ports(vec![Port::Name("5432:5432".into())])
.options(
"--health-cmd pg_isready \
--health-interval 500ms \
--health-timeout 5s \
--health-retries 10",
),
)
.add_step(steps::checkout_repo().with_full_history())
.add_step(steps::setup_cargo_config(Platform::Linux))
.add_step(steps::cache_rust_dependencies_namespace())
.map(steps::install_linux_dependencies)
.add_step(steps::cargo_install_nextest())
.add_step(steps::clear_target_dir_if_large(Platform::Linux))
.add_step(run_collab_tests()),
))
}
fn publish(deps: &[&NamedJob]) -> NamedJob {
fn install_doctl() -> Step<Use> {
named::uses("digitalocean", "action-doctl", "v2")
.add_with(("token", vars::DIGITALOCEAN_ACCESS_TOKEN))
}
fn sign_into_registry() -> Step<Run> {
named::bash("doctl registry login")
}
fn build_docker_image() -> Step<Run> {
named::bash(indoc! {r#"
docker build -f Dockerfile-collab \
--build-arg "GITHUB_SHA=$GITHUB_SHA" \
--tag "registry.digitalocean.com/zed/collab:$GITHUB_SHA" \
.
"#})
}
fn publish_docker_image() -> Step<Run> {
named::bash(r#"docker push "registry.digitalocean.com/zed/collab:${GITHUB_SHA}""#)
}
fn prune_docker_system() -> Step<Run> {
named::bash("docker system prune --filter 'until=72h' -f")
}
named::job(
dependant_job(deps)
.name("Publish collab server image")
.runs_on(runners::LINUX_XL)
.add_step(install_doctl())
.add_step(sign_into_registry())
.add_step(steps::checkout_repo())
.add_step(build_docker_image())
.add_step(publish_docker_image())
.add_step(prune_docker_system()),
)
}
fn deploy(deps: &[&NamedJob]) -> NamedJob {
fn install_doctl() -> Step<Use> {
named::uses("digitalocean", "action-doctl", "v2")
.add_with(("token", vars::DIGITALOCEAN_ACCESS_TOKEN))
}
fn sign_into_kubernetes() -> Step<Run> {
named::bash(
r#"doctl kubernetes cluster kubeconfig save --expiry-seconds 600 "$CLUSTER_NAME""#,
)
.add_env(("CLUSTER_NAME", vars::CLUSTER_NAME))
}
fn start_rollout() -> Step<Run> {
named::bash(indoc! {r#"
set -eu
if [[ $GITHUB_REF_NAME = "collab-production" ]]; then
export ZED_KUBE_NAMESPACE=production
export ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT=10
export ZED_API_LOAD_BALANCER_SIZE_UNIT=2
elif [[ $GITHUB_REF_NAME = "collab-staging" ]]; then
export ZED_KUBE_NAMESPACE=staging
export ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT=1
export ZED_API_LOAD_BALANCER_SIZE_UNIT=1
else
echo "cowardly refusing to deploy from an unknown branch"
exit 1
fi
echo "Deploying collab:$GITHUB_SHA to $ZED_KUBE_NAMESPACE"
source script/lib/deploy-helpers.sh
export_vars_for_environment "$ZED_KUBE_NAMESPACE"
ZED_DO_CERTIFICATE_ID="$(doctl compute certificate list --format ID --no-header)"
export ZED_DO_CERTIFICATE_ID
export ZED_IMAGE_ID="registry.digitalocean.com/zed/collab:${GITHUB_SHA}"
export ZED_SERVICE_NAME=collab
export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_COLLAB_LOAD_BALANCER_SIZE_UNIT
export DATABASE_MAX_CONNECTIONS=850
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status "deployment/$ZED_SERVICE_NAME" --watch
echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}"
export ZED_SERVICE_NAME=api
export ZED_LOAD_BALANCER_SIZE_UNIT=$ZED_API_LOAD_BALANCER_SIZE_UNIT
export DATABASE_MAX_CONNECTIONS=60
envsubst < crates/collab/k8s/collab.template.yml | kubectl apply -f -
kubectl -n "$ZED_KUBE_NAMESPACE" rollout status "deployment/$ZED_SERVICE_NAME" --watch
echo "deployed ${ZED_SERVICE_NAME} to ${ZED_KUBE_NAMESPACE}"
"#})
}
named::job(
dependant_job(deps)
.name("Deploy new server image")
.runs_on(runners::LINUX_XL)
.add_step(steps::checkout_repo())
.add_step(install_doctl())
.add_step(sign_into_kubernetes())
.add_step(start_rollout()),
)
}
@@ -0,0 +1,115 @@
use gh_workflow::{
Event, Expression, Input, Job, Level, Permissions, Push, Strategy, UsesJob, Workflow,
};
use indoc::indoc;
use serde_json::json;
use crate::tasks::workflows::{
extensions::WithAppSecrets,
run_tests::DETECT_CHANGED_EXTENSIONS_SCRIPT,
runners,
steps::{self, CommonJobConditions, NamedJob, named},
vars::{StepOutput, one_workflow_per_non_main_branch},
};
/// Generates a workflow that triggers on push to main, detects changed extensions
/// in the `extensions/` directory, and invokes the `extension_bump` reusable workflow
/// for each changed extension via a matrix strategy.
pub(crate) fn extension_auto_bump() -> Workflow {
let detect = detect_changed_extensions();
let bump = bump_extension_versions(&detect);
named::workflow()
.add_event(
Event::default().push(
Push::default()
.add_branch("main")
.add_path("extensions/**")
.add_path("!extensions/slash-commands-example/**")
.add_path("!extensions/test-extension/**")
.add_path("!extensions/workflows/**")
.add_path("!extensions/*.md"),
),
)
.concurrency(one_workflow_per_non_main_branch())
.add_job(detect.name, detect.job)
.add_job(bump.name, bump.job)
}
fn detect_changed_extensions() -> NamedJob {
let preamble = indoc! {r#"
COMPARE_REV="$(git rev-parse HEAD~1)"
CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
"#};
let filter_new_and_removed = indoc! {r#"
# Filter out newly added or entirely removed extensions
FILTERED="[]"
for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do
if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1 && \
[ -f "$ext/extension.toml" ]; then
FILTERED=$(echo "$FILTERED" | jq -c --arg e "$ext" '. + [$e]')
fi
done
echo "changed_extensions=$FILTERED" >> "$GITHUB_OUTPUT"
"#};
let script = format!(
"{preamble}{detect}{filter}",
preamble = preamble,
detect = DETECT_CHANGED_EXTENSIONS_SCRIPT,
filter = filter_new_and_removed,
);
let step = named::bash(script).id("detect");
let output = StepOutput::new(&step, "changed_extensions");
let job = Job::default()
.with_repository_owner_guard()
.runs_on(runners::LINUX_SMALL)
.timeout_minutes(5u32)
.add_step(steps::checkout_repo().with_custom_fetch_depth(2))
.add_step(step)
.outputs([("changed_extensions".to_owned(), output.to_string())]);
named::job(job)
}
fn bump_extension_versions(detect_job: &NamedJob) -> NamedJob<UsesJob> {
let job = Job::default()
.needs(vec![detect_job.name.clone()])
.cond(Expression::new(format!(
"needs.{}.outputs.changed_extensions != '[]'",
detect_job.name
)))
.permissions(
Permissions::default()
.contents(Level::Write)
.issues(Level::Write)
.pull_requests(Level::Write)
.actions(Level::Write),
)
.strategy(
Strategy::default()
.fail_fast(false)
// TODO: Remove the limit. We currently need this to workaround the concurrency group issue
// where different matrix jobs would be placed in the same concurrency group and thus cancelled.
.max_parallel(1u32)
.matrix(json!({
"extension": format!(
"${{{{ fromJson(needs.{}.outputs.changed_extensions) }}}}",
detect_job.name
)
})),
)
.uses_local(".github/workflows/extension_bump.yml")
.with(
Input::default()
.add("working-directory", "${{ matrix.extension }}")
.add("force-bump", false),
)
.with_app_secrets();
named::job(job)
}
@@ -0,0 +1,559 @@
use gh_workflow::{ctx::Context, *};
use indoc::{formatdoc, indoc};
use crate::tasks::workflows::{
extension_tests::{self},
runners,
steps::{
self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder,
NamedJob, cache_rust_dependencies_namespace, checkout_repo, dependant_job, named,
},
vars::{
JobOutput, StepOutput, WorkflowInput, WorkflowSecret,
one_workflow_per_non_main_branch_and_token,
},
};
const VERSION_CHECK: &str =
r#"sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]'"#;
// This is used by various extensions repos in the zed-extensions org to bump extension versions.
pub(crate) fn extension_bump() -> Workflow {
let bump_type = WorkflowInput::string("bump-type", Some("patch".to_owned()));
// TODO: Ideally, this would have a default of `false`, but this is currently not
// supported in gh-workflows
let force_bump = WorkflowInput::bool("force-bump", None);
let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
let (app_id, app_secret) = extension_workflow_secrets();
let (check_version_changed, version_changed, current_version) = check_version_changed();
let version_changed = version_changed.as_job_output(&check_version_changed);
let current_version = current_version.as_job_output(&check_version_changed);
let dependencies = [&check_version_changed];
let bump_version = bump_extension_version(
&dependencies,
&current_version,
&bump_type,
&version_changed,
&force_bump,
&app_id,
&app_secret,
);
let (create_label, tag) = create_version_label(
&dependencies,
&version_changed,
&current_version,
&app_id,
&app_secret,
);
let tag = tag.as_job_output(&create_label);
let trigger_release = trigger_release(
&[&check_version_changed, &create_label],
tag,
&app_id,
&app_secret,
);
named::workflow()
.add_event(
Event::default().workflow_call(
WorkflowCall::default()
.add_input(bump_type.name, bump_type.call_input())
.add_input(force_bump.name, force_bump.call_input())
.add_input(working_directory.name, working_directory.call_input())
.secrets([
(app_id.name.to_owned(), app_id.secret_configuration()),
(
app_secret.name.to_owned(),
app_secret.secret_configuration(),
),
]),
),
)
.concurrency(one_workflow_per_non_main_branch_and_token("extension-bump"))
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("RUST_BACKTRACE", 1))
.add_env(("CARGO_INCREMENTAL", 0))
.add_env((
"ZED_EXTENSION_CLI_SHA",
extension_tests::ZED_EXTENSION_CLI_SHA,
))
.add_job(check_version_changed.name, check_version_changed.job)
.add_job(bump_version.name, bump_version.job)
.add_job(create_label.name, create_label.job)
.add_job(trigger_release.name, trigger_release.job)
}
fn extension_job_defaults() -> Defaults {
Defaults::default().run(
RunDefaults::default()
.shell(BASH_SHELL)
.working_directory("${{ inputs.working-directory }}"),
)
}
fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) {
let (compare_versions, version_changed, current_version) = compare_versions();
let job = Job::default()
.defaults(extension_job_defaults())
.with_repository_owner_guard()
.outputs([
(version_changed.name.to_owned(), version_changed.to_string()),
(
current_version.name.to_string(),
current_version.to_string(),
),
])
.runs_on(runners::LINUX_SMALL)
.timeout_minutes(1u32)
.add_step(steps::checkout_repo().with_full_history())
.add_step(compare_versions);
(named::job(job), version_changed, current_version)
}
fn create_version_label(
dependencies: &[&NamedJob],
version_changed_output: &JobOutput,
current_version: &JobOutput,
app_id: &WorkflowSecret,
app_secret: &WorkflowSecret,
) -> (NamedJob, StepOutput) {
let (generate_token, generated_token) =
generate_token(&app_id.to_string(), &app_secret.to_string(), None);
let (determine_tag_step, tag) = determine_tag(current_version);
let job = steps::dependant_job(dependencies)
.defaults(extension_job_defaults())
.cond(Expression::new(format!(
"{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \
github.ref == 'refs/heads/main' && {version_changed} == 'true'",
version_changed = version_changed_output.expr(),
)))
.outputs([(tag.name.to_owned(), tag.to_string())])
.runs_on(runners::LINUX_SMALL)
.timeout_minutes(1u32)
.add_step(generate_token)
.add_step(steps::checkout_repo())
.add_step(determine_tag_step)
.add_step(create_version_tag(&tag, generated_token));
(named::job(job), tag)
}
fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step<Use> {
named::uses("actions", "github-script", "v7").with(
Input::default()
.add(
"script",
formatdoc! {r#"
github.rest.git.createRef({{
owner: context.repo.owner,
repo: context.repo.repo,
ref: 'refs/tags/{tag}',
sha: context.sha
}})"#
},
)
.add("github-token", generated_token.to_string()),
)
}
fn determine_tag(current_version: &JobOutput) -> (Step<Run>, StepOutput) {
let step = named::bash(formatdoc! {r#"
EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
TAG="v${{CURRENT_VERSION}}"
else
TAG="${{EXTENSION_ID}}-v${{CURRENT_VERSION}}"
fi
echo "tag=${{TAG}}" >> "$GITHUB_OUTPUT"
"#})
.id("determine-tag")
.add_env(("CURRENT_VERSION", current_version.to_string()))
.add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
let tag = StepOutput::new(&step, "tag");
(step, tag)
}
/// Compares the current and previous commit and checks whether versions changed inbetween.
pub(crate) fn compare_versions() -> (Step<Run>, StepOutput, StepOutput) {
let check_needs_bump = named::bash(formatdoc! {
r#"
CURRENT_VERSION="$({VERSION_CHECK})"
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
PR_FORK_POINT="$(git merge-base origin/main HEAD)"
git checkout "$PR_FORK_POINT"
else
git checkout "$(git log -1 --format=%H)"~1
fi
PARENT_COMMIT_VERSION="$({VERSION_CHECK})"
[[ "$CURRENT_VERSION" == "$PARENT_COMMIT_VERSION" ]] && \
echo "version_changed=false" >> "$GITHUB_OUTPUT" || \
echo "version_changed=true" >> "$GITHUB_OUTPUT"
echo "current_version=${{CURRENT_VERSION}}" >> "$GITHUB_OUTPUT"
"#
})
.id("compare-versions-check");
let version_changed = StepOutput::new(&check_needs_bump, "version_changed");
let current_version = StepOutput::new(&check_needs_bump, "current_version");
(check_needs_bump, version_changed, current_version)
}
fn bump_extension_version(
dependencies: &[&NamedJob],
current_version: &JobOutput,
bump_type: &WorkflowInput,
version_changed_output: &JobOutput,
force_bump_output: &WorkflowInput,
app_id: &WorkflowSecret,
app_secret: &WorkflowSecret,
) -> NamedJob {
let (generate_token, generated_token) =
generate_token(&app_id.to_string(), &app_secret.to_string(), None);
let (bump_version, _new_version, title, body, branch_name) =
bump_version(current_version, bump_type);
let job = steps::dependant_job(dependencies)
.defaults(extension_job_defaults())
.cond(Expression::new(format!(
"{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')",
force_bump = force_bump_output.expr(),
version_changed = version_changed_output.expr(),
)))
.runs_on(runners::LINUX_SMALL)
.timeout_minutes(5u32)
.add_step(generate_token)
.add_step(steps::checkout_repo())
.add_step(cache_rust_dependencies_namespace())
.add_step(install_bump_2_version())
.add_step(bump_version)
.add_step(create_pull_request(
title,
body,
generated_token,
branch_name,
));
named::job(job)
}
pub(crate) fn generate_token(
app_id_source: &str,
app_secret_source: &str,
repository_target: Option<RepositoryTarget>,
) -> (Step<Use>, StepOutput) {
let step = named::uses("actions", "create-github-app-token", "v2")
.id("generate-token")
.add_with(
Input::default()
.add("app-id", app_id_source)
.add("private-key", app_secret_source)
.when_some(
repository_target,
|input,
RepositoryTarget {
owner,
repositories,
permissions,
}| {
input
.when_some(owner, |input, owner| input.add("owner", owner))
.when_some(repositories, |input, repositories| {
input.add("repositories", repositories)
})
.when_some(permissions, |input, permissions| {
permissions
.into_iter()
.fold(input, |input, (permission, level)| {
input.add(
permission,
serde_json::to_value(&level).unwrap_or_default(),
)
})
})
},
),
);
let generated_token = StepOutput::new(&step, "token");
(step, generated_token)
}
fn install_bump_2_version() -> Step<Run> {
named::run(
runners::Platform::Linux,
"pip install bump2version --break-system-packages",
)
}
fn bump_version(
current_version: &JobOutput,
bump_type: &WorkflowInput,
) -> (Step<Run>, StepOutput, StepOutput, StepOutput, StepOutput) {
let step = named::bash(formatdoc! {r#"
BUMP_FILES=("extension.toml")
if [[ -f "Cargo.toml" ]]; then
BUMP_FILES+=("Cargo.toml")
fi
bump2version \
--search "version = \"{{current_version}}"\" \
--replace "version = \"{{new_version}}"\" \
--current-version "$OLD_VERSION" \
--no-configured-files "$BUMP_TYPE" "${{BUMP_FILES[@]}}"
if [[ -f "Cargo.toml" ]]; then
cargo +stable update --workspace
fi
NEW_VERSION="$({VERSION_CHECK})"
EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
{{
echo "title=Bump version to ${{NEW_VERSION}}";
echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}";
echo "branch_name=zed-zippy-autobump";
}} >> "$GITHUB_OUTPUT"
else
{{
echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}";
echo "body<<EOF";
echo "This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}.";
echo "";
echo "Release Notes:";
echo "";
echo "- N/A";
echo "EOF";
echo "branch_name=zed-zippy-${{EXTENSION_ID}}-autobump";
}} >> "$GITHUB_OUTPUT"
fi
echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT"
"#
})
.id("bump-version")
.add_env(("OLD_VERSION", current_version.to_string()))
.add_env(("BUMP_TYPE", bump_type.to_string()))
.add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
let new_version = StepOutput::new(&step, "new_version");
let title = StepOutput::new(&step, "title");
let body = StepOutput::new(&step, "body");
let branch_name = StepOutput::new(&step, "branch_name");
(step, new_version, title, body, branch_name)
}
fn create_pull_request(
title: StepOutput,
body: StepOutput,
generated_token: StepOutput,
branch_name: StepOutput,
) -> Step<Use> {
named::uses("peter-evans", "create-pull-request", "v7").with(
Input::default()
.add("title", title.to_string())
.add("body", body.to_string())
.add("commit-message", title.to_string())
.add("branch", branch_name.to_string())
.add(
"committer",
"zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
)
.add("base", "main")
.add("delete-branch", true)
.add("token", generated_token.to_string())
.add("sign-commits", true)
.add("assignees", Context::github().actor().to_string()),
)
}
fn trigger_release(
dependencies: &[&NamedJob],
tag: JobOutput,
app_id: &WorkflowSecret,
app_secret: &WorkflowSecret,
) -> NamedJob {
let extension_registry = RepositoryTarget::new("zed-industries", &["extensions"]);
let (generate_token, generated_token) = generate_token(
&app_id.to_string(),
&app_secret.to_string(),
Some(extension_registry),
);
let (get_extension_id, extension_id) = get_extension_id();
let (release_action, pull_request_number) = release_action(extension_id, tag, &generated_token);
let job = dependant_job(dependencies)
.defaults(extension_job_defaults())
.with_repository_owner_guard()
.runs_on(runners::LINUX_SMALL)
.add_step(generate_token)
.add_step(checkout_repo())
.add_step(get_extension_id)
.add_step(release_action)
.add_step(enable_automerge_if_staff(
pull_request_number,
generated_token,
));
named::job(job)
}
fn get_extension_id() -> (Step<Run>, StepOutput) {
let step = named::bash(indoc! {
r#"
EXTENSION_ID="$(sed -n 's/id = \"\(.*\)\"/\1/p' < extension.toml)"
echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT"
"#})
.id("get-extension-id");
let extension_id = StepOutput::new(&step, "extension_id");
(step, extension_id)
}
fn release_action(
extension_id: StepOutput,
tag: JobOutput,
generated_token: &StepOutput,
) -> (Step<Use>, StepOutput) {
let step = named::uses(
"huacnlee",
"zed-extension-action",
"82920ff0876879f65ffbcfa3403589114a8919c6",
)
.id("extension-update")
.add_with(("extension-name", extension_id.to_string()))
.add_with(("push-to", "zed-industries/extensions"))
.add_with(("tag", tag.to_string()))
.add_env(("COMMITTER_TOKEN", generated_token.to_string()));
let pull_request_number = StepOutput::new(&step, "pull-request-number");
(step, pull_request_number)
}
fn enable_automerge_if_staff(
pull_request_number: StepOutput,
generated_token: StepOutput,
) -> Step<Use> {
named::uses("actions", "github-script", "v7")
.add_with(("github-token", generated_token.to_string()))
.add_with((
"script",
indoc! {r#"
const prNumber = process.env.PR_NUMBER;
if (!prNumber) {
console.log('No pull request number set, skipping automerge.');
return;
}
const author = process.env.GITHUB_ACTOR;
let isStaff = false;
try {
const response = await github.rest.teams.getMembershipForUserInOrg({
org: 'zed-industries',
team_slug: 'staff',
username: author
});
isStaff = response.data.state === 'active';
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
if (!isStaff) {
console.log(`Actor ${author} is not a staff member, skipping automerge.`);
return;
}
// Assign staff member responsible for the bump
const pullNumber = parseInt(prNumber);
await github.rest.issues.addAssignees({
owner: 'zed-industries',
repo: 'extensions',
issue_number: pullNumber,
assignees: [author]
});
console.log(`Assigned ${author} to PR #${prNumber} in zed-industries/extensions`);
// Get the GraphQL node ID
const { data: pr } = await github.rest.pulls.get({
owner: 'zed-industries',
repo: 'extensions',
pull_number: pullNumber
});
await github.graphql(`
mutation($pullRequestId: ID!) {
enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: SQUASH }) {
pullRequest {
autoMergeRequest {
enabledAt
}
}
}
}
`, { pullRequestId: pr.node_id });
console.log(`Automerge enabled for PR #${prNumber} in zed-industries/extensions`);
"#},
))
.add_env(("PR_NUMBER", pull_request_number.to_string()))
}
fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) {
let app_id = WorkflowSecret::new("app-id", "The app ID used to create the PR");
let app_secret =
WorkflowSecret::new("app-secret", "The app secret for the corresponding app ID");
(app_id, app_secret)
}
pub(crate) struct RepositoryTarget {
owner: Option<String>,
repositories: Option<String>,
permissions: Option<Vec<(String, Level)>>,
}
impl RepositoryTarget {
pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
Self {
owner: Some(owner.to_string()),
repositories: Some(repositories.join("\n")),
permissions: None,
}
}
pub fn current() -> Self {
Self {
owner: None,
repositories: None,
permissions: None,
}
}
pub fn permissions(self, permissions: impl Into<Vec<(String, Level)>>) -> Self {
Self {
permissions: Some(permissions.into()),
..self
}
}
}
@@ -0,0 +1,204 @@
use gh_workflow::*;
use indoc::indoc;
use crate::tasks::workflows::{
extension_bump::compare_versions,
run_tests::{fetch_ts_query_ls, orchestrate_for_extension, run_ts_query_ls, tests_pass},
runners,
steps::{
self, BASH_SHELL, CommonJobConditions, FluentBuilder, NamedJob,
cache_rust_dependencies_namespace, named,
},
vars::{PathCondition, StepOutput, WorkflowInput, one_workflow_per_non_main_branch_and_token},
};
pub(crate) const ZED_EXTENSION_CLI_SHA: &str = "03d8e9aee95ea6117d75a48bcac2e19241f6e667";
// This should follow the set target in crates/extension/src/extension_builder.rs
const EXTENSION_RUST_TARGET: &str = "wasm32-wasip2";
// This is used by various extensions repos in the zed-extensions org to run automated tests.
pub(crate) fn extension_tests() -> Workflow {
let should_check_rust = PathCondition::new("check_rust", r"^(Cargo.lock|Cargo.toml|.*\.rs)$");
let should_check_extension =
PathCondition::new("check_extension", r"^(extension\.toml|.*\.scm)$");
let orchestrate = with_extension_defaults(orchestrate_for_extension(&[
&should_check_rust,
&should_check_extension,
]));
let jobs = [
orchestrate,
should_check_rust.guard(check_rust()),
should_check_extension.guard(check_extension()),
];
let tests_pass = tests_pass(&jobs, &[]);
let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
named::workflow()
.add_event(
Event::default().workflow_call(
WorkflowCall::default()
.add_input(working_directory.name, working_directory.call_input()),
),
)
.concurrency(one_workflow_per_non_main_branch_and_token(
"extension-tests",
))
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("RUST_BACKTRACE", 1))
.add_env(("CARGO_INCREMENTAL", 0))
.add_env(("ZED_EXTENSION_CLI_SHA", ZED_EXTENSION_CLI_SHA))
.add_env(("RUSTUP_TOOLCHAIN", "stable"))
.add_env(("CARGO_BUILD_TARGET", EXTENSION_RUST_TARGET))
.map(|workflow| {
jobs.into_iter()
.chain([tests_pass])
.fold(workflow, |workflow, job| {
workflow.add_job(job.name, job.job)
})
})
}
fn install_rust_target() -> Step<Run> {
named::bash(format!("rustup target add {EXTENSION_RUST_TARGET}",))
}
fn get_package_name() -> (Step<Run>, StepOutput) {
let step = named::bash(indoc! {r#"
PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < Cargo.toml | head -1 | tr -d '[:space:]')"
echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
"#})
.id("get-package-name");
let output = StepOutput::new(&step, "package_name");
(step, output)
}
fn cargo_fmt_package(package_name: &StepOutput) -> Step<Run> {
named::bash(r#"cargo fmt -p "$PACKAGE_NAME" -- --check"#)
.add_env(("PACKAGE_NAME", package_name.to_string()))
}
fn run_clippy(package_name: &StepOutput) -> Step<Run> {
named::bash(r#"cargo clippy -p "$PACKAGE_NAME" --release --all-features -- --deny warnings"#)
.add_env(("PACKAGE_NAME", package_name.to_string()))
}
fn run_nextest(package_name: &StepOutput) -> Step<Run> {
named::bash(
r#"cargo nextest run -p "$PACKAGE_NAME" --no-fail-fast --no-tests=warn --target "$(rustc -vV | sed -n 's|host: ||p')""#,
)
.add_env(("PACKAGE_NAME", package_name.to_string()))
.add_env(("NEXTEST_NO_TESTS", "warn"))
}
fn extension_job_defaults() -> Defaults {
Defaults::default().run(
RunDefaults::default()
.shell(BASH_SHELL)
.working_directory("${{ inputs.working-directory }}"),
)
}
fn with_extension_defaults(named_job: NamedJob) -> NamedJob {
NamedJob {
name: named_job.name,
job: named_job.job.defaults(extension_job_defaults()),
}
}
fn check_rust() -> NamedJob {
let (get_package, package_name) = get_package_name();
let job = Job::default()
.defaults(extension_job_defaults())
.with_repository_owner_guard()
.runs_on(runners::LINUX_LARGE_RAM)
.timeout_minutes(6u32)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(install_rust_target())
.add_step(get_package)
.add_step(cargo_fmt_package(&package_name))
.add_step(run_clippy(&package_name))
.add_step(steps::cargo_install_nextest())
.add_step(run_nextest(&package_name));
named::job(job)
}
pub(crate) fn check_extension() -> NamedJob {
let (cache_download, cache_hit) = cache_zed_extension_cli();
let (check_version_job, version_changed, _) = compare_versions();
let job = Job::default()
.defaults(extension_job_defaults())
.with_repository_owner_guard()
.runs_on(runners::LINUX_LARGE_RAM)
.timeout_minutes(6u32)
.add_step(steps::checkout_repo().with_full_history())
.add_step(cache_download)
.add_step(download_zed_extension_cli(cache_hit))
.add_step(cache_rust_dependencies_namespace()) // Extensions can compile Rust, so provide the cache if needed.
.add_step(check())
.add_step(fetch_ts_query_ls())
.add_step(run_ts_query_ls())
.add_step(check_version_job)
.add_step(verify_version_did_not_change(version_changed));
named::job(job)
}
pub fn cache_zed_extension_cli() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",
"cache",
"0057852bfaa89a56745cba8c7296529d2fc39830",
)
.id("cache-zed-extension-cli")
.with(
Input::default()
.add("path", "zed-extension")
.add("key", "zed-extension-${{ env.ZED_EXTENSION_CLI_SHA }}"),
);
let output = StepOutput::new(&step, "cache-hit");
(step, output)
}
pub fn download_zed_extension_cli(cache_hit: StepOutput) -> Step<Run> {
named::bash(
indoc! {
r#"
wget --quiet "https://zed-extension-cli.nyc3.digitaloceanspaces.com/$ZED_EXTENSION_CLI_SHA/x86_64-unknown-linux-gnu/zed-extension" -O "$GITHUB_WORKSPACE/zed-extension"
chmod +x "$GITHUB_WORKSPACE/zed-extension"
"#,
}
).if_condition(Expression::new(format!("{} != 'true'", cache_hit.expr())))
}
pub fn check() -> Step<Run> {
named::bash(indoc! {
r#"
mkdir -p /tmp/ext-scratch
mkdir -p /tmp/ext-output
"$GITHUB_WORKSPACE/zed-extension" --source-dir . --scratch-dir /tmp/ext-scratch --output-dir /tmp/ext-output
"#
})
}
fn verify_version_did_not_change(version_changed: StepOutput) -> Step<Run> {
named::bash(indoc! {r#"
if [[ "$VERSION_CHANGED" == "true" && "$GITHUB_EVENT_NAME" == "pull_request" && "$PR_USER_LOGIN" != "zed-zippy[bot]" ]] ; then
echo "Version change detected in your change!"
echo "Version changes happen in separate PRs and will be performed by the zed-zippy bot"
exit 42
fi
"#
})
.add_env(("VERSION_CHANGED", version_changed.to_string()))
.add_env(("PR_USER_LOGIN", "${{ github.event.pull_request.user.login }}"))
}
@@ -0,0 +1,394 @@
use gh_workflow::{
Event, Expression, Job, Level, Run, Step, Strategy, Use, Workflow, WorkflowDispatch,
};
use indoc::formatdoc;
use indoc::indoc;
use serde_json::json;
use crate::tasks::workflows::steps::CheckoutStep;
use crate::tasks::workflows::steps::cache_rust_dependencies_namespace;
use crate::tasks::workflows::vars::JobOutput;
use crate::tasks::workflows::{
extension_bump::{RepositoryTarget, generate_token},
runners,
steps::{self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, named},
vars::{self, StepOutput, WorkflowInput},
};
const ROLLOUT_TAG_NAME: &str = "extension-workflows";
const WORKFLOW_ARTIFACT_NAME: &str = "extension-workflow-files";
pub(crate) fn extension_workflow_rollout() -> Workflow {
let filter_repos_input = WorkflowInput::string("filter-repos", Some(String::new()))
.description(
"Comma-separated list of repository names to rollout to. Leave empty for all repos.",
);
let extra_context_input = WorkflowInput::string("change-description", Some(String::new()))
.description("Description for the changes to be expected with this rollout");
let (fetch_repos, removed_ci, removed_shared) = fetch_extension_repos(&filter_repos_input);
let rollout_workflows = rollout_workflows_to_extension(
&fetch_repos,
removed_ci,
removed_shared,
&extra_context_input,
);
let create_tag = create_rollout_tag(&rollout_workflows, &filter_repos_input);
named::workflow()
.on(Event::default().workflow_dispatch(
WorkflowDispatch::default()
.add_input(filter_repos_input.name, filter_repos_input.input())
.add_input(extra_context_input.name, extra_context_input.input()),
))
.add_env(("CARGO_TERM_COLOR", "always"))
.add_job(fetch_repos.name, fetch_repos.job)
.add_job(rollout_workflows.name, rollout_workflows.job)
.add_job(create_tag.name, create_tag.job)
}
fn fetch_extension_repos(filter_repos_input: &WorkflowInput) -> (NamedJob, JobOutput, JobOutput) {
fn get_repositories(filter_repos_input: &WorkflowInput) -> (Step<Use>, StepOutput) {
let step = named::uses("actions", "github-script", "v7")
.id("list-repos")
.add_with((
"script",
formatdoc! {r#"
const repos = await github.paginate(github.rest.repos.listForOrg, {{
org: 'zed-extensions',
type: 'public',
per_page: 100,
}});
let filteredRepos = repos
.filter(repo => !repo.archived)
.map(repo => repo.name);
const filterInput = `{filter_repos_input}`.trim();
if (filterInput.length > 0) {{
const allowedNames = filterInput.split(',').map(s => s.trim()).filter(s => s.length > 0);
filteredRepos = filteredRepos.filter(name => allowedNames.includes(name));
console.log(`Filter applied. Matched ${{filteredRepos.length}} repos from ${{allowedNames.length}} requested.`);
}}
console.log(`Found ${{filteredRepos.length}} extension repos`);
return filteredRepos;
"#},
))
.add_with(("result-encoding", "json"));
let filtered_repos = StepOutput::new(&step, "result");
(step, filtered_repos)
}
fn checkout_zed_repo() -> CheckoutStep {
steps::checkout_repo()
.with_full_history()
.with_custom_name("checkout_zed_repo")
}
fn get_previous_tag_commit() -> (Step<Run>, StepOutput) {
let step = named::bash(formatdoc! {r#"
PREV_COMMIT=$(git rev-parse "{ROLLOUT_TAG_NAME}^{{commit}}" 2>/dev/null || echo "")
if [ -z "$PREV_COMMIT" ]; then
echo "::error::No previous rollout tag '{ROLLOUT_TAG_NAME}' found. Cannot determine file changes."
exit 1
fi
echo "Found previous rollout at commit: $PREV_COMMIT"
echo "prev_commit=$PREV_COMMIT" >> "$GITHUB_OUTPUT"
"#})
.id("prev-tag");
let step_output = StepOutput::new(&step, "prev_commit");
(step, step_output)
}
fn get_removed_files(prev_commit: &StepOutput) -> (Step<Run>, StepOutput, StepOutput) {
let step = named::bash(indoc! {r#"
for workflow_type in "ci" "shared"; do
if [ "$workflow_type" = "ci" ]; then
WORKFLOW_DIR="extensions/workflows"
else
WORKFLOW_DIR="extensions/workflows/shared"
fi
REMOVED=$(git diff --name-status -M "$PREV_COMMIT" HEAD -- "$WORKFLOW_DIR" | \
awk '/^D/ { print $2 } /^R/ { print $2 }' | \
xargs -I{} basename {} 2>/dev/null | \
tr '\n' ' ' || echo "")
REMOVED=$(echo "$REMOVED" | xargs)
echo "Removed files for $workflow_type: $REMOVED"
echo "removed_${workflow_type}=$REMOVED" >> "$GITHUB_OUTPUT"
done
"#})
.id("calc-changes")
.add_env(("PREV_COMMIT", prev_commit.to_string()));
// These are created in the for-loop above and thus do exist
let removed_ci = StepOutput::new_unchecked(&step, "removed_ci");
let removed_shared = StepOutput::new_unchecked(&step, "removed_shared");
(step, removed_ci, removed_shared)
}
fn generate_workflow_files() -> Step<Run> {
named::bash(indoc! {r#"
cargo xtask workflows "$COMMIT_SHA"
"#})
.add_env(("COMMIT_SHA", "${{ github.sha }}"))
}
fn upload_workflow_files() -> Step<Use> {
named::uses(
"actions",
"upload-artifact",
"330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
)
.add_with(("name", WORKFLOW_ARTIFACT_NAME))
.add_with(("path", "extensions/workflows/**/*.yml"))
.add_with(("if-no-files-found", "error"))
}
let (get_org_repositories, list_repos_output) = get_repositories(filter_repos_input);
let (get_prev_tag, prev_commit) = get_previous_tag_commit();
let (calc_changes, removed_ci, removed_shared) = get_removed_files(&prev_commit);
let job = Job::default()
.cond(Expression::new(format!(
"{DEFAULT_REPOSITORY_OWNER_GUARD} && github.ref == 'refs/heads/main'"
)))
.runs_on(runners::LINUX_SMALL)
.timeout_minutes(10u32)
.outputs([
("repos".to_owned(), list_repos_output.to_string()),
("prev_commit".to_owned(), prev_commit.to_string()),
("removed_ci".to_owned(), removed_ci.to_string()),
("removed_shared".to_owned(), removed_shared.to_string()),
])
.add_step(checkout_zed_repo())
.add_step(get_prev_tag)
.add_step(calc_changes)
.add_step(get_org_repositories)
.add_step(cache_rust_dependencies_namespace())
.add_step(generate_workflow_files())
.add_step(upload_workflow_files());
let job = named::job(job);
let (removed_ci, removed_shared) = (
removed_ci.as_job_output(&job),
removed_shared.as_job_output(&job),
);
(job, removed_ci, removed_shared)
}
fn rollout_workflows_to_extension(
fetch_repos_job: &NamedJob,
removed_ci: JobOutput,
removed_shared: JobOutput,
extra_context_input: &WorkflowInput,
) -> NamedJob {
fn checkout_extension_repo(token: &StepOutput) -> CheckoutStep {
steps::checkout_repo()
.with_custom_name("checkout_extension_repo")
.with_token(token)
.with_repository("zed-extensions/${{ matrix.repo }}")
.with_path("extension")
}
fn download_workflow_files() -> Step<Use> {
named::uses(
"actions",
"download-artifact",
"018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
)
.add_with(("name", WORKFLOW_ARTIFACT_NAME))
.add_with(("path", "workflow-files"))
}
fn sync_workflow_files(removed_ci: JobOutput, removed_shared: JobOutput) -> Step<Run> {
named::bash(indoc! {r#"
mkdir -p extension/.github/workflows
if [ "$MATRIX_REPO" = "workflows" ]; then
REMOVED_FILES="$REMOVED_CI"
else
REMOVED_FILES="$REMOVED_SHARED"
fi
cd extension/.github/workflows
if [ -n "$REMOVED_FILES" ]; then
for file in $REMOVED_FILES; do
if [ -f "$file" ]; then
rm -f "$file"
fi
done
fi
cd - > /dev/null
if [ "$MATRIX_REPO" = "workflows" ]; then
cp workflow-files/*.yml extension/.github/workflows/
else
cp workflow-files/shared/*.yml extension/.github/workflows/
fi
"#})
.add_env(("REMOVED_CI", removed_ci))
.add_env(("REMOVED_SHARED", removed_shared))
.add_env(("MATRIX_REPO", "${{ matrix.repo }}"))
}
fn get_short_sha() -> (Step<Run>, StepOutput) {
let step = named::bash(indoc! {r#"
echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
"#})
.id("short-sha");
let step_output = StepOutput::new(&step, "sha_short");
(step, step_output)
}
fn create_pull_request(
token: &StepOutput,
short_sha: &StepOutput,
context_input: &WorkflowInput,
) -> Step<Use> {
let title = format!("Update CI workflows to `{short_sha}`");
let body = formatdoc! {r#"
This PR updates the CI workflow files from the main Zed repository
based on the commit zed-industries/zed@${{{{ github.sha }}}}
{context_input}
"#,
};
named::uses("peter-evans", "create-pull-request", "v7")
.add_with(("path", "extension"))
.add_with(("title", title.clone()))
.add_with(("body", body))
.add_with(("commit-message", title))
.add_with(("branch", "update-workflows"))
.add_with((
"committer",
"zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
))
.add_with((
"author",
"zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
))
.add_with(("base", "main"))
.add_with(("delete-branch", true))
.add_with(("token", token.to_string()))
.add_with(("sign-commits", true))
.id("create-pr")
}
fn enable_auto_merge(token: &StepOutput) -> Step<gh_workflow::Run> {
named::bash(indoc! {r#"
if [ -n "$PR_NUMBER" ]; then
gh pr merge "$PR_NUMBER" --auto --squash
fi
"#})
.working_directory("extension")
.add_env(("GH_TOKEN", token.to_string()))
.add_env((
"PR_NUMBER",
"${{ steps.create-pr.outputs.pull-request-number }}",
))
}
let (authenticate, token) = generate_token(
vars::ZED_ZIPPY_APP_ID,
vars::ZED_ZIPPY_APP_PRIVATE_KEY,
Some(
RepositoryTarget::new("zed-extensions", &["${{ matrix.repo }}"]).permissions([
("permission-pull-requests".to_owned(), Level::Write),
("permission-contents".to_owned(), Level::Write),
("permission-workflows".to_owned(), Level::Write),
]),
),
);
let (calculate_short_sha, short_sha) = get_short_sha();
let job = Job::default()
.needs([fetch_repos_job.name.clone()])
.cond(Expression::new(format!(
"needs.{}.outputs.repos != '[]'",
fetch_repos_job.name
)))
.runs_on(runners::LINUX_SMALL)
.timeout_minutes(10u32)
.strategy(
Strategy::default()
.fail_fast(false)
.max_parallel(10u32)
.matrix(json!({
"repo": format!("${{{{ fromJson(needs.{}.outputs.repos) }}}}", fetch_repos_job.name)
})),
)
.add_step(authenticate)
.add_step(checkout_extension_repo(&token))
.add_step(download_workflow_files())
.add_step(sync_workflow_files(removed_ci, removed_shared))
.add_step(calculate_short_sha)
.add_step(create_pull_request(&token, &short_sha, extra_context_input))
.add_step(enable_auto_merge(&token));
named::job(job)
}
fn create_rollout_tag(rollout_job: &NamedJob, filter_repos_input: &WorkflowInput) -> NamedJob {
fn checkout_zed_repo(token: &StepOutput) -> CheckoutStep {
steps::checkout_repo().with_full_history().with_token(token)
}
fn update_rollout_tag() -> Step<Run> {
named::bash(formatdoc! {r#"
if git rev-parse "{ROLLOUT_TAG_NAME}" >/dev/null 2>&1; then
git tag -d "{ROLLOUT_TAG_NAME}"
git push origin ":refs/tags/{ROLLOUT_TAG_NAME}" || true
fi
echo "Creating new tag '{ROLLOUT_TAG_NAME}' at $(git rev-parse --short HEAD)"
git tag "{ROLLOUT_TAG_NAME}"
git push origin "{ROLLOUT_TAG_NAME}"
"#})
}
fn configure_git() -> Step<Run> {
named::bash(indoc! {r#"
git config user.name "zed-zippy[bot]"
git config user.email "234243425+zed-zippy[bot]@users.noreply.github.com"
"#})
}
let (authenticate, token) = generate_token(
vars::ZED_ZIPPY_APP_ID,
vars::ZED_ZIPPY_APP_PRIVATE_KEY,
Some(
RepositoryTarget::current()
.permissions([("permission-contents".to_owned(), Level::Write)]),
),
);
let job = Job::default()
.needs([rollout_job.name.clone()])
.cond(Expression::new(format!(
"{filter_repos} == ''",
filter_repos = filter_repos_input.expr(),
)))
.runs_on(runners::LINUX_SMALL)
.timeout_minutes(1u32)
.add_step(authenticate)
.add_step(checkout_zed_repo(&token))
.add_step(configure_git())
.add_step(update_rollout_tag());
named::job(job)
}
@@ -0,0 +1,23 @@
use gh_workflow::{Job, UsesJob};
use indexmap::IndexMap;
use crate::tasks::workflows::vars;
pub(crate) mod bump_version;
pub(crate) mod run_tests;
pub(crate) trait WithAppSecrets: Sized {
fn with_app_secrets(self) -> Self;
}
impl WithAppSecrets for Job<UsesJob> {
fn with_app_secrets(self) -> Self {
self.secrets(IndexMap::from([
("app-id".to_owned(), vars::ZED_ZIPPY_APP_ID.to_owned()),
(
"app-secret".to_owned(),
vars::ZED_ZIPPY_APP_PRIVATE_KEY.to_owned(),
),
]))
}
}
@@ -0,0 +1,108 @@
use gh_workflow::{
Event, Expression, Input, Job, Level, Permissions, PullRequest, PullRequestType, Push, Run,
Step, UsesJob, Workflow, WorkflowDispatch,
};
use indoc::indoc;
use crate::tasks::workflows::{
GenerateWorkflowArgs, GitSha,
extensions::WithAppSecrets,
runners,
steps::{CommonJobConditions, NamedJob, named},
vars::{JobOutput, StepOutput, one_workflow_per_non_main_branch_and_token},
};
pub(crate) fn bump_version(args: &GenerateWorkflowArgs) -> Workflow {
let (determine_bump_type, bump_type) = determine_bump_type();
let bump_type = bump_type.as_job_output(&determine_bump_type);
let call_bump_version = call_bump_version(args.sha.as_ref(), &determine_bump_type, bump_type);
named::workflow()
.on(Event::default()
.push(
Push::default()
.add_branch("main")
.add_ignored_path(".github/**"),
)
.pull_request(PullRequest::default().add_type(PullRequestType::Labeled))
.workflow_dispatch(WorkflowDispatch::default()))
.concurrency(one_workflow_per_non_main_branch_and_token("labels"))
.add_job(determine_bump_type.name, determine_bump_type.job)
.add_job(call_bump_version.name, call_bump_version.job)
}
pub(crate) fn call_bump_version(
target_ref: Option<&GitSha>,
depending_job: &NamedJob,
bump_type: JobOutput,
) -> NamedJob<UsesJob> {
let job = Job::default()
.cond(Expression::new(format!(
"github.event.action != 'labeled' || {} != 'patch'",
bump_type.expr()
)))
.permissions(
Permissions::default()
.contents(Level::Write)
.issues(Level::Write)
.pull_requests(Level::Write)
.actions(Level::Write),
)
.uses(
"zed-industries",
"zed",
".github/workflows/extension_bump.yml",
target_ref.map_or("main", AsRef::as_ref),
)
.add_need(depending_job.name.clone())
.with(
Input::default()
.add("bump-type", bump_type.to_string())
.add("force-bump", "${{ github.event_name != 'push' }}"),
)
.with_app_secrets();
named::job(job)
}
fn determine_bump_type() -> (NamedJob, StepOutput) {
let (get_bump_type, output) = get_bump_type();
let job = Job::default()
.with_repository_owner_guard()
.permissions(Permissions::default())
.runs_on(runners::LINUX_SMALL)
.add_step(get_bump_type)
.outputs([(output.name.to_owned(), output.to_string())]);
(named::job(job), output)
}
fn get_bump_type() -> (Step<Run>, StepOutput) {
let step = named::bash(
indoc! {r#"
if [ "$HAS_MAJOR_LABEL" = "true" ]; then
bump_type="major"
elif [ "$HAS_MINOR_LABEL" = "true" ]; then
bump_type="minor"
else
bump_type="patch"
fi
echo "bump_type=$bump_type" >> $GITHUB_OUTPUT
"#},
)
.add_env(("HAS_MAJOR_LABEL",
indoc!{
"${{ (github.event.action == 'labeled' && github.event.label.name == 'major') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'major')) }}"
}))
.add_env(("HAS_MINOR_LABEL",
indoc!{
"${{ (github.event.action == 'labeled' && github.event.label.name == 'minor') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'minor')) }}"
}))
.id("get-bump-type");
let step_output = StepOutput::new(&step, "bump_type");
(step, step_output)
}
@@ -0,0 +1,30 @@
use gh_workflow::{Event, Job, Level, Permissions, PullRequest, Push, UsesJob, Workflow};
use crate::tasks::workflows::{
GenerateWorkflowArgs, GitSha,
steps::{NamedJob, named},
vars::one_workflow_per_non_main_branch_and_token,
};
pub(crate) fn run_tests(args: &GenerateWorkflowArgs) -> Workflow {
let call_extension_tests = call_extension_tests(args.sha.as_ref());
named::workflow()
.on(Event::default()
.pull_request(PullRequest::default().add_branch("**"))
.push(Push::default().add_branch("main")))
.concurrency(one_workflow_per_non_main_branch_and_token("pr"))
.add_job(call_extension_tests.name, call_extension_tests.job)
}
pub(crate) fn call_extension_tests(target_ref: Option<&GitSha>) -> NamedJob<UsesJob> {
let job = Job::default()
.permissions(Permissions::default().contents(Level::Read))
.uses(
"zed-industries",
"zed",
".github/workflows/extension_tests.yml",
target_ref.map_or("main", AsRef::as_ref),
);
named::job(job)
}
@@ -0,0 +1,125 @@
use crate::tasks::workflows::{
runners::{Arch, Platform},
steps::{CommonJobConditions, NamedJob},
};
use super::{runners, steps, steps::named, vars};
use gh_workflow::*;
pub(crate) fn build_nix(
platform: Platform,
arch: Arch,
flake_output: &str,
cachix_filter: Option<&str>,
deps: &[&NamedJob],
) -> NamedJob {
pub fn install_nix() -> Step<Use> {
named::uses(
"cachix",
"install-nix-action",
"02a151ada4993995686f9ed4f1be7cfbb229e56f", // v31
)
.add_with(("github_access_token", vars::GITHUB_TOKEN))
}
pub fn cachix_action(cachix_filter: Option<&str>) -> Step<Use> {
let mut step = named::uses(
"cachix",
"cachix-action",
"0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad", // v16
)
.add_with(("name", "zed"))
.add_with(("authToken", vars::CACHIX_AUTH_TOKEN))
.add_with(("cachixArgs", "-v"));
if let Some(cachix_filter) = cachix_filter {
step = step.add_with(("pushFilter", cachix_filter));
}
step
}
pub fn build(flake_output: &str) -> Step<Run> {
named::bash(&format!(
"nix build .#{} -L --accept-flake-config",
flake_output
))
}
// After install-nix, register ~/nix-cache as a local binary cache
// substituter so nix pulls from it on demand during builds (no bulk
// import). Also restart the daemon so it picks up the new config.
pub fn configure_local_nix_cache() -> Step<Run> {
named::bash(indoc::indoc! {r#"
mkdir -p ~/nix-cache
echo "extra-substituters = file://$HOME/nix-cache?priority=10" | sudo tee -a /etc/nix/nix.conf
echo "require-sigs = false" | sudo tee -a /etc/nix/nix.conf
sudo launchctl kickstart -k system/org.nixos.nix-daemon
"#})
}
// Incrementally copy only new store paths from the build result's
// closure into the local binary cache for the next run.
pub fn export_to_local_nix_cache() -> Step<Run> {
named::bash(indoc::indoc! {r#"
if [ -L result ]; then
echo "Copying build closure to local binary cache..."
nix copy --to "file://$HOME/nix-cache" ./result || echo "Warning: nix copy to local cache failed"
else
echo "No build result found, skipping cache export."
fi
"#})
.if_condition(Expression::new("always()"))
}
let runner = match platform {
Platform::Windows => unimplemented!(),
Platform::Linux => runners::LINUX_X86_BUNDLER,
Platform::Mac => runners::MAC_DEFAULT,
};
let mut job = Job::default()
.timeout_minutes(60u32)
.continue_on_error(true)
.with_repository_owner_guard()
.runs_on(runner)
.add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED))
.add_env(("ZED_MINIDUMP_ENDPOINT", vars::ZED_SENTRY_MINIDUMP_ENDPOINT))
.add_env((
"ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON",
vars::ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON,
))
.add_env(("GIT_LFS_SKIP_SMUDGE", "1")) // breaks the livekit rust sdk examples which we don't actually depend on
.add_step(steps::checkout_repo());
if deps.len() > 0 {
job = job.needs(deps.iter().map(|d| d.name.clone()).collect::<Vec<String>>());
}
// On Linux, `cache: nix` uses bind-mounts so the /nix store is available
// before install-nix-action runs — no extra steps needed.
//
// On macOS, `/nix` lives on a read-only root filesystem and the nscloud
// cache action cannot mount or symlink there. Instead we cache a
// user-writable directory (~/nix-cache) as a local binary cache and
// register it as a nix substituter. Nix then pulls paths from it on
// demand during builds (zero-copy at startup), and after building we
// incrementally copy new paths into the cache for the next run.
job = match platform {
Platform::Linux => job
.add_step(steps::cache_nix_dependencies_namespace())
.add_step(install_nix())
.add_step(cachix_action(cachix_filter))
.add_step(build(&flake_output)),
Platform::Mac => job
.add_step(steps::cache_nix_store_macos())
.add_step(install_nix())
.add_step(configure_local_nix_cache())
.add_step(cachix_action(cachix_filter))
.add_step(build(&flake_output))
.add_step(export_to_local_nix_cache()),
Platform::Windows => unimplemented!(),
};
NamedJob {
name: format!("build_nix_{platform}_{arch}"),
job,
}
}
@@ -0,0 +1,201 @@
use gh_workflow::{ctx::Context, *};
use indoc::indoc;
use crate::tasks::workflows::{
extension_bump::{RepositoryTarget, generate_token},
runners,
steps::{self, CommonJobConditions, NamedJob, named},
vars::{self, StepOutput},
};
pub fn publish_extension_cli() -> Workflow {
let publish = publish_job();
let update_sha_in_zed = update_sha_in_zed(&publish);
let update_sha_in_extensions = update_sha_in_extensions(&publish);
named::workflow()
.on(Event::default().push(Push::default().tags(vec!["extension-cli".to_string()])))
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("CARGO_INCREMENTAL", 0))
.add_job(publish.name, publish.job)
.add_job(update_sha_in_zed.name, update_sha_in_zed.job)
.add_job(update_sha_in_extensions.name, update_sha_in_extensions.job)
}
fn publish_job() -> NamedJob {
fn build_extension_cli() -> Step<Run> {
named::bash("cargo build --release --package extension_cli")
}
fn upload_binary() -> Step<Run> {
named::bash(r#"script/upload-extension-cli "$GITHUB_SHA""#)
.add_env((
"DIGITALOCEAN_SPACES_ACCESS_KEY",
vars::DIGITALOCEAN_SPACES_ACCESS_KEY,
))
.add_env((
"DIGITALOCEAN_SPACES_SECRET_KEY",
vars::DIGITALOCEAN_SPACES_SECRET_KEY,
))
}
named::job(
Job::default()
.with_repository_owner_guard()
.runs_on(runners::LINUX_SMALL)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(steps::setup_linux())
.add_step(build_extension_cli())
.add_step(upload_binary()),
)
}
fn update_sha_in_zed(publish_job: &NamedJob) -> NamedJob {
let (generate_token, generated_token) = generate_token(
vars::ZED_ZIPPY_APP_ID,
vars::ZED_ZIPPY_APP_PRIVATE_KEY,
Some(RepositoryTarget::current()),
);
fn replace_sha() -> Step<Run> {
named::bash(indoc! {r#"
sed -i "s/ZED_EXTENSION_CLI_SHA: &str = \"[a-f0-9]*\"/ZED_EXTENSION_CLI_SHA: \&str = \"$GITHUB_SHA\"/" \
tooling/xtask/src/tasks/workflows/extension_tests.rs
"#})
}
fn regenerate_workflows() -> Step<Run> {
named::bash("cargo xtask workflows")
}
let (get_short_sha_step, short_sha) = get_short_sha();
named::job(
Job::default()
.with_repository_owner_guard()
.needs(vec![publish_job.name.clone()])
.runs_on(runners::LINUX_LARGE)
.add_step(generate_token)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(get_short_sha_step)
.add_step(replace_sha())
.add_step(regenerate_workflows())
.add_step(create_pull_request_zed(&generated_token, &short_sha)),
)
}
fn create_pull_request_zed(generated_token: &StepOutput, short_sha: &StepOutput) -> Step<Use> {
let title = format!(
"extension_ci: Bump extension CLI version to `{}`",
short_sha
);
named::uses("peter-evans", "create-pull-request", "v7").with(
Input::default()
.add("title", title.clone())
.add(
"body",
indoc! {r#"
This PR bumps the extension CLI version used in the extension workflows to `${{ github.sha }}`.
Release Notes:
- N/A
"#},
)
.add("commit-message", title)
.add("branch", "update-extension-cli-sha")
.add(
"committer",
"zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
)
.add("base", "main")
.add("delete-branch", true)
.add("token", generated_token.to_string())
.add("sign-commits", true)
.add("assignees", Context::github().actor().to_string()),
)
}
fn update_sha_in_extensions(publish_job: &NamedJob) -> NamedJob {
let extensions_repo = RepositoryTarget::new("zed-industries", &["extensions"]);
let (generate_token, generated_token) = generate_token(
vars::ZED_ZIPPY_APP_ID,
vars::ZED_ZIPPY_APP_PRIVATE_KEY,
Some(extensions_repo),
);
fn checkout_extensions_repo(token: &StepOutput) -> Step<Use> {
named::uses(
"actions",
"checkout",
"11bd71901bbe5b1630ceea73d27597364c9af683", // v4
)
.add_with(("repository", "zed-industries/extensions"))
.add_with(("token", token.to_string()))
}
fn replace_sha() -> Step<Run> {
named::bash(indoc! {r#"
sed -i "s/ZED_EXTENSION_CLI_SHA: [a-f0-9]*/ZED_EXTENSION_CLI_SHA: $GITHUB_SHA/" \
.github/workflows/ci.yml
"#})
}
let (get_short_sha_step, short_sha) = get_short_sha();
named::job(
Job::default()
.with_repository_owner_guard()
.needs(vec![publish_job.name.clone()])
.runs_on(runners::LINUX_SMALL)
.add_step(generate_token)
.add_step(get_short_sha_step)
.add_step(checkout_extensions_repo(&generated_token))
.add_step(replace_sha())
.add_step(create_pull_request_extensions(&generated_token, &short_sha)),
)
}
fn create_pull_request_extensions(
generated_token: &StepOutput,
short_sha: &StepOutput,
) -> Step<Use> {
let title = format!("Bump extension CLI version to `{}`", short_sha);
named::uses("peter-evans", "create-pull-request", "v7").with(
Input::default()
.add("title", title.clone())
.add(
"body",
indoc! {r#"
This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{ github.sha }}.
"#},
)
.add("commit-message", title)
.add("branch", "update-extension-cli-sha")
.add(
"committer",
"zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
)
.add("base", "main")
.add("delete-branch", true)
.add("token", generated_token.to_string())
.add("sign-commits", true)
.add("labels", "allow-no-extension")
.add("assignees", Context::github().actor().to_string()),
)
}
fn get_short_sha() -> (Step<Run>, StepOutput) {
let step = named::bash(indoc::indoc! {r#"
echo "sha_short=$(echo "$GITHUB_SHA" | cut -c1-7)" >> "$GITHUB_OUTPUT"
"#})
.id("short-sha");
let step_output = vars::StepOutput::new(&step, "sha_short");
(step, step_output)
}
@@ -0,0 +1,446 @@
use gh_workflow::{Event, Expression, Push, Run, Step, Use, Workflow, ctx::Context};
use indoc::formatdoc;
use crate::tasks::workflows::{
run_bundling::{bundle_linux, bundle_mac, bundle_windows},
run_tests,
runners::{self, Arch, Platform},
steps::{self, FluentBuilder, NamedJob, dependant_job, named, release_job},
vars::{self, StepOutput, assets},
};
const CURRENT_ACTION_RUN_URL: &str =
"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}";
pub(crate) fn release() -> Workflow {
let macos_tests = run_tests::run_platform_tests_no_filter(Platform::Mac);
let linux_tests = run_tests::run_platform_tests_no_filter(Platform::Linux);
let windows_tests = run_tests::run_platform_tests_no_filter(Platform::Windows);
let macos_clippy = run_tests::clippy(Platform::Mac, None);
let linux_clippy = run_tests::clippy(Platform::Linux, None);
let windows_clippy = run_tests::clippy(Platform::Windows, None);
let check_scripts = run_tests::check_scripts();
let create_draft_release = create_draft_release();
let bundle = ReleaseBundleJobs {
linux_aarch64: bundle_linux(
Arch::AARCH64,
None,
&[&linux_tests, &linux_clippy, &check_scripts],
),
linux_x86_64: bundle_linux(
Arch::X86_64,
None,
&[&linux_tests, &linux_clippy, &check_scripts],
),
mac_aarch64: bundle_mac(
Arch::AARCH64,
None,
&[&macos_tests, &macos_clippy, &check_scripts],
),
mac_x86_64: bundle_mac(
Arch::X86_64,
None,
&[&macos_tests, &macos_clippy, &check_scripts],
),
windows_aarch64: bundle_windows(
Arch::AARCH64,
None,
&[&windows_tests, &windows_clippy, &check_scripts],
),
windows_x86_64: bundle_windows(
Arch::X86_64,
None,
&[&windows_tests, &windows_clippy, &check_scripts],
),
};
let upload_release_assets = upload_release_assets(&[&create_draft_release], &bundle);
let validate_release_assets = validate_release_assets(&[&upload_release_assets]);
let auto_release_preview = auto_release_preview(&[&validate_release_assets]);
let test_jobs = [
&macos_tests,
&linux_tests,
&windows_tests,
&macos_clippy,
&linux_clippy,
&windows_clippy,
&check_scripts,
];
let push_slack_notification = push_release_update_notification(
&create_draft_release,
&upload_release_assets,
&validate_release_assets,
&auto_release_preview,
&test_jobs,
&bundle,
);
named::workflow()
.on(Event::default().push(Push::default().tags(vec!["v*".to_string()])))
.concurrency(vars::one_workflow_per_non_main_branch())
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("RUST_BACKTRACE", "1"))
.add_job(macos_tests.name, macos_tests.job)
.add_job(linux_tests.name, linux_tests.job)
.add_job(windows_tests.name, windows_tests.job)
.add_job(macos_clippy.name, macos_clippy.job)
.add_job(linux_clippy.name, linux_clippy.job)
.add_job(windows_clippy.name, windows_clippy.job)
.add_job(check_scripts.name, check_scripts.job)
.add_job(create_draft_release.name, create_draft_release.job)
.map(|mut workflow| {
for job in bundle.into_jobs() {
workflow = workflow.add_job(job.name, job.job);
}
workflow
})
.add_job(upload_release_assets.name, upload_release_assets.job)
.add_job(validate_release_assets.name, validate_release_assets.job)
.add_job(auto_release_preview.name, auto_release_preview.job)
.add_job(push_slack_notification.name, push_slack_notification.job)
}
pub(crate) struct ReleaseBundleJobs {
pub linux_aarch64: NamedJob,
pub linux_x86_64: NamedJob,
pub mac_aarch64: NamedJob,
pub mac_x86_64: NamedJob,
pub windows_aarch64: NamedJob,
pub windows_x86_64: NamedJob,
}
impl ReleaseBundleJobs {
pub fn jobs(&self) -> Vec<&NamedJob> {
vec![
&self.linux_aarch64,
&self.linux_x86_64,
&self.mac_aarch64,
&self.mac_x86_64,
&self.windows_aarch64,
&self.windows_x86_64,
]
}
pub fn into_jobs(self) -> Vec<NamedJob> {
vec![
self.linux_aarch64,
self.linux_x86_64,
self.mac_aarch64,
self.mac_x86_64,
self.windows_aarch64,
self.windows_x86_64,
]
}
}
pub(crate) fn create_sentry_release() -> Step<Use> {
named::uses(
"getsentry",
"action-release",
"526942b68292201ac6bbb99b9a0747d4abee354c", // v3
)
.add_env(("SENTRY_ORG", "zed-dev"))
.add_env(("SENTRY_PROJECT", "zed"))
.add_env(("SENTRY_AUTH_TOKEN", vars::SENTRY_AUTH_TOKEN))
.add_with(("environment", "production"))
}
fn validate_release_assets(deps: &[&NamedJob]) -> NamedJob {
let expected_assets: Vec<String> = assets::all().iter().map(|a| format!("\"{a}\"")).collect();
let expected_assets_json = format!("[{}]", expected_assets.join(", "));
let validation_script = formatdoc! {r#"
EXPECTED_ASSETS='{expected_assets_json}'
TAG="$GITHUB_REF_NAME"
ACTUAL_ASSETS=$(gh release view "$TAG" --repo=zed-industries/zed --json assets -q '[.assets[].name]')
MISSING_ASSETS=$(echo "$EXPECTED_ASSETS" | jq -r --argjson actual "$ACTUAL_ASSETS" '. - $actual | .[]')
if [ -n "$MISSING_ASSETS" ]; then
echo "Error: The following assets are missing from the release:"
echo "$MISSING_ASSETS"
exit 1
fi
echo "All expected assets are present in the release."
"#,
};
named::job(
dependant_job(deps).runs_on(runners::LINUX_SMALL).add_step(
named::bash(&validation_script).add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
),
)
}
fn auto_release_preview(deps: &[&NamedJob]) -> NamedJob {
let (authenticate, token) = steps::authenticate_as_zippy();
named::job(
dependant_job(deps)
.runs_on(runners::LINUX_SMALL)
.cond(Expression::new(indoc::indoc!(
r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
)))
.add_step(authenticate)
.add_step(
steps::script(
r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
)
.add_env(("GITHUB_TOKEN", &token)),
)
)
}
pub(crate) fn download_workflow_artifacts() -> Step<Use> {
named::uses(
"actions",
"download-artifact",
"018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
)
.add_with(("path", "./artifacts/"))
}
pub(crate) fn prep_release_artifacts() -> Step<Run> {
let mut script_lines = vec!["mkdir -p release-artifacts/\n".to_string()];
for asset in assets::all() {
let mv_command = format!("mv ./artifacts/{asset}/{asset} release-artifacts/{asset}");
script_lines.push(mv_command)
}
named::bash(&script_lines.join("\n"))
}
fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> NamedJob {
let mut deps = deps.to_vec();
deps.extend(bundle.jobs());
named::job(
dependant_job(&deps)
.runs_on(runners::LINUX_MEDIUM)
.add_step(download_workflow_artifacts())
.add_step(steps::script("ls -lR ./artifacts"))
.add_step(prep_release_artifacts())
.add_step(
steps::script("gh release upload \"$GITHUB_REF_NAME\" --repo=zed-industries/zed release-artifacts/*")
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
),
)
}
fn create_draft_release() -> NamedJob {
fn generate_release_notes() -> Step<Run> {
named::bash(
r#"node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md"#,
)
}
fn create_release() -> Step<Run> {
named::bash("script/create-draft-release target/release-notes.md")
.add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
}
named::job(
release_job(&[])
.runs_on(runners::LINUX_SMALL)
// We need to fetch more than one commit so that `script/draft-release-notes`
// is able to diff between the current and previous tag.
//
// 25 was chosen arbitrarily.
.add_step(
steps::checkout_repo()
.with_custom_fetch_depth(25)
.with_ref("${{ github.ref }}"),
)
.add_step(steps::script("script/determine-release-channel"))
.add_step(steps::script("mkdir -p target/"))
.add_step(generate_release_notes())
.add_step(create_release()),
)
}
pub(crate) fn push_release_update_notification(
create_draft_release_job: &NamedJob,
upload_assets_job: &NamedJob,
validate_assets_job: &NamedJob,
auto_release_preview: &NamedJob,
test_jobs: &[&NamedJob],
bundle_jobs: &ReleaseBundleJobs,
) -> NamedJob {
fn env_name(name: &str) -> String {
format!("RESULT_{}", name.to_uppercase())
}
let all_job_names: Vec<&str> = test_jobs
.iter()
.map(|j| j.name.as_ref())
.chain(bundle_jobs.jobs().into_iter().map(|j| j.name.as_ref()))
.collect();
let env_entries = [
(
"DRAFT_RESULT".into(),
format!("${{{{ needs.{}.result }}}}", create_draft_release_job.name),
),
(
"UPLOAD_RESULT".into(),
format!("${{{{ needs.{}.result }}}}", upload_assets_job.name),
),
(
"VALIDATE_RESULT".into(),
format!("${{{{ needs.{}.result }}}}", validate_assets_job.name),
),
(
"AUTO_RELEASE_RESULT".into(),
format!("${{{{ needs.{}.result }}}}", auto_release_preview.name),
),
("RUN_URL".into(), CURRENT_ACTION_RUN_URL.to_string()),
]
.into_iter()
.chain(
all_job_names
.iter()
.map(|name| (env_name(name), format!("${{{{ needs.{name}.result }}}}"))),
);
let failure_checks = all_job_names
.iter()
.map(|name| {
format!(
"if [ \"${env_name}\" == \"failure\" ];then FAILED_JOBS=\"$FAILED_JOBS {name}\"; fi",
env_name = env_name(name)
)
})
.collect::<Vec<_>>()
.join("\n ");
let notification_script = formatdoc! {r#"
TAG="$GITHUB_REF_NAME"
if [ "$DRAFT_RESULT" == "failure" ]; then
echo "❌ Draft release creation failed for $TAG: $RUN_URL"
else
RELEASE_URL=$(gh release view "$TAG" --repo=zed-industries/zed --json url -q '.url')
if [ "$UPLOAD_RESULT" == "failure" ]; then
echo "❌ Release asset upload failed for $TAG: $RELEASE_URL"
elif [ "$UPLOAD_RESULT" == "cancelled" ] || [ "$UPLOAD_RESULT" == "skipped" ]; then
FAILED_JOBS=""
{failure_checks}
FAILED_JOBS=$(echo "$FAILED_JOBS" | xargs)
if [ "$UPLOAD_RESULT" == "cancelled" ]; then
if [ -n "$FAILED_JOBS" ]; then
echo "❌ Release job for $TAG was cancelled, most likely because tests \`$FAILED_JOBS\` failed: $RUN_URL"
else
echo "❌ Release job for $TAG was cancelled: $RUN_URL"
fi
else
if [ -n "$FAILED_JOBS" ]; then
echo "❌ Tests \`$FAILED_JOBS\` for $TAG failed: $RUN_URL"
else
echo "❌ Tests for $TAG failed: $RUN_URL"
fi
fi
elif [ "$VALIDATE_RESULT" == "failure" ]; then
echo "❌ Release asset validation failed for $TAG (missing assets): $RUN_URL"
elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then
echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
echo "❌ Auto release failed for $TAG: $RUN_URL"
else
echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL"
fi
fi
"#,
};
let mut all_deps: Vec<&NamedJob> = vec![
create_draft_release_job,
upload_assets_job,
validate_assets_job,
auto_release_preview,
];
all_deps.extend(test_jobs.iter().copied());
all_deps.extend(bundle_jobs.jobs());
let mut job = dependant_job(&all_deps)
.runs_on(runners::LINUX_SMALL)
.cond(Expression::new("always()"));
for step in notify_slack(MessageType::Evaluated {
script: notification_script,
env: env_entries.collect(),
}) {
job = job.add_step(step);
}
named::job(job)
}
pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob {
let failure_message = format!("❌ ${{{{ github.workflow }}}} failed: {CURRENT_ACTION_RUN_URL}");
let mut job = dependant_job(deps)
.runs_on(runners::LINUX_SMALL)
.cond(Expression::new("failure()"));
for step in notify_slack(MessageType::Static(failure_message)) {
job = job.add_step(step);
}
named::job(job)
}
pub(crate) enum MessageType {
Static(String),
Evaluated {
script: String,
env: Vec<(String, String)>,
},
}
fn notify_slack(message: MessageType) -> Vec<Step<Run>> {
match message {
MessageType::Static(message) => vec![send_slack_message(message)],
MessageType::Evaluated { script, env } => {
let (generate_step, generated_message) = generate_slack_message(script, env);
vec![
generate_step,
send_slack_message(generated_message.to_string()),
]
}
}
}
fn generate_slack_message(
expression: String,
env: Vec<(String, String)>,
) -> (Step<Run>, StepOutput) {
let script = formatdoc! {r#"
MESSAGE=$({expression})
echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
"#
};
let mut generate_step = named::bash(&script)
.id("generate-webhook-message")
.add_env(("GH_TOKEN", Context::github().token()));
for (name, value) in env {
generate_step = generate_step.add_env((name, value));
}
let output = StepOutput::new(&generate_step, "message");
(generate_step, output)
}
fn send_slack_message(message: String) -> Step<Run> {
named::bash(
r#"curl -X POST -H 'Content-type: application/json' --data "$(jq -n --arg text "$SLACK_MESSAGE" '{"text": $text}')" "$SLACK_WEBHOOK""#
)
.add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
.add_env(("SLACK_MESSAGE", message))
}
@@ -0,0 +1,129 @@
use crate::tasks::workflows::{
nix_build::build_nix,
release::{
ReleaseBundleJobs, create_sentry_release, download_workflow_artifacts, notify_on_failure,
prep_release_artifacts,
},
run_bundling::{bundle_linux, bundle_mac, bundle_windows},
run_tests::{clippy, run_platform_tests_no_filter},
runners::{Arch, Platform, ReleaseChannel},
steps::{CommonJobConditions, FluentBuilder, NamedJob},
};
use super::{runners, steps, steps::named, vars};
use gh_workflow::*;
/// Generates the release_nightly.yml workflow
pub fn release_nightly() -> Workflow {
let style = check_style();
// run only on windows as that's our fastest platform right now.
let tests = run_platform_tests_no_filter(Platform::Windows);
let clippy_job = clippy(Platform::Windows, None);
let nightly = Some(ReleaseChannel::Nightly);
let bundle = ReleaseBundleJobs {
linux_aarch64: bundle_linux(Arch::AARCH64, nightly, &[&style, &tests, &clippy_job]),
linux_x86_64: bundle_linux(Arch::X86_64, nightly, &[&style, &tests, &clippy_job]),
mac_aarch64: bundle_mac(Arch::AARCH64, nightly, &[&style, &tests, &clippy_job]),
mac_x86_64: bundle_mac(Arch::X86_64, nightly, &[&style, &tests, &clippy_job]),
windows_aarch64: bundle_windows(Arch::AARCH64, nightly, &[&style, &tests, &clippy_job]),
windows_x86_64: bundle_windows(Arch::X86_64, nightly, &[&style, &tests, &clippy_job]),
};
let nix_linux_x86 = build_nix(
Platform::Linux,
Arch::X86_64,
"default",
None,
&[&style, &tests],
);
let nix_mac_arm = build_nix(
Platform::Mac,
Arch::AARCH64,
"default",
None,
&[&style, &tests],
);
let update_nightly_tag = update_nightly_tag_job(&bundle);
let notify_on_failure = notify_on_failure(&bundle.jobs());
named::workflow()
.on(Event::default()
// Fire every day at 7:00am UTC (Roughly before EU workday and after US workday)
.schedule([Schedule::new("0 7 * * *")])
.push(Push::default().add_tag("nightly")))
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("RUST_BACKTRACE", "1"))
.add_job(style.name, style.job)
.add_job(tests.name, tests.job)
.add_job(clippy_job.name, clippy_job.job)
.map(|mut workflow| {
for job in bundle.into_jobs() {
workflow = workflow.add_job(job.name, job.job);
}
workflow
})
.add_job(nix_linux_x86.name, nix_linux_x86.job)
.add_job(nix_mac_arm.name, nix_mac_arm.job)
.add_job(update_nightly_tag.name, update_nightly_tag.job)
.add_job(notify_on_failure.name, notify_on_failure.job)
}
fn check_style() -> NamedJob {
let job = release_job(&[])
.runs_on(runners::MAC_DEFAULT)
.add_step(steps::checkout_repo().with_full_history())
.add_step(steps::cargo_fmt())
.add_step(steps::script("./script/clippy"));
named::job(job)
}
fn release_job(deps: &[&NamedJob]) -> Job {
let job = Job::default()
.with_repository_owner_guard()
.timeout_minutes(60u32);
if deps.len() > 0 {
job.needs(deps.iter().map(|j| j.name.clone()).collect::<Vec<_>>())
} else {
job
}
}
fn update_nightly_tag_job(bundle: &ReleaseBundleJobs) -> NamedJob {
fn update_nightly_tag() -> Step<Run> {
named::bash(indoc::indoc! {r#"
if [ "$(git rev-parse nightly)" = "$(git rev-parse HEAD)" ]; then
echo "Nightly tag already points to current commit. Skipping tagging."
exit 0
fi
git config user.name github-actions
git config user.email github-actions@github.com
git tag -f nightly
git push origin nightly --force
"#})
}
NamedJob {
name: "update_nightly_tag".to_owned(),
job: steps::release_job(&bundle.jobs())
.runs_on(runners::LINUX_MEDIUM)
.add_step(steps::checkout_repo().with_full_history())
.add_step(download_workflow_artifacts())
.add_step(steps::script("ls -lR ./artifacts"))
.add_step(prep_release_artifacts())
.add_step(
steps::script("./script/upload-nightly")
.add_env((
"DIGITALOCEAN_SPACES_ACCESS_KEY",
vars::DIGITALOCEAN_SPACES_ACCESS_KEY,
))
.add_env((
"DIGITALOCEAN_SPACES_SECRET_KEY",
vars::DIGITALOCEAN_SPACES_SECRET_KEY,
)),
)
.add_step(update_nightly_tag())
.add_step(create_sentry_release()),
}
}
@@ -0,0 +1,169 @@
use gh_workflow::{Event, Expression, Job, Run, Step, Strategy, Use, Workflow, WorkflowDispatch};
use serde_json::json;
use crate::tasks::workflows::{
runners::{self, Platform},
steps::{self, FluentBuilder as _, NamedJob, named, setup_cargo_config},
vars::{self, WorkflowInput},
};
pub(crate) fn run_agent_evals() -> Workflow {
let agent_evals = agent_evals();
let model_name = WorkflowInput::string("model_name", None);
named::workflow()
.on(Event::default().workflow_dispatch(
WorkflowDispatch::default().add_input(model_name.name, model_name.input()),
))
.concurrency(vars::one_workflow_per_non_main_branch())
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("CARGO_INCREMENTAL", 0))
.add_env(("RUST_BACKTRACE", 1))
.add_env(("ANTHROPIC_API_KEY", vars::ANTHROPIC_API_KEY))
.add_env(("OPENAI_API_KEY", vars::OPENAI_API_KEY))
.add_env(("GOOGLE_AI_API_KEY", vars::GOOGLE_AI_API_KEY))
.add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT))
.add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED))
.add_env(("ZED_EVAL_TELEMETRY", 1))
.add_env(("MODEL_NAME", model_name.to_string()))
.add_job(agent_evals.name, agent_evals.job)
}
pub(crate) fn run_unit_evals() -> Workflow {
let model_name = WorkflowInput::string("model_name", None);
let commit_sha = WorkflowInput::string("commit_sha", None);
let unit_evals = named::job(unit_evals(Some(&commit_sha)));
named::workflow()
.name("run_unit_evals")
.on(Event::default().workflow_dispatch(
WorkflowDispatch::default()
.add_input(model_name.name, model_name.input())
.add_input(commit_sha.name, commit_sha.input()),
))
.concurrency(vars::allow_concurrent_runs())
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("CARGO_INCREMENTAL", 0))
.add_env(("RUST_BACKTRACE", 1))
.add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED))
.add_env(("ZED_EVAL_TELEMETRY", 1))
.add_env(("MODEL_NAME", model_name.to_string()))
.add_job(unit_evals.name, unit_evals.job)
}
fn add_api_keys(step: Step<Run>) -> Step<Run> {
step.add_env(("ANTHROPIC_API_KEY", vars::ANTHROPIC_API_KEY))
.add_env(("OPENAI_API_KEY", vars::OPENAI_API_KEY))
.add_env(("GOOGLE_AI_API_KEY", vars::GOOGLE_AI_API_KEY))
.add_env(("GOOGLE_CLOUD_PROJECT", vars::GOOGLE_CLOUD_PROJECT))
}
fn agent_evals() -> NamedJob {
fn run_eval() -> Step<Run> {
named::bash(
"cargo run --package=eval -- --repetitions=8 --concurrency=1 --model \"${MODEL_NAME}\"",
)
}
named::job(
Job::default()
.runs_on(runners::LINUX_DEFAULT)
.timeout_minutes(60_u32 * 10)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.map(steps::install_linux_dependencies)
.add_step(setup_cargo_config(Platform::Linux))
.add_step(steps::setup_sccache(Platform::Linux))
.add_step(steps::script("cargo build --package=eval"))
.add_step(add_api_keys(run_eval()))
.add_step(steps::show_sccache_stats(Platform::Linux))
.add_step(steps::cleanup_cargo_config(Platform::Linux)),
)
}
pub(crate) fn run_cron_unit_evals() -> Workflow {
let unit_evals = cron_unit_evals();
named::workflow()
.name("run_cron_unit_evals")
.on(Event::default()
// .schedule([
// // GitHub might drop jobs at busy times, so we choose a random time in the middle of the night.
// Schedule::default().cron("47 1 * * 2"),
// ])
.workflow_dispatch(WorkflowDispatch::default()))
.concurrency(vars::one_workflow_per_non_main_branch())
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("CARGO_INCREMENTAL", 0))
.add_env(("RUST_BACKTRACE", 1))
.add_env(("ZED_CLIENT_CHECKSUM_SEED", vars::ZED_CLIENT_CHECKSUM_SEED))
.add_job(unit_evals.name, unit_evals.job)
}
fn cron_unit_evals() -> NamedJob {
fn send_failure_to_slack() -> Step<Use> {
named::uses(
"slackapi",
"slack-github-action",
"b0fa283ad8fea605de13dc3f449259339835fc52",
)
.if_condition(Expression::new("${{ failure() }}"))
.add_with(("method", "chat.postMessage"))
.add_with(("token", vars::SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN))
.add_with(("payload", indoc::indoc!{r#"
channel: C04UDRNNJFQ
text: "Unit Evals Failed: https://github.com/zed-industries/zed/actions/runs/${{ github.run_id }}"
"#}))
}
named::job(cron_unit_evals_job().add_step(send_failure_to_slack()))
}
const UNIT_EVAL_MODELS: &[&str] = &[
"anthropic/claude-sonnet-4-5-latest",
"anthropic/claude-opus-4-5-latest",
"google/gemini-3.1-pro",
"openai/gpt-5",
];
fn cron_unit_evals_job() -> Job {
let script_step = add_api_keys(steps::script("./script/run-unit-evals"))
.add_env(("ZED_AGENT_MODEL", "${{ matrix.model }}"));
Job::default()
.runs_on(runners::LINUX_DEFAULT)
.strategy(Strategy::default().fail_fast(false).matrix(json!({
"model": UNIT_EVAL_MODELS
})))
.add_step(steps::checkout_repo())
.add_step(steps::setup_cargo_config(Platform::Linux))
.add_step(steps::cache_rust_dependencies_namespace())
.map(steps::install_linux_dependencies)
.add_step(steps::cargo_install_nextest())
.add_step(steps::clear_target_dir_if_large(Platform::Linux))
.add_step(steps::setup_sccache(Platform::Linux))
.add_step(script_step)
.add_step(steps::show_sccache_stats(Platform::Linux))
.add_step(steps::cleanup_cargo_config(Platform::Linux))
}
fn unit_evals(commit: Option<&WorkflowInput>) -> Job {
let script_step = add_api_keys(steps::script("./script/run-unit-evals"));
Job::default()
.runs_on(runners::LINUX_DEFAULT)
.add_step(steps::checkout_repo())
.add_step(steps::setup_cargo_config(Platform::Linux))
.add_step(steps::cache_rust_dependencies_namespace())
.map(steps::install_linux_dependencies)
.add_step(steps::cargo_install_nextest())
.add_step(steps::clear_target_dir_if_large(Platform::Linux))
.add_step(steps::setup_sccache(Platform::Linux))
.add_step(match commit {
Some(commit) => script_step.add_env(("UNIT_EVAL_COMMIT", commit)),
None => script_step,
})
.add_step(steps::show_sccache_stats(Platform::Linux))
.add_step(steps::cleanup_cargo_config(Platform::Linux))
}
@@ -0,0 +1,226 @@
use std::path::Path;
use crate::tasks::workflows::{
nix_build::build_nix,
release::ReleaseBundleJobs,
runners::{Arch, Platform, ReleaseChannel},
steps::{DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder, NamedJob, dependant_job, named},
vars::{assets, bundle_envs},
};
use super::{runners, steps};
use gh_workflow::*;
use indoc::indoc;
pub fn run_bundling() -> Workflow {
let bundle = ReleaseBundleJobs {
linux_aarch64: bundle_linux(Arch::AARCH64, None, &[]),
linux_x86_64: bundle_linux(Arch::X86_64, None, &[]),
mac_aarch64: bundle_mac(Arch::AARCH64, None, &[]),
mac_x86_64: bundle_mac(Arch::X86_64, None, &[]),
windows_aarch64: bundle_windows(Arch::AARCH64, None, &[]),
windows_x86_64: bundle_windows(Arch::X86_64, None, &[]),
};
let nix_linux_x86_64 = nix_job(Platform::Linux, Arch::X86_64);
let nix_mac_aarch64 = nix_job(Platform::Mac, Arch::AARCH64);
named::workflow()
.on(Event::default().pull_request(
PullRequest::default().types([PullRequestType::Labeled, PullRequestType::Synchronize]),
))
.concurrency(
Concurrency::new(Expression::new(
"${{ github.workflow }}-${{ github.head_ref || github.ref }}",
))
.cancel_in_progress(true),
)
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("RUST_BACKTRACE", "1"))
.map(|mut workflow| {
for job in bundle.into_jobs() {
workflow = workflow.add_job(job.name, job.job);
}
workflow
})
.add_job(nix_linux_x86_64.name, nix_linux_x86_64.job)
.add_job(nix_mac_aarch64.name, nix_mac_aarch64.job)
}
fn nix_job(platform: Platform, arch: Arch) -> NamedJob {
let mut job = build_nix(
platform,
arch,
"default",
// don't push PR builds to the cache
Some("-zed-editor-[0-9.]*"),
&[],
);
job.job = job.job.cond(Expression::new(format!(
"{} && ((github.event.action == 'labeled' && github.event.label.name == 'run-bundling') || \
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling')))",
DEFAULT_REPOSITORY_OWNER_GUARD
)));
job
}
fn bundle_job(deps: &[&NamedJob]) -> Job {
dependant_job(deps)
.when(deps.len() == 0, |job|
job.cond(Expression::new(
indoc! {
r#"(github.event.action == 'labeled' && github.event.label.name == 'run-bundling') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'run-bundling'))"#,
})))
.timeout_minutes(60u32)
}
pub(crate) fn bundle_mac(
arch: Arch,
release_channel: Option<ReleaseChannel>,
deps: &[&NamedJob],
) -> NamedJob {
pub fn bundle_mac(arch: Arch) -> Step<Run> {
named::bash(&format!("./script/bundle-mac {arch}-apple-darwin"))
}
let platform = Platform::Mac;
let artifact_name = match arch {
Arch::X86_64 => assets::MAC_X86_64,
Arch::AARCH64 => assets::MAC_AARCH64,
};
let remote_server_artifact_name = match arch {
Arch::X86_64 => assets::REMOTE_SERVER_MAC_X86_64,
Arch::AARCH64 => assets::REMOTE_SERVER_MAC_AARCH64,
};
NamedJob {
name: format!("bundle_mac_{arch}"),
job: bundle_job(deps)
.runs_on(runners::MAC_DEFAULT)
.envs(bundle_envs(platform))
.add_step(steps::checkout_repo())
.when_some(release_channel, |job, release_channel| {
job.add_step(set_release_channel(platform, release_channel))
})
.add_step(steps::setup_node())
.add_step(steps::setup_sentry())
.add_step(steps::clear_target_dir_if_large(runners::Platform::Mac))
.add_step(bundle_mac(arch))
.add_step(upload_artifact(&format!(
"target/{arch}-apple-darwin/release/{artifact_name}"
)))
.add_step(upload_artifact(&format!(
"target/{remote_server_artifact_name}"
))),
}
}
pub fn upload_artifact(path: &str) -> Step<Use> {
let name = Path::new(path).file_name().unwrap().to_str().unwrap();
Step::new(format!("@actions/upload-artifact {}", name))
.uses(
"actions",
"upload-artifact",
"330a01c490aca151604b8cf639adc76d48f6c5d4", // v5
)
// N.B. "name" is the name for the asset. The uploaded
// file retains its filename.
.add_with(("name", name))
.add_with(("path", path))
.add_with(("if-no-files-found", "error"))
}
pub(crate) fn bundle_linux(
arch: Arch,
release_channel: Option<ReleaseChannel>,
deps: &[&NamedJob],
) -> NamedJob {
let platform = Platform::Linux;
let artifact_name = match arch {
Arch::X86_64 => assets::LINUX_X86_64,
Arch::AARCH64 => assets::LINUX_AARCH64,
};
let remote_server_artifact_name = match arch {
Arch::X86_64 => assets::REMOTE_SERVER_LINUX_X86_64,
Arch::AARCH64 => assets::REMOTE_SERVER_LINUX_AARCH64,
};
NamedJob {
name: format!("bundle_linux_{arch}"),
job: bundle_job(deps)
.runs_on(arch.linux_bundler())
.envs(bundle_envs(platform))
.add_env(Env::new("CC", "clang-18"))
.add_env(Env::new("CXX", "clang++-18"))
.add_step(steps::checkout_repo())
.when_some(release_channel, |job, release_channel| {
job.add_step(set_release_channel(platform, release_channel))
})
.add_step(steps::setup_sentry())
.map(steps::install_linux_dependencies)
.add_step(steps::script("./script/bundle-linux"))
.add_step(upload_artifact(&format!("target/release/{artifact_name}")))
.add_step(upload_artifact(&format!(
"target/{remote_server_artifact_name}"
))),
}
}
pub(crate) fn bundle_windows(
arch: Arch,
release_channel: Option<ReleaseChannel>,
deps: &[&NamedJob],
) -> NamedJob {
let platform = Platform::Windows;
pub fn bundle_windows(arch: Arch) -> Step<Run> {
let step = match arch {
Arch::X86_64 => named::pwsh("script/bundle-windows.ps1 -Architecture x86_64"),
Arch::AARCH64 => named::pwsh("script/bundle-windows.ps1 -Architecture aarch64"),
};
step.working_directory("${{ env.ZED_WORKSPACE }}")
}
let artifact_name = match arch {
Arch::X86_64 => assets::WINDOWS_X86_64,
Arch::AARCH64 => assets::WINDOWS_AARCH64,
};
let remote_server_artifact_name = match arch {
Arch::X86_64 => assets::REMOTE_SERVER_WINDOWS_X86_64,
Arch::AARCH64 => assets::REMOTE_SERVER_WINDOWS_AARCH64,
};
NamedJob {
name: format!("bundle_windows_{arch}"),
job: bundle_job(deps)
.runs_on(runners::WINDOWS_DEFAULT)
.envs(bundle_envs(platform))
.add_step(steps::checkout_repo())
.when_some(release_channel, |job, release_channel| {
job.add_step(set_release_channel(platform, release_channel))
})
.add_step(steps::setup_sentry())
.add_step(bundle_windows(arch))
.add_step(upload_artifact(&format!("target/{artifact_name}")))
.add_step(upload_artifact(&format!(
"target/{remote_server_artifact_name}"
))),
}
}
fn set_release_channel(platform: Platform, release_channel: ReleaseChannel) -> Step<Run> {
match release_channel {
ReleaseChannel::Nightly => set_release_channel_to_nightly(platform),
}
}
fn set_release_channel_to_nightly(platform: Platform) -> Step<Run> {
match platform {
Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
set -eu
version=$(git rev-parse --short HEAD)
echo "Publishing version: ${version} on release channel nightly"
echo "nightly" > crates/zed/RELEASE_CHANNEL
"#}),
Platform::Windows => named::pwsh(indoc::indoc! {r#"
$ErrorActionPreference = "Stop"
$version = git rev-parse --short HEAD
Write-Host "Publishing version: $version on release channel nightly"
"nightly" | Set-Content -Path "crates/zed/RELEASE_CHANNEL"
"#})
.working_directory("${{ env.ZED_WORKSPACE }}"),
}
}
@@ -0,0 +1,788 @@
use gh_workflow::{
Concurrency, Container, Event, Expression, Input, Job, Level, Permissions, Port, PullRequest,
Push, Run, Step, Strategy, Use, UsesJob, Workflow,
};
use indexmap::IndexMap;
use indoc::formatdoc;
use serde_json::json;
use crate::tasks::workflows::{
steps::{
CommonJobConditions, cache_rust_dependencies_namespace, repository_owner_guard_expression,
use_clang,
},
vars::{self, PathCondition},
};
use super::{
runners::{self, Arch, Platform},
steps::{self, FluentBuilder, NamedJob, named, release_job},
};
pub(crate) fn run_tests() -> Workflow {
// Specify anything which should potentially skip full test suite in this regex:
// - docs/
// - script/update_top_ranking_issues/
// - .github/ISSUE_TEMPLATE/
// - .github/workflows/ (except .github/workflows/ci.yml)
// - extensions/ (these have their own test workflow)
let should_run_tests = PathCondition::inverted(
"run_tests",
r"^(docs/|script/update_top_ranking_issues/|\.github/(ISSUE_TEMPLATE|workflows/(?!run_tests))|extensions/)",
);
let should_check_docs = PathCondition::new("run_docs", r"^(docs/|crates/.*\.rs)");
let should_check_scripts = PathCondition::new(
"run_action_checks",
r"^\.github/(workflows/|actions/|actionlint.yml)|tooling/xtask|script/",
);
let should_check_licences =
PathCondition::new("run_licenses", r"^(Cargo.lock|script/.*licenses)");
let orchestrate = orchestrate(&[
&should_check_scripts,
&should_check_docs,
&should_check_licences,
&should_run_tests,
]);
let mut jobs = vec![
orchestrate,
check_style(),
should_run_tests.guard(clippy(Platform::Windows, None)),
should_run_tests.guard(clippy(Platform::Linux, None)),
should_run_tests.guard(clippy(Platform::Mac, None)),
should_run_tests.guard(clippy(Platform::Mac, Some(Arch::X86_64))),
should_run_tests.guard(run_platform_tests(Platform::Windows)),
should_run_tests.guard(run_platform_tests(Platform::Linux)),
should_run_tests.guard(run_platform_tests(Platform::Mac)),
should_run_tests.guard(doctests()),
should_run_tests.guard(check_workspace_binaries()),
should_run_tests.guard(check_wasm()),
should_run_tests.guard(check_dependencies()), // could be more specific here?
should_check_docs.guard(check_docs()),
should_check_licences.guard(check_licenses()),
should_check_scripts.guard(check_scripts()),
];
let ext_tests = extension_tests();
let tests_pass = tests_pass(&jobs, &[&ext_tests.name]);
jobs.push(should_run_tests.guard(check_postgres_and_protobuf_migrations())); // could be more specific here?
named::workflow()
.add_event(
Event::default()
.push(
Push::default()
.add_branch("main")
.add_branch("v[0-9]+.[0-9]+.x"),
)
.pull_request(PullRequest::default().add_branch("**")),
)
.concurrency(
Concurrency::default()
.group(concat!(
"${{ github.workflow }}-${{ github.ref_name }}-",
"${{ github.ref_name == 'main' && github.sha || 'anysha' }}"
))
.cancel_in_progress(true),
)
.add_env(("CARGO_TERM_COLOR", "always"))
.add_env(("RUST_BACKTRACE", 1))
.add_env(("CARGO_INCREMENTAL", 0))
.map(|mut workflow| {
for job in jobs {
workflow = workflow.add_job(job.name, job.job)
}
workflow
})
.add_job(ext_tests.name, ext_tests.job)
.add_job(tests_pass.name, tests_pass.job)
}
/// Controls which features `orchestrate_impl` includes in the generated script.
#[derive(PartialEq, Eq)]
enum OrchestrateTarget {
/// For the main Zed repo: includes the cargo package filter and extension
/// change detection, but no working-directory scoping.
ZedRepo,
/// For individual extension repos: scopes changed-file detection to the
/// working directory, with no package filter or extension detection.
Extension,
}
// Generates a bash script that checks changed files against regex patterns
// and sets GitHub output variables accordingly
pub fn orchestrate(rules: &[&PathCondition]) -> NamedJob {
orchestrate_impl(rules, OrchestrateTarget::ZedRepo)
}
pub fn orchestrate_for_extension(rules: &[&PathCondition]) -> NamedJob {
orchestrate_impl(rules, OrchestrateTarget::Extension)
}
fn orchestrate_impl(rules: &[&PathCondition], target: OrchestrateTarget) -> NamedJob {
let name = "orchestrate".to_owned();
let step_name = "filter".to_owned();
let mut script = String::new();
script.push_str(indoc::indoc! {r#"
set -euo pipefail
if [ -z "$GITHUB_BASE_REF" ]; then
echo "Not in a PR context (i.e., push to main/stable/preview)"
COMPARE_REV="$(git rev-parse HEAD~1)"
else
echo "In a PR context comparing to pull_request.base.ref"
git fetch origin "$GITHUB_BASE_REF" --depth=350
COMPARE_REV="$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD)"
fi
CHANGED_FILES="$(git diff --name-only "$COMPARE_REV" "$GITHUB_SHA")"
"#});
if target == OrchestrateTarget::Extension {
script.push_str(indoc::indoc! {r#"
# When running from a subdirectory, git diff returns repo-root-relative paths.
# Filter to only files within the current working directory and strip the prefix.
REPO_SUBDIR="$(git rev-parse --show-prefix)"
REPO_SUBDIR="${REPO_SUBDIR%/}"
if [ -n "$REPO_SUBDIR" ]; then
CHANGED_FILES="$(echo "$CHANGED_FILES" | grep "^${REPO_SUBDIR}/" | sed "s|^${REPO_SUBDIR}/||" || true)"
fi
"#});
}
script.push_str(indoc::indoc! {r#"
check_pattern() {
local output_name="$1"
local pattern="$2"
local grep_arg="$3"
echo "$CHANGED_FILES" | grep "$grep_arg" "$pattern" && \
echo "${output_name}=true" >> "$GITHUB_OUTPUT" || \
echo "${output_name}=false" >> "$GITHUB_OUTPUT"
}
"#});
let mut outputs = IndexMap::new();
if target == OrchestrateTarget::ZedRepo {
script.push_str(indoc::indoc! {r#"
# Check for changes that require full rebuild (no filter)
# Direct pushes to main/stable/preview always run full suite
if [ -z "$GITHUB_BASE_REF" ]; then
echo "Not a PR, running full test suite"
echo "changed_packages=" >> "$GITHUB_OUTPUT"
elif echo "$CHANGED_FILES" | grep -qP '^(rust-toolchain\.toml|\.cargo/|\.github/|Cargo\.(toml|lock)$)'; then
echo "Toolchain, cargo config, or root Cargo files changed, will run all tests"
echo "changed_packages=" >> "$GITHUB_OUTPUT"
else
# Extract changed directories from file paths
CHANGED_DIRS=$(echo "$CHANGED_FILES" | \
grep -oP '^(crates|tooling)/\K[^/]+' | \
sort -u || true)
# Build directory-to-package mapping using cargo metadata
DIR_TO_PKG=$(cargo metadata --format-version=1 --no-deps 2>/dev/null | \
jq -r '.packages[] | select(.manifest_path | test("crates/|tooling/")) | "\(.manifest_path | capture("(crates|tooling)/(?<dir>[^/]+)") | .dir)=\(.name)"')
# Map directory names to package names
FILE_CHANGED_PKGS=""
for dir in $CHANGED_DIRS; do
pkg=$(echo "$DIR_TO_PKG" | grep "^${dir}=" | cut -d= -f2 | head -1)
if [ -n "$pkg" ]; then
FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$pkg")
else
# Fall back to directory name if no mapping found
FILE_CHANGED_PKGS=$(printf '%s\n%s' "$FILE_CHANGED_PKGS" "$dir")
fi
done
FILE_CHANGED_PKGS=$(echo "$FILE_CHANGED_PKGS" | grep -v '^$' | sort -u || true)
# If assets/ changed, add crates that depend on those assets
if echo "$CHANGED_FILES" | grep -qP '^assets/'; then
FILE_CHANGED_PKGS=$(printf '%s\n%s\n%s\n%s' "$FILE_CHANGED_PKGS" "settings" "storybook" "assets" | sort -u)
fi
# Combine all changed packages
ALL_CHANGED_PKGS=$(echo "$FILE_CHANGED_PKGS" | grep -v '^$' || true)
if [ -z "$ALL_CHANGED_PKGS" ]; then
echo "No package changes detected, will run all tests"
echo "changed_packages=" >> "$GITHUB_OUTPUT"
else
# Build nextest filterset with rdeps for each package
FILTERSET=$(echo "$ALL_CHANGED_PKGS" | \
sed 's/.*/rdeps(&)/' | \
tr '\n' '|' | \
sed 's/|$//')
echo "Changed packages filterset: $FILTERSET"
echo "changed_packages=$FILTERSET" >> "$GITHUB_OUTPUT"
fi
fi
"#});
outputs.insert(
"changed_packages".to_owned(),
format!("${{{{ steps.{}.outputs.changed_packages }}}}", step_name),
);
}
for rule in rules {
assert!(
rule.set_by_step
.borrow_mut()
.replace(name.clone())
.is_none()
);
assert!(
outputs
.insert(
rule.name.to_owned(),
format!("${{{{ steps.{}.outputs.{} }}}}", step_name, rule.name)
)
.is_none()
);
let grep_arg = if rule.invert { "-qvP" } else { "-qP" };
script.push_str(&format!(
"check_pattern \"{}\" '{}' {}\n",
rule.name, rule.pattern, grep_arg
));
}
if target == OrchestrateTarget::ZedRepo {
script.push_str(DETECT_CHANGED_EXTENSIONS_SCRIPT);
script.push_str("echo \"changed_extensions=$EXTENSIONS_JSON\" >> \"$GITHUB_OUTPUT\"\n");
outputs.insert(
"changed_extensions".to_owned(),
format!("${{{{ steps.{}.outputs.changed_extensions }}}}", step_name),
);
}
let job = Job::default()
.runs_on(runners::LINUX_SMALL)
.with_repository_owner_guard()
.outputs(outputs)
.add_step(steps::checkout_repo().with_deep_history_on_non_main())
.add_step(Step::new(step_name.clone()).run(script).id(step_name));
NamedJob { name, job }
}
pub fn tests_pass(jobs: &[NamedJob], extra_job_names: &[&str]) -> NamedJob {
let mut script = String::from(indoc::indoc! {r#"
set +x
EXIT_CODE=0
check_result() {
echo "* $1: $2"
if [[ "$2" != "skipped" && "$2" != "success" ]]; then EXIT_CODE=1; fi
}
"#});
let all_names: Vec<&str> = jobs
.iter()
.map(|job| job.name.as_str())
.chain(extra_job_names.iter().copied())
.collect();
let env_entries: Vec<_> = all_names
.iter()
.map(|name| {
let env_name = format!("RESULT_{}", name.to_uppercase());
let env_value = format!("${{{{ needs.{}.result }}}}", name);
(env_name, env_value)
})
.collect();
script.push_str(
&all_names
.iter()
.zip(env_entries.iter())
.map(|(name, (env_name, _))| format!("check_result \"{}\" \"${}\"", name, env_name))
.collect::<Vec<_>>()
.join("\n"),
);
script.push_str("\n\nexit $EXIT_CODE\n");
let job = Job::default()
.runs_on(runners::LINUX_SMALL)
.needs(
all_names
.iter()
.map(|name| name.to_string())
.collect::<Vec<String>>(),
)
.cond(repository_owner_guard_expression(true))
.add_step(
env_entries
.into_iter()
.fold(named::bash(&script), |step, env_item| {
step.add_env(env_item)
}),
);
named::job(job)
}
/// Bash script snippet that detects changed extension directories from `$CHANGED_FILES`.
/// Assumes `$CHANGED_FILES` is already set. Sets `$EXTENSIONS_JSON` to a JSON array of
/// changed extension paths. Callers are responsible for writing the result to `$GITHUB_OUTPUT`.
pub(crate) const DETECT_CHANGED_EXTENSIONS_SCRIPT: &str = indoc::indoc! {r#"
# Detect changed extension directories (excluding extensions/workflows)
CHANGED_EXTENSIONS=$(echo "$CHANGED_FILES" | grep -oP '^extensions/[^/]+(?=/)' | sort -u | grep -v '^extensions/workflows$' || true)
if [ -n "$CHANGED_EXTENSIONS" ]; then
EXTENSIONS_JSON=$(echo "$CHANGED_EXTENSIONS" | jq -R -s -c 'split("\n") | map(select(length > 0))')
else
EXTENSIONS_JSON="[]"
fi
"#};
const TS_QUERY_LS_FILE: &str = "ts_query_ls-x86_64-unknown-linux-gnu.tar.gz";
const CI_TS_QUERY_RELEASE: &str = "tags/v3.15.1";
pub(crate) fn fetch_ts_query_ls() -> Step<Use> {
named::uses(
"dsaltares",
"fetch-gh-release-asset",
"aa37ae5c44d3c9820bc12fe675e8670ecd93bd1c",
) // v1.1.1
.add_with(("repo", "ribru17/ts_query_ls"))
.add_with(("version", CI_TS_QUERY_RELEASE))
.add_with(("file", TS_QUERY_LS_FILE))
}
pub(crate) fn run_ts_query_ls() -> Step<Run> {
named::bash(formatdoc!(
r#"tar -xf "$GITHUB_WORKSPACE/{TS_QUERY_LS_FILE}" -C "$GITHUB_WORKSPACE"
"$GITHUB_WORKSPACE/ts_query_ls" format --check . || {{
echo "Found unformatted queries, please format them with ts_query_ls."
echo "For easy use, install the Tree-sitter query extension:"
echo "zed://extension/tree-sitter-query"
false
}}"#
))
}
fn check_style() -> NamedJob {
fn check_for_typos() -> Step<Use> {
named::uses(
"crate-ci",
"typos",
"2d0ce569feab1f8752f1dde43cc2f2aa53236e06",
) // v1.40.0
.with(("config", "./typos.toml"))
}
named::job(
release_job(&[])
.runs_on(runners::LINUX_MEDIUM)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(steps::setup_pnpm())
.add_step(steps::prettier())
.add_step(steps::cargo_fmt())
.add_step(steps::script("./script/check-todos"))
.add_step(steps::script("./script/check-keymaps"))
.add_step(check_for_typos())
.add_step(fetch_ts_query_ls())
.add_step(run_ts_query_ls()),
)
}
fn check_dependencies() -> NamedJob {
fn install_cargo_machete() -> Step<Use> {
named::uses(
"clechasseur",
"rs-cargo",
"8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386", // v2
)
.add_with(("command", "install"))
.add_with(("args", "cargo-machete@0.7.0"))
}
fn run_cargo_machete() -> Step<Use> {
named::uses(
"clechasseur",
"rs-cargo",
"8435b10f6e71c2e3d4d3b7573003a8ce4bfc6386", // v2
)
.add_with(("command", "machete"))
}
fn check_cargo_lock() -> Step<Run> {
named::bash("cargo update --locked --workspace")
}
fn check_vulnerable_dependencies() -> Step<Use> {
named::uses(
"actions",
"dependency-review-action",
"67d4f4bd7a9b17a0db54d2a7519187c65e339de8", // v4
)
.if_condition(Expression::new("github.event_name == 'pull_request'"))
.with(("license-check", false))
}
named::job(use_clang(
release_job(&[])
.runs_on(runners::LINUX_SMALL)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(install_cargo_machete())
.add_step(run_cargo_machete())
.add_step(check_cargo_lock())
.add_step(check_vulnerable_dependencies()),
))
}
fn check_wasm() -> NamedJob {
fn install_nightly_wasm_toolchain() -> Step<Run> {
named::bash(
"rustup toolchain install nightly --component rust-src --target wasm32-unknown-unknown",
)
}
fn cargo_check_wasm() -> Step<Run> {
named::bash(concat!(
"cargo +nightly -Zbuild-std=std,panic_abort ",
"check --target wasm32-unknown-unknown -p gpui_platform",
))
.add_env((
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
"-C target-feature=+atomics,+bulk-memory,+mutable-globals",
))
}
named::job(
release_job(&[])
.runs_on(runners::LINUX_LARGE)
.add_step(steps::checkout_repo())
.add_step(steps::setup_cargo_config(Platform::Linux))
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(install_nightly_wasm_toolchain())
.add_step(steps::setup_sccache(Platform::Linux))
.add_step(cargo_check_wasm())
.add_step(steps::show_sccache_stats(Platform::Linux))
.add_step(steps::cleanup_cargo_config(Platform::Linux)),
)
}
fn check_workspace_binaries() -> NamedJob {
named::job(use_clang(
release_job(&[])
.runs_on(runners::LINUX_LARGE)
.add_step(steps::checkout_repo())
.add_step(steps::setup_cargo_config(Platform::Linux))
.add_step(steps::cache_rust_dependencies_namespace())
.map(steps::install_linux_dependencies)
.add_step(steps::setup_sccache(Platform::Linux))
.add_step(steps::script("cargo build -p collab"))
.add_step(steps::script("cargo build --workspace --bins --examples"))
.add_step(steps::show_sccache_stats(Platform::Linux))
.add_step(steps::cleanup_cargo_config(Platform::Linux)),
))
}
pub(crate) fn clippy(platform: Platform, arch: Option<Arch>) -> NamedJob {
let target = arch.map(|arch| match (platform, arch) {
(Platform::Mac, Arch::X86_64) => "x86_64-apple-darwin",
(Platform::Mac, Arch::AARCH64) => "aarch64-apple-darwin",
_ => unimplemented!("cross-arch clippy not supported for {platform}/{arch}"),
});
let runner = match platform {
Platform::Windows => runners::WINDOWS_DEFAULT,
Platform::Linux => runners::LINUX_DEFAULT,
Platform::Mac => runners::MAC_DEFAULT,
};
let mut job = release_job(&[])
.runs_on(runner)
.add_step(steps::checkout_repo())
.add_step(steps::setup_cargo_config(platform))
.when(
platform == Platform::Linux || platform == Platform::Mac,
|this| this.add_step(steps::cache_rust_dependencies_namespace()),
)
.when(
platform == Platform::Linux,
steps::install_linux_dependencies,
)
.when_some(target, |this, target| {
this.add_step(steps::install_rustup_target(target))
})
.add_step(steps::setup_sccache(platform))
.add_step(steps::clippy(platform, target))
.add_step(steps::show_sccache_stats(platform));
if platform == Platform::Linux {
job = use_clang(job);
}
let name = match arch {
Some(arch) => format!("clippy_{platform}_{arch}"),
None => format!("clippy_{platform}"),
};
NamedJob { name, job }
}
pub(crate) fn run_platform_tests(platform: Platform) -> NamedJob {
run_platform_tests_impl(platform, true)
}
pub(crate) fn run_platform_tests_no_filter(platform: Platform) -> NamedJob {
run_platform_tests_impl(platform, false)
}
fn run_platform_tests_impl(platform: Platform, filter_packages: bool) -> NamedJob {
let runner = match platform {
Platform::Windows => runners::WINDOWS_DEFAULT,
Platform::Linux => runners::LINUX_DEFAULT,
Platform::Mac => runners::MAC_DEFAULT,
};
NamedJob {
name: format!("run_tests_{platform}"),
job: release_job(&[])
.runs_on(runner)
.when(platform == Platform::Linux, |job| {
job.add_service(
"postgres",
Container::new("postgres:15")
.add_env(("POSTGRES_HOST_AUTH_METHOD", "trust"))
.ports(vec![Port::Name("5432:5432".into())])
.options(
"--health-cmd pg_isready \
--health-interval 500ms \
--health-timeout 5s \
--health-retries 10",
),
)
})
.add_step(steps::checkout_repo())
.add_step(steps::setup_cargo_config(platform))
.when(platform == Platform::Mac, |this| {
this.add_step(steps::cache_rust_dependencies_namespace())
})
.when(platform == Platform::Linux, |this| {
use_clang(this.add_step(steps::cache_rust_dependencies_namespace()))
})
.when(
platform == Platform::Linux,
steps::install_linux_dependencies,
)
.add_step(steps::setup_node())
.when(
platform == Platform::Linux || platform == Platform::Mac,
|job| job.add_step(steps::cargo_install_nextest()),
)
.add_step(steps::clear_target_dir_if_large(platform))
.add_step(steps::setup_sccache(platform))
.when(filter_packages, |job| {
job.add_step(
steps::cargo_nextest(platform).with_changed_packages_filter("orchestrate"),
)
})
.when(!filter_packages, |job| {
job.add_step(steps::cargo_nextest(platform))
})
.add_step(steps::show_sccache_stats(platform))
.add_step(steps::cleanup_cargo_config(platform)),
}
}
pub(crate) fn check_postgres_and_protobuf_migrations() -> NamedJob {
fn ensure_fresh_merge() -> Step<Run> {
named::bash(indoc::indoc! {r#"
if [ -z "$GITHUB_BASE_REF" ];
then
echo "BUF_BASE_BRANCH=$(git merge-base origin/main HEAD)" >> "$GITHUB_ENV"
else
git checkout -B temp
git merge -q "origin/$GITHUB_BASE_REF" -m "merge main into temp"
echo "BUF_BASE_BRANCH=$GITHUB_BASE_REF" >> "$GITHUB_ENV"
fi
"#})
}
fn bufbuild_setup_action() -> Step<Use> {
named::uses("bufbuild", "buf-setup-action", "v1")
.add_with(("version", "v1.29.0"))
.add_with(("github_token", vars::GITHUB_TOKEN))
}
fn bufbuild_breaking_action() -> Step<Use> {
named::uses("bufbuild", "buf-breaking-action", "v1").add_with(("input", "crates/proto/proto/"))
.add_with(("against", "https://github.com/${GITHUB_REPOSITORY}.git#branch=${BUF_BASE_BRANCH},subdir=crates/proto/proto/"))
}
fn buf_lint() -> Step<Run> {
named::bash("buf lint crates/proto/proto")
}
fn check_protobuf_formatting() -> Step<Run> {
named::bash("buf format --diff --exit-code crates/proto/proto")
}
named::job(
release_job(&[])
.runs_on(runners::LINUX_DEFAULT)
.add_env(("GIT_AUTHOR_NAME", "Protobuf Action"))
.add_env(("GIT_AUTHOR_EMAIL", "ci@zed.dev"))
.add_env(("GIT_COMMITTER_NAME", "Protobuf Action"))
.add_env(("GIT_COMMITTER_EMAIL", "ci@zed.dev"))
.add_step(steps::checkout_repo().with_full_history())
.add_step(ensure_fresh_merge())
.add_step(bufbuild_setup_action())
.add_step(bufbuild_breaking_action())
.add_step(buf_lint())
.add_step(check_protobuf_formatting()),
)
}
fn doctests() -> NamedJob {
fn run_doctests() -> Step<Run> {
named::bash(indoc::indoc! {r#"
cargo test --workspace --doc --no-fail-fast
"#})
.id("run_doctests")
}
named::job(use_clang(
release_job(&[])
.runs_on(runners::LINUX_DEFAULT)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.map(steps::install_linux_dependencies)
.add_step(steps::setup_cargo_config(Platform::Linux))
.add_step(steps::setup_sccache(Platform::Linux))
.add_step(run_doctests())
.add_step(steps::show_sccache_stats(Platform::Linux))
.add_step(steps::cleanup_cargo_config(Platform::Linux)),
))
}
fn check_licenses() -> NamedJob {
named::job(
Job::default()
.runs_on(runners::LINUX_SMALL)
.add_step(steps::checkout_repo())
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(steps::script("./script/check-licenses"))
.add_step(steps::script("./script/generate-licenses")),
)
}
fn check_docs() -> NamedJob {
fn lychee_link_check(dir: &str) -> Step<Use> {
named::uses(
"lycheeverse",
"lychee-action",
"82202e5e9c2f4ef1a55a3d02563e1cb6041e5332",
) // v2.4.1
.add_with(("args", format!("--no-progress --exclude '^http' '{dir}'")))
.add_with(("fail", true))
.add_with(("jobSummary", false))
}
fn install_mdbook() -> Step<Use> {
named::uses(
"peaceiris",
"actions-mdbook",
"ee69d230fe19748b7abf22df32acaa93833fad08", // v2
)
.with(("mdbook-version", "0.4.37"))
}
fn build_docs() -> Step<Run> {
named::bash(indoc::indoc! {r#"
mkdir -p target/deploy
mdbook build ./docs --dest-dir=../target/deploy/docs/
"#})
}
named::job(use_clang(
release_job(&[])
.runs_on(runners::LINUX_LARGE)
.add_step(steps::checkout_repo())
.add_step(steps::setup_cargo_config(Platform::Linux))
// todo(ci): un-inline build_docs/action.yml here
.add_step(steps::cache_rust_dependencies_namespace())
.add_step(
lychee_link_check("./docs/src/**/*"), // check markdown links
)
.map(steps::install_linux_dependencies)
.add_step(steps::script("./script/generate-action-metadata"))
.add_step(install_mdbook())
.add_step(build_docs())
.add_step(
lychee_link_check("target/deploy/docs"), // check links in generated html
),
))
}
pub(crate) fn check_scripts() -> NamedJob {
fn download_actionlint() -> Step<Run> {
named::bash(
"bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)",
)
}
fn run_actionlint() -> Step<Run> {
named::bash(r#""$ACTIONLINT_BIN" -color"#).add_env((
"ACTIONLINT_BIN",
"${{ steps.get_actionlint.outputs.executable }}",
))
}
fn run_shellcheck() -> Step<Run> {
named::bash("./script/shellcheck-scripts error")
}
fn check_xtask_workflows() -> Step<Run> {
named::bash(indoc::indoc! {r#"
cargo xtask workflows
if ! git diff --exit-code .github; then
echo "Error: .github directory has uncommitted changes after running 'cargo xtask workflows'"
echo "Please run 'cargo xtask workflows' locally and commit the changes"
exit 1
fi
"#})
}
named::job(
release_job(&[])
.runs_on(runners::LINUX_SMALL)
.add_step(steps::checkout_repo())
.add_step(run_shellcheck())
.add_step(download_actionlint().id("get_actionlint"))
.add_step(run_actionlint())
.add_step(cache_rust_dependencies_namespace())
.add_step(check_xtask_workflows()),
)
}
fn extension_tests() -> NamedJob<UsesJob> {
let job = Job::default()
.needs(vec!["orchestrate".to_owned()])
.cond(Expression::new(
"needs.orchestrate.outputs.changed_extensions != '[]'",
))
.permissions(Permissions::default().contents(Level::Read))
.strategy(
Strategy::default()
.fail_fast(false)
// TODO: Remove the limit. We currently need this to workaround the concurrency group issue
// where different matrix jobs would be placed in the same concurrency group and thus cancelled.
.max_parallel(1u32)
.matrix(json!({
"extension": "${{ fromJson(needs.orchestrate.outputs.changed_extensions) }}"
})),
)
.uses_local(".github/workflows/extension_tests.yml")
.with(Input::default().add("working-directory", "${{ matrix.extension }}"));
named::job(job)
}
@@ -0,0 +1,69 @@
pub const LINUX_SMALL: Runner = Runner("namespace-profile-2x4-ubuntu-2404");
pub const LINUX_DEFAULT: Runner = LINUX_XL;
pub const LINUX_XL: Runner = Runner("namespace-profile-16x32-ubuntu-2204");
pub const LINUX_LARGE: Runner = Runner("namespace-profile-8x16-ubuntu-2204");
pub const LINUX_MEDIUM: Runner = Runner("namespace-profile-4x8-ubuntu-2204");
// Using Ubuntu 20.04 for minimal glibc version
pub const LINUX_X86_BUNDLER: Runner = Runner("namespace-profile-32x64-ubuntu-2004");
pub const LINUX_ARM_BUNDLER: Runner = Runner("namespace-profile-8x32-ubuntu-2004-arm-m4");
// Larger Ubuntu runner with glibc 2.39 for extension bundling
pub const LINUX_LARGE_RAM: Runner = Runner("namespace-profile-8x32-ubuntu-2404");
pub const MAC_DEFAULT: Runner = Runner("namespace-profile-mac-large");
pub const WINDOWS_DEFAULT: Runner = Runner("self-32vcpu-windows-2022");
pub struct Runner(&'static str);
impl Into<gh_workflow::RunsOn> for Runner {
fn into(self) -> gh_workflow::RunsOn {
self.0.into()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Arch {
X86_64,
AARCH64,
}
impl std::fmt::Display for Arch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Arch::X86_64 => write!(f, "x86_64"),
Arch::AARCH64 => write!(f, "aarch64"),
}
}
}
impl Arch {
pub fn linux_bundler(&self) -> Runner {
match self {
Arch::X86_64 => LINUX_X86_BUNDLER,
Arch::AARCH64 => LINUX_ARM_BUNDLER,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Platform {
Windows,
Linux,
Mac,
}
impl std::fmt::Display for Platform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Platform::Windows => write!(f, "windows"),
Platform::Linux => write!(f, "linux"),
Platform::Mac => write!(f, "mac"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReleaseChannel {
Nightly,
}
+505
View File
@@ -0,0 +1,505 @@
use gh_workflow::*;
use serde_json::Value;
use crate::tasks::workflows::{runners::Platform, vars, vars::StepOutput};
pub(crate) fn use_clang(job: Job) -> Job {
job.add_env(Env::new("CC", "clang"))
.add_env(Env::new("CXX", "clang++"))
}
const SCCACHE_R2_BUCKET: &str = "sccache-zed";
pub(crate) const BASH_SHELL: &str = "bash -euxo pipefail {0}";
// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell
pub const PWSH_SHELL: &str = "pwsh";
pub(crate) struct Nextest(Step<Run>);
pub(crate) fn cargo_nextest(platform: Platform) -> Nextest {
Nextest(named::run(
platform,
"cargo nextest run --workspace --no-fail-fast --no-tests=warn",
))
}
impl Nextest {
#[allow(dead_code)]
pub(crate) fn with_filter_expr(mut self, filter_expr: &str) -> Self {
if let Some(nextest_command) = self.0.value.run.as_mut() {
nextest_command.push_str(&format!(r#" -E "{filter_expr}""#));
}
self
}
pub(crate) fn with_changed_packages_filter(mut self, orchestrate_job: &str) -> Self {
if let Some(nextest_command) = self.0.value.run.as_mut() {
nextest_command.push_str(&format!(
r#"${{{{ needs.{orchestrate_job}.outputs.changed_packages && format(' -E "{{0}}"', needs.{orchestrate_job}.outputs.changed_packages) || '' }}}}"#
));
}
self
}
}
impl From<Nextest> for Step<Run> {
fn from(value: Nextest) -> Self {
value.0
}
}
#[derive(Default)]
enum FetchDepth {
#[default]
Shallow,
Full,
Custom(serde_json::Value),
}
#[derive(Default)]
pub(crate) struct CheckoutStep {
fetch_depth: FetchDepth,
name: Option<String>,
token: Option<String>,
path: Option<String>,
repository: Option<String>,
ref_: Option<String>,
}
impl CheckoutStep {
pub fn with_full_history(mut self) -> Self {
self.fetch_depth = FetchDepth::Full;
self
}
pub fn with_custom_name(mut self, name: &str) -> Self {
self.name = Some(name.to_string());
self
}
pub fn with_custom_fetch_depth(mut self, fetch_depth: impl Into<Value>) -> Self {
self.fetch_depth = FetchDepth::Custom(fetch_depth.into());
self
}
/// Sets `fetch-depth` to `2` on the main branch and `350` on all other branches.
pub fn with_deep_history_on_non_main(self) -> Self {
self.with_custom_fetch_depth("${{ github.ref == 'refs/heads/main' && 2 || 350 }}")
}
pub fn with_token(mut self, token: &StepOutput) -> Self {
self.token = Some(token.to_string());
self
}
pub fn with_path(mut self, path: &str) -> Self {
self.path = Some(path.to_string());
self
}
pub fn with_repository(mut self, repository: &str) -> Self {
self.repository = Some(repository.to_string());
self
}
pub fn with_ref(mut self, ref_: impl ToString) -> Self {
self.ref_ = Some(ref_.to_string());
self
}
}
impl From<CheckoutStep> for Step<Use> {
fn from(value: CheckoutStep) -> Self {
Step::new(value.name.unwrap_or("steps::checkout_repo".to_string()))
.uses(
"actions",
"checkout",
"11bd71901bbe5b1630ceea73d27597364c9af683", // v4
)
// prevent checkout action from running `git clean -ffdx` which
// would delete the target directory
.add_with(("clean", false))
.map(|step| match value.fetch_depth {
FetchDepth::Shallow => step,
FetchDepth::Full => step.add_with(("fetch-depth", 0)),
FetchDepth::Custom(depth) => step.add_with(("fetch-depth", depth)),
})
.when_some(value.path, |step, path| step.add_with(("path", path)))
.when_some(value.repository, |step, repository| {
step.add_with(("repository", repository))
})
.when_some(value.ref_, |step, ref_| step.add_with(("ref", ref_)))
.when_some(value.token, |step, token| step.add_with(("token", token)))
}
}
pub fn checkout_repo() -> CheckoutStep {
CheckoutStep::default()
}
pub fn setup_pnpm() -> Step<Use> {
named::uses(
"pnpm",
"action-setup",
"fe02b34f77f8bc703788d5817da081398fad5dd2", // v4.0.0
)
.add_with(("version", "9"))
}
pub fn setup_node() -> Step<Use> {
named::uses(
"actions",
"setup-node",
"49933ea5288caeca8642d1e84afbd3f7d6820020", // v4
)
.add_with(("node-version", "20"))
}
pub fn setup_sentry() -> Step<Use> {
named::uses(
"matbour",
"setup-sentry-cli",
"3e938c54b3018bdd019973689ef984e033b0454b",
)
.add_with(("token", vars::SENTRY_AUTH_TOKEN))
}
pub fn prettier() -> Step<Run> {
named::bash("./script/prettier")
}
pub fn cargo_fmt() -> Step<Run> {
named::bash("cargo fmt --all -- --check")
}
pub fn cargo_install_nextest() -> Step<Use> {
named::uses("taiki-e", "install-action", "nextest")
}
pub fn setup_cargo_config(platform: Platform) -> Step<Run> {
match platform {
Platform::Windows => named::pwsh(indoc::indoc! {r#"
New-Item -ItemType Directory -Path "./../.cargo" -Force
Copy-Item -Path "./.cargo/ci-config.toml" -Destination "./../.cargo/config.toml"
"#}),
Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
mkdir -p ./../.cargo
cp ./.cargo/ci-config.toml ./../.cargo/config.toml
"#}),
}
}
pub fn cleanup_cargo_config(platform: Platform) -> Step<Run> {
let step = match platform {
Platform::Windows => named::pwsh(indoc::indoc! {r#"
Remove-Item -Recurse -Path "./../.cargo" -Force -ErrorAction SilentlyContinue
"#}),
Platform::Linux | Platform::Mac => named::bash(indoc::indoc! {r#"
rm -rf ./../.cargo
"#}),
};
step.if_condition(Expression::new("always()"))
}
pub fn clear_target_dir_if_large(platform: Platform) -> Step<Run> {
match platform {
Platform::Windows => named::pwsh("./script/clear-target-dir-if-larger-than.ps1 250"),
Platform::Linux => named::bash("./script/clear-target-dir-if-larger-than 250"),
Platform::Mac => named::bash("./script/clear-target-dir-if-larger-than 300"),
}
}
pub fn clippy(platform: Platform, target: Option<&str>) -> Step<Run> {
match platform {
Platform::Windows => named::pwsh("./script/clippy.ps1"),
_ => match target {
Some(target) => named::bash(format!("./script/clippy --target {target}")),
None => named::bash("./script/clippy"),
},
}
}
pub fn install_rustup_target(target: &str) -> Step<Run> {
named::bash(format!("rustup target add {target}"))
}
pub fn cache_rust_dependencies_namespace() -> Step<Use> {
named::uses("namespacelabs", "nscloud-cache-action", "v1")
.add_with(("cache", "rust"))
.add_with(("path", "~/.rustup"))
}
pub fn setup_sccache(platform: Platform) -> Step<Run> {
let step = match platform {
Platform::Windows => named::pwsh("./script/setup-sccache.ps1"),
Platform::Linux | Platform::Mac => named::bash("./script/setup-sccache"),
};
step.add_env(("R2_ACCOUNT_ID", vars::R2_ACCOUNT_ID))
.add_env(("R2_ACCESS_KEY_ID", vars::R2_ACCESS_KEY_ID))
.add_env(("R2_SECRET_ACCESS_KEY", vars::R2_SECRET_ACCESS_KEY))
.add_env(("SCCACHE_BUCKET", SCCACHE_R2_BUCKET))
}
pub fn show_sccache_stats(platform: Platform) -> Step<Run> {
match platform {
// Use $env:RUSTC_WRAPPER (absolute path) because GITHUB_PATH changes
// don't take effect until the next step in PowerShell.
// Check if RUSTC_WRAPPER is set first (it won't be for fork PRs without secrets).
Platform::Windows => {
named::pwsh("if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0")
}
Platform::Linux | Platform::Mac => named::bash("sccache --show-stats || true"),
}
}
pub fn cache_nix_dependencies_namespace() -> Step<Use> {
named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("cache", "nix"))
}
pub fn cache_nix_store_macos() -> Step<Use> {
// On macOS, `/nix` is on a read-only root filesystem so nscloud's `cache: nix`
// cannot mount or symlink there. Instead we cache a user-writable directory and
// use nix-store --import/--export in separate steps to transfer store paths.
named::uses("namespacelabs", "nscloud-cache-action", "v1").add_with(("path", "~/nix-cache"))
}
pub fn setup_linux() -> Step<Run> {
named::bash("./script/linux")
}
fn download_wasi_sdk() -> Step<Run> {
named::bash("./script/download-wasi-sdk")
}
pub(crate) fn install_linux_dependencies(job: Job) -> Job {
job.add_step(setup_linux()).add_step(download_wasi_sdk())
}
pub fn script(name: &str) -> Step<Run> {
if name.ends_with(".ps1") {
Step::new(name).run(name).shell(PWSH_SHELL)
} else {
Step::new(name).run(name)
}
}
pub struct NamedJob<J: JobType = RunJob> {
pub name: String,
pub job: Job<J>,
}
// impl NamedJob {
// pub fn map(self, f: impl FnOnce(Job) -> Job) -> Self {
// NamedJob {
// name: self.name,
// job: f(self.job),
// }
// }
// }
pub(crate) const DEFAULT_REPOSITORY_OWNER_GUARD: &str =
"(github.repository_owner == 'zed-industries' || github.repository_owner == 'zed-extensions')";
pub fn repository_owner_guard_expression(trigger_always: bool) -> Expression {
Expression::new(format!(
"{}{}",
DEFAULT_REPOSITORY_OWNER_GUARD,
trigger_always.then_some(" && always()").unwrap_or_default()
))
}
pub trait CommonJobConditions: Sized {
fn with_repository_owner_guard(self) -> Self;
}
impl CommonJobConditions for Job {
fn with_repository_owner_guard(self) -> Self {
self.cond(repository_owner_guard_expression(false))
}
}
pub(crate) fn release_job(deps: &[&NamedJob]) -> Job {
dependant_job(deps)
.with_repository_owner_guard()
.timeout_minutes(60u32)
}
pub(crate) fn dependant_job(deps: &[&NamedJob]) -> Job {
let job = Job::default();
if deps.len() > 0 {
job.needs(deps.iter().map(|j| j.name.clone()).collect::<Vec<_>>())
} else {
job
}
}
impl FluentBuilder for Job {}
impl FluentBuilder for Workflow {}
impl FluentBuilder for Input {}
impl<T> FluentBuilder for Step<T> {}
/// A helper trait for building complex objects with imperative conditionals in a fluent style.
/// Copied from GPUI to avoid adding GPUI as dependency
/// todo(ci) just put this in gh-workflow
#[allow(unused)]
pub trait FluentBuilder {
/// Imperatively modify self with the given closure.
fn map<U>(self, f: impl FnOnce(Self) -> U) -> U
where
Self: Sized,
{
f(self)
}
/// Conditionally modify self with the given closure.
fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
where
Self: Sized,
{
self.map(|this| if condition { then(this) } else { this })
}
/// Conditionally modify self with the given closure.
fn when_else(
self,
condition: bool,
then: impl FnOnce(Self) -> Self,
else_fn: impl FnOnce(Self) -> Self,
) -> Self
where
Self: Sized,
{
self.map(|this| if condition { then(this) } else { else_fn(this) })
}
/// Conditionally unwrap and modify self with the given closure, if the given option is Some.
fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
where
Self: Sized,
{
self.map(|this| {
if let Some(value) = option {
then(this, value)
} else {
this
}
})
}
/// Conditionally unwrap and modify self with the given closure, if the given option is None.
fn when_none<T>(self, option: &Option<T>, then: impl FnOnce(Self) -> Self) -> Self
where
Self: Sized,
{
self.map(|this| if option.is_some() { this } else { then(this) })
}
}
// (janky) helper to generate steps with a name that corresponds
// to the name of the calling function.
pub mod named {
use super::*;
/// Returns a uses step with the same name as the enclosing function.
/// (You shouldn't inline this function into the workflow definition, you must
/// wrap it in a new function.)
pub fn uses(owner: &str, repo: &str, ref_: &str) -> Step<Use> {
Step::new(function_name(1)).uses(owner, repo, ref_)
}
/// Returns a bash-script step with the same name as the enclosing function.
/// (You shouldn't inline this function into the workflow definition, you must
/// wrap it in a new function.)
pub fn bash(script: impl AsRef<str>) -> Step<Run> {
Step::new(function_name(1)).run(script.as_ref())
}
/// Returns a pwsh-script step with the same name as the enclosing function.
/// (You shouldn't inline this function into the workflow definition, you must
/// wrap it in a new function.)
pub fn pwsh(script: &str) -> Step<Run> {
Step::new(function_name(1)).run(script).shell(PWSH_SHELL)
}
/// Runs the command in either powershell or bash, depending on platform.
/// (You shouldn't inline this function into the workflow definition, you must
/// wrap it in a new function.)
pub fn run(platform: Platform, script: &str) -> Step<Run> {
match platform {
Platform::Windows => Step::new(function_name(1)).run(script).shell(PWSH_SHELL),
Platform::Linux | Platform::Mac => Step::new(function_name(1)).run(script),
}
}
/// Returns a Workflow with the same name as the enclosing module with default
/// set for the running shell.
pub fn workflow() -> Workflow {
Workflow::default()
.name(
named::function_name(1)
.split("::")
.collect::<Vec<_>>()
.into_iter()
.rev()
.skip(1)
.rev()
.collect::<Vec<_>>()
.join("::"),
)
.defaults(Defaults::default().run(RunDefaults::default().shell(BASH_SHELL)))
}
/// Returns a Job with the same name as the enclosing function.
/// (note job names may not contain `::`)
pub fn job<J: JobType>(job: Job<J>) -> NamedJob<J> {
NamedJob {
name: function_name(1).split("::").last().unwrap().to_owned(),
job,
}
}
/// Returns the function name N callers above in the stack
/// (typically 1).
/// This only works because xtask always runs debug builds.
pub fn function_name(i: usize) -> String {
let mut name = "<unknown>".to_string();
let mut count = 0;
backtrace::trace(|frame| {
if count < i + 3 {
count += 1;
return true;
}
backtrace::resolve_frame(frame, |cb| {
if let Some(s) = cb.name() {
name = s.to_string()
}
});
false
});
name.split("::")
.skip_while(|s| s != &"workflows")
.skip(1)
.collect::<Vec<_>>()
.join("::")
}
}
pub fn git_checkout(ref_name: &dyn std::fmt::Display) -> Step<Run> {
named::bash(r#"git fetch origin "$REF_NAME" && git checkout "$REF_NAME""#)
.add_env(("REF_NAME", ref_name.to_string()))
}
pub fn authenticate_as_zippy() -> (Step<Use>, StepOutput) {
let step = named::uses(
"actions",
"create-github-app-token",
"bef1eaf1c0ac2b148ee2a0a74c65fbe6db0631f1",
)
.add_with(("app-id", vars::ZED_ZIPPY_APP_ID))
.add_with(("private-key", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
.id("get-app-token");
let output = StepOutput::new(&step, "token");
(step, output)
}
+381
View File
@@ -0,0 +1,381 @@
use std::cell::RefCell;
use gh_workflow::{
Concurrency, Env, Expression, Step, WorkflowCallInput, WorkflowCallSecret,
WorkflowDispatchInput,
};
use crate::tasks::workflows::{runners::Platform, steps::NamedJob};
macro_rules! secret {
($secret_name:ident) => {
pub const $secret_name: &str = concat!("${{ secrets.", stringify!($secret_name), " }}");
};
}
macro_rules! var {
($var_name:ident) => {
pub const $var_name: &str = concat!("${{ vars.", stringify!($var_name), " }}");
};
}
secret!(ANTHROPIC_API_KEY);
secret!(OPENAI_API_KEY);
secret!(GOOGLE_AI_API_KEY);
secret!(GOOGLE_CLOUD_PROJECT);
secret!(APPLE_NOTARIZATION_ISSUER_ID);
secret!(APPLE_NOTARIZATION_KEY);
secret!(APPLE_NOTARIZATION_KEY_ID);
secret!(AZURE_SIGNING_CLIENT_ID);
secret!(AZURE_SIGNING_CLIENT_SECRET);
secret!(AZURE_SIGNING_TENANT_ID);
secret!(CACHIX_AUTH_TOKEN);
secret!(CLUSTER_NAME);
secret!(DIGITALOCEAN_ACCESS_TOKEN);
secret!(DIGITALOCEAN_SPACES_ACCESS_KEY);
secret!(DIGITALOCEAN_SPACES_SECRET_KEY);
secret!(GITHUB_TOKEN);
secret!(MACOS_CERTIFICATE);
secret!(MACOS_CERTIFICATE_PASSWORD);
secret!(SENTRY_AUTH_TOKEN);
secret!(ZED_CLIENT_CHECKSUM_SEED);
secret!(ZED_CLOUD_PROVIDER_ADDITIONAL_MODELS_JSON);
secret!(ZED_SENTRY_MINIDUMP_ENDPOINT);
secret!(SLACK_APP_ZED_UNIT_EVALS_BOT_TOKEN);
secret!(ZED_ZIPPY_APP_ID);
secret!(ZED_ZIPPY_APP_PRIVATE_KEY);
secret!(DISCORD_WEBHOOK_RELEASE_NOTES);
secret!(WINGET_TOKEN);
secret!(VERCEL_TOKEN);
secret!(SLACK_WEBHOOK_WORKFLOW_FAILURES);
secret!(R2_ACCOUNT_ID);
secret!(R2_ACCESS_KEY_ID);
secret!(R2_SECRET_ACCESS_KEY);
// todo(ci) make these secrets too...
var!(AZURE_SIGNING_ACCOUNT_NAME);
var!(AZURE_SIGNING_CERT_PROFILE_NAME);
var!(AZURE_SIGNING_ENDPOINT);
pub fn bundle_envs(platform: Platform) -> Env {
let env = Env::default()
.add("CARGO_INCREMENTAL", 0)
.add("ZED_CLIENT_CHECKSUM_SEED", ZED_CLIENT_CHECKSUM_SEED)
.add("ZED_MINIDUMP_ENDPOINT", ZED_SENTRY_MINIDUMP_ENDPOINT);
match platform {
Platform::Linux => env,
Platform::Mac => env
.add("MACOS_CERTIFICATE", MACOS_CERTIFICATE)
.add("MACOS_CERTIFICATE_PASSWORD", MACOS_CERTIFICATE_PASSWORD)
.add("APPLE_NOTARIZATION_KEY", APPLE_NOTARIZATION_KEY)
.add("APPLE_NOTARIZATION_KEY_ID", APPLE_NOTARIZATION_KEY_ID)
.add("APPLE_NOTARIZATION_ISSUER_ID", APPLE_NOTARIZATION_ISSUER_ID),
Platform::Windows => env
.add("AZURE_TENANT_ID", AZURE_SIGNING_TENANT_ID)
.add("AZURE_CLIENT_ID", AZURE_SIGNING_CLIENT_ID)
.add("AZURE_CLIENT_SECRET", AZURE_SIGNING_CLIENT_SECRET)
.add("ACCOUNT_NAME", AZURE_SIGNING_ACCOUNT_NAME)
.add("CERT_PROFILE_NAME", AZURE_SIGNING_CERT_PROFILE_NAME)
.add("ENDPOINT", AZURE_SIGNING_ENDPOINT)
.add("FILE_DIGEST", "SHA256")
.add("TIMESTAMP_DIGEST", "SHA256")
.add("TIMESTAMP_SERVER", "http://timestamp.acs.microsoft.com"),
}
}
pub fn one_workflow_per_non_main_branch() -> Concurrency {
one_workflow_per_non_main_branch_and_token("")
}
pub fn one_workflow_per_non_main_branch_and_token<T: AsRef<str>>(token: T) -> Concurrency {
Concurrency::default()
.group(format!(
concat!(
"${{{{ github.workflow }}}}-${{{{ github.ref_name }}}}-",
"${{{{ github.ref_name == 'main' && github.sha || 'anysha' }}}}{}"
),
token.as_ref()
))
.cancel_in_progress(true)
}
pub(crate) fn allow_concurrent_runs() -> Concurrency {
Concurrency::default()
.group("${{ github.workflow }}-${{ github.ref_name }}-${{ github.run_id }}")
.cancel_in_progress(true)
}
// Represents a pattern to check for changed files and corresponding output variable
pub struct PathCondition {
pub name: &'static str,
pub pattern: &'static str,
pub invert: bool,
pub set_by_step: RefCell<Option<String>>,
}
impl PathCondition {
pub fn new(name: &'static str, pattern: &'static str) -> Self {
Self {
name,
pattern,
invert: false,
set_by_step: Default::default(),
}
}
pub fn inverted(name: &'static str, pattern: &'static str) -> Self {
Self {
name,
pattern,
invert: true,
set_by_step: Default::default(),
}
}
pub fn guard(&self, job: NamedJob) -> NamedJob {
let set_by_step = self
.set_by_step
.borrow()
.clone()
.unwrap_or_else(|| panic!("condition {},is never set", self.name));
NamedJob {
name: job.name,
job: job
.job
.add_need(set_by_step.clone())
.cond(Expression::new(format!(
"needs.{}.outputs.{} == 'true'",
&set_by_step, self.name
))),
}
}
}
pub(crate) struct StepOutput {
pub name: &'static str,
step_id: String,
}
impl StepOutput {
pub fn new<T>(step: &Step<T>, name: &'static str) -> Self {
let step_id = step
.value
.id
.clone()
.expect("Steps that produce outputs must have an ID");
assert!(
step.value
.run
.as_ref()
.is_none_or(|run_command| run_command.contains(name)),
"Step Output name {name} must occur at least once in run command with ID {step_id}!"
);
Self { name, step_id }
}
pub fn new_unchecked<T>(step: &Step<T>, name: &'static str) -> Self {
let step_id = step
.value
.id
.clone()
.expect("Steps that produce outputs must have an ID");
Self { name, step_id }
}
pub fn expr(&self) -> String {
format!("steps.{}.outputs.{}", self.step_id, self.name)
}
pub fn as_job_output(self, job: &NamedJob) -> JobOutput {
JobOutput {
job_name: job.name.clone(),
name: self.name,
}
}
}
impl serde::Serialize for StepOutput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl std::fmt::Display for StepOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "${{{{ {} }}}}", self.expr())
}
}
pub(crate) struct JobOutput {
job_name: String,
name: &'static str,
}
impl JobOutput {
pub fn expr(&self) -> String {
format!("needs.{}.outputs.{}", self.job_name, self.name)
}
}
impl serde::Serialize for JobOutput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl std::fmt::Display for JobOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "${{{{ {} }}}}", self.expr())
}
}
pub struct WorkflowInput {
pub input_type: &'static str,
pub name: &'static str,
pub default: Option<String>,
pub description: Option<String>,
}
impl WorkflowInput {
pub fn string(name: &'static str, default: Option<String>) -> Self {
Self {
input_type: "string",
name,
default,
description: None,
}
}
pub fn bool(name: &'static str, default: Option<bool>) -> Self {
Self {
input_type: "boolean",
name,
default: default.as_ref().map(ToString::to_string),
description: None,
}
}
pub fn description(mut self, description: impl ToString) -> Self {
self.description = Some(description.to_string());
self
}
pub fn input(&self) -> WorkflowDispatchInput {
WorkflowDispatchInput {
description: self
.description
.clone()
.unwrap_or_else(|| self.name.to_owned()),
required: self.default.is_none(),
input_type: self.input_type.to_owned(),
default: self.default.clone(),
}
}
pub fn call_input(&self) -> WorkflowCallInput {
WorkflowCallInput {
description: self.name.to_owned(),
required: self.default.is_none(),
input_type: self.input_type.to_owned(),
default: self.default.clone(),
}
}
pub(crate) fn expr(&self) -> String {
format!("inputs.{}", self.name)
}
}
impl std::fmt::Display for WorkflowInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "${{{{ {} }}}}", self.expr())
}
}
impl serde::Serialize for WorkflowInput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
pub(crate) struct WorkflowSecret {
pub name: &'static str,
description: String,
required: bool,
}
impl WorkflowSecret {
pub fn new(name: &'static str, description: impl ToString) -> Self {
Self {
name,
description: description.to_string(),
required: true,
}
}
pub fn secret_configuration(&self) -> WorkflowCallSecret {
WorkflowCallSecret {
description: self.description.clone(),
required: self.required,
}
}
}
impl std::fmt::Display for WorkflowSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "${{{{ secrets.{} }}}}", self.name)
}
}
impl serde::Serialize for WorkflowSecret {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
pub mod assets {
// NOTE: these asset names also exist in the zed.dev codebase.
pub const MAC_AARCH64: &str = "Zed-aarch64.dmg";
pub const MAC_X86_64: &str = "Zed-x86_64.dmg";
pub const LINUX_AARCH64: &str = "zed-linux-aarch64.tar.gz";
pub const LINUX_X86_64: &str = "zed-linux-x86_64.tar.gz";
pub const WINDOWS_X86_64: &str = "Zed-x86_64.exe";
pub const WINDOWS_AARCH64: &str = "Zed-aarch64.exe";
pub const REMOTE_SERVER_MAC_AARCH64: &str = "zed-remote-server-macos-aarch64.gz";
pub const REMOTE_SERVER_MAC_X86_64: &str = "zed-remote-server-macos-x86_64.gz";
pub const REMOTE_SERVER_LINUX_AARCH64: &str = "zed-remote-server-linux-aarch64.gz";
pub const REMOTE_SERVER_LINUX_X86_64: &str = "zed-remote-server-linux-x86_64.gz";
pub const REMOTE_SERVER_WINDOWS_AARCH64: &str = "zed-remote-server-windows-aarch64.zip";
pub const REMOTE_SERVER_WINDOWS_X86_64: &str = "zed-remote-server-windows-x86_64.zip";
pub fn all() -> Vec<&'static str> {
vec![
MAC_AARCH64,
MAC_X86_64,
LINUX_AARCH64,
LINUX_X86_64,
WINDOWS_X86_64,
WINDOWS_AARCH64,
REMOTE_SERVER_MAC_AARCH64,
REMOTE_SERVER_MAC_X86_64,
REMOTE_SERVER_LINUX_AARCH64,
REMOTE_SERVER_LINUX_X86_64,
REMOTE_SERVER_WINDOWS_AARCH64,
REMOTE_SERVER_WINDOWS_X86_64,
]
}
}
+9
View File
@@ -0,0 +1,9 @@
use anyhow::{Context as _, Result};
use cargo_metadata::{Metadata, MetadataCommand};
/// Returns the Cargo workspace.
pub fn load_workspace() -> Result<Metadata> {
MetadataCommand::new()
.exec()
.context("failed to load cargo metadata")
}