logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
38
tooling/compliance/Cargo.toml
Normal file
38
tooling/compliance/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "compliance"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
octo-client = ["dep:octocrab", "dep:jsonwebtoken", "dep:tokio"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
derive_more.workspace = true
|
||||
futures.workspace = true
|
||||
itertools.workspace = true
|
||||
jsonwebtoken = { version = "10.2", features = ["use_pem"], optional = true }
|
||||
octocrab = { version = "0.49", default-features = false, features = [
|
||||
"default-client",
|
||||
"jwt-aws-lc-rs",
|
||||
"retry",
|
||||
"rustls",
|
||||
"rustls-aws-lc-rs",
|
||||
"stream",
|
||||
"timeout"
|
||||
], optional = true }
|
||||
regex.workspace = true
|
||||
semver.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
indoc.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
||||
1
tooling/compliance/LICENSE-GPL
Symbolic link
1
tooling/compliance/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
1249
tooling/compliance/src/checks.rs
Normal file
1249
tooling/compliance/src/checks.rs
Normal file
File diff suppressed because it is too large
Load Diff
779
tooling/compliance/src/git.rs
Normal file
779
tooling/compliance/src/git.rs
Normal file
@@ -0,0 +1,779 @@
|
||||
#![allow(clippy::disallowed_methods, reason = "This is only used in xtasks")]
|
||||
use std::{
|
||||
fmt::{self, Debug},
|
||||
ops::Not,
|
||||
process::Command,
|
||||
str::FromStr,
|
||||
sync::LazyLock,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use derive_more::{Deref, DerefMut, FromStr};
|
||||
|
||||
use itertools::Itertools;
|
||||
use regex::Regex;
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(crate) const ZED_ZIPPY_LOGIN: &str = "zed-zippy[bot]";
|
||||
pub(crate) const ZED_ZIPPY_EMAIL: &str = "234243425+zed-zippy[bot]@users.noreply.github.com";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutomatedChangeKind {
|
||||
VersionBump,
|
||||
ReleaseChannelUpdate,
|
||||
}
|
||||
|
||||
impl AutomatedChangeKind {
|
||||
pub(crate) fn expected_files(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::VersionBump => &["Cargo.lock", "crates/zed/Cargo.toml"],
|
||||
Self::ReleaseChannelUpdate => &["crates/zed/RELEASE_CHANNEL"],
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn expected_loc(&self) -> u64 {
|
||||
match self {
|
||||
Self::VersionBump => 2,
|
||||
Self::ReleaseChannelUpdate => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AutomatedChangeKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::VersionBump => formatter.write_str("version bump"),
|
||||
Self::ReleaseChannelUpdate => formatter.write_str("release channel update"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Subcommand {
|
||||
type ParsedOutput: FromStr<Err = anyhow::Error>;
|
||||
|
||||
fn args(&self) -> impl IntoIterator<Item = String>;
|
||||
}
|
||||
|
||||
#[derive(Deref, DerefMut)]
|
||||
pub struct GitCommand<G: Subcommand> {
|
||||
#[deref]
|
||||
#[deref_mut]
|
||||
subcommand: G,
|
||||
}
|
||||
|
||||
impl<G: Subcommand> GitCommand<G> {
|
||||
#[must_use]
|
||||
pub fn run(subcommand: G) -> Result<G::ParsedOutput> {
|
||||
Self { subcommand }.run_impl()
|
||||
}
|
||||
|
||||
fn run_impl(self) -> Result<G::ParsedOutput> {
|
||||
let command_output = Command::new("git")
|
||||
.args(self.subcommand.args())
|
||||
.output()
|
||||
.context("Failed to spawn command")?;
|
||||
|
||||
if command_output.status.success() {
|
||||
String::from_utf8(command_output.stdout)
|
||||
.map_err(|_| anyhow!("Invalid UTF8"))
|
||||
.and_then(|s| {
|
||||
G::ParsedOutput::from_str(s.trim())
|
||||
.map_err(|e| anyhow!("Failed to parse from string: {e:?}"))
|
||||
})
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Command failed with exit code {}, stderr: {}",
|
||||
command_output.status.code().unwrap_or_default(),
|
||||
String::from_utf8(command_output.stderr).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ReleaseChannel {
|
||||
Stable,
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl ReleaseChannel {
|
||||
pub(crate) fn tag_suffix(&self) -> &'static str {
|
||||
match self {
|
||||
ReleaseChannel::Stable => "",
|
||||
ReleaseChannel::Preview => "-pre",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VersionTag(Version, ReleaseChannel);
|
||||
|
||||
impl VersionTag {
|
||||
pub fn parse(input: &str) -> Result<Self, anyhow::Error> {
|
||||
// Being a bit more lenient for human inputs
|
||||
let version = input.strip_prefix('v').unwrap_or(input);
|
||||
|
||||
let (version_str, channel) = version
|
||||
.strip_suffix("-pre")
|
||||
.map_or((version, ReleaseChannel::Stable), |version_str| {
|
||||
(version_str, ReleaseChannel::Preview)
|
||||
});
|
||||
|
||||
Version::parse(version_str)
|
||||
.map(|version| Self(version, channel))
|
||||
.map_err(|_| anyhow::anyhow!("Failed to parse version from tag!"))
|
||||
}
|
||||
|
||||
pub fn version(&self) -> &Version {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for VersionTag {
|
||||
fn to_string(&self) -> String {
|
||||
format!(
|
||||
"v{version}{channel_suffix}",
|
||||
version = self.0,
|
||||
channel_suffix = self.1.tag_suffix()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deref, FromStr, PartialEq, Eq, Hash, Deserialize)]
|
||||
pub struct CommitSha(pub(crate) String);
|
||||
|
||||
impl CommitSha {
|
||||
pub fn new(sha: String) -> Self {
|
||||
Self(sha)
|
||||
}
|
||||
|
||||
pub fn short(&self) -> &str {
|
||||
self.0.as_str().split_at(8).0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommitDetails {
|
||||
sha: CommitSha,
|
||||
author: Committer,
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl CommitDetails {
|
||||
pub fn new(sha: CommitSha, author: Committer, title: String, body: String) -> Self {
|
||||
Self {
|
||||
sha,
|
||||
author,
|
||||
title,
|
||||
body,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CommitDetails {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
CommitList::from_str(s).and_then(|list| {
|
||||
list.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("No commit found"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Committer {
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
impl Committer {
|
||||
pub fn new(name: &str, email: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_owned(),
|
||||
email: email.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_zed_zippy(&self) -> bool {
|
||||
self.email == ZED_ZIPPY_EMAIL
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Committer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "{} ({})", self.name, self.email)
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitDetails {
|
||||
const BODY_DELIMITER: &str = "|body-delimiter|";
|
||||
const COMMIT_DELIMITER: &str = "|commit-delimiter|";
|
||||
const FIELD_DELIMITER: &str = "|field-delimiter|";
|
||||
const FORMAT_STRING: &str = "%H|field-delimiter|%an|field-delimiter|%ae|field-delimiter|%s|body-delimiter|%b|commit-delimiter|";
|
||||
|
||||
fn parse(line: &str, body: &str) -> Result<Self, anyhow::Error> {
|
||||
let Some([sha, author_name, author_email, title]) =
|
||||
line.splitn(4, Self::FIELD_DELIMITER).collect_array()
|
||||
else {
|
||||
return Err(anyhow!("Failed to parse commit fields from input {line}"));
|
||||
};
|
||||
|
||||
Ok(CommitDetails {
|
||||
sha: CommitSha(sha.to_owned()),
|
||||
author: Committer::new(author_name, author_email),
|
||||
title: title.to_owned(),
|
||||
body: body.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pr_number(&self) -> Option<u64> {
|
||||
// Since we use squash merge, all commit titles end with the '(#12345)' pattern.
|
||||
// While we could strictly speaking index into this directly, go for a slightly
|
||||
// less prone approach to errors
|
||||
const PATTERN: &str = " (#";
|
||||
self.title
|
||||
.rfind(PATTERN)
|
||||
.and_then(|location| {
|
||||
self.title[location..]
|
||||
.find(')')
|
||||
.map(|relative_end| location + PATTERN.len()..location + relative_end)
|
||||
})
|
||||
.and_then(|range| self.title[range].parse().ok())
|
||||
}
|
||||
|
||||
pub(crate) fn co_authors(&self) -> Option<Vec<Committer>> {
|
||||
static CO_AUTHOR_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"Co-authored-by: (.+) <(.+)>").unwrap());
|
||||
|
||||
let mut co_authors = Vec::new();
|
||||
|
||||
for cap in CO_AUTHOR_REGEX.captures_iter(&self.body.as_ref()) {
|
||||
let Some((name, email)) = cap
|
||||
.get(1)
|
||||
.map(|m| m.as_str())
|
||||
.zip(cap.get(2).map(|m| m.as_str()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
co_authors.push(Committer::new(name, email));
|
||||
}
|
||||
|
||||
co_authors.is_empty().not().then_some(co_authors)
|
||||
}
|
||||
|
||||
pub(crate) fn author(&self) -> &Committer {
|
||||
&self.author
|
||||
}
|
||||
|
||||
pub(crate) fn title(&self) -> &str {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn sha(&self) -> &CommitSha {
|
||||
&self.sha
|
||||
}
|
||||
|
||||
pub(crate) fn detect_automated_change(&self) -> Option<(AutomatedChangeKind, &str)> {
|
||||
static VERSION_BUMP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^Bump to [0-9]+\.[0-9]+\.[0-9]+ for @([a-zA-Z0-9][a-zA-Z0-9-]*)$").unwrap()
|
||||
});
|
||||
static RELEASE_CHANNEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"^v[0-9]+\.[0-9]+\.x (?:stable|preview) for @([a-zA-Z0-9][a-zA-Z0-9-]*)$")
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
VERSION_BUMP_REGEX
|
||||
.captures(&self.title)
|
||||
.and_then(|capture| capture.get(1))
|
||||
.map(|r#match| (AutomatedChangeKind::VersionBump, r#match.as_str()))
|
||||
.or_else(|| {
|
||||
RELEASE_CHANNEL_REGEX
|
||||
.captures(&self.title)
|
||||
.and_then(|capture| capture.get(1))
|
||||
.map(|r#match| (AutomatedChangeKind::ReleaseChannelUpdate, r#match.as_str()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deref, Default, DerefMut)]
|
||||
pub struct CommitList(Vec<CommitDetails>);
|
||||
|
||||
impl CommitList {
|
||||
pub fn range(&self) -> Option<String> {
|
||||
self.0
|
||||
.first()
|
||||
.zip(self.0.last())
|
||||
.map(|(first, last)| format!("{}..{}", last.sha().0, first.sha().0))
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for CommitList {
|
||||
type IntoIter = std::vec::IntoIter<CommitDetails>;
|
||||
type Item = CommitDetails;
|
||||
|
||||
fn into_iter(self) -> std::vec::IntoIter<Self::Item> {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for CommitList {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
Ok(CommitList(
|
||||
input
|
||||
.split(CommitDetails::COMMIT_DELIMITER)
|
||||
.filter(|commit_details| !commit_details.is_empty())
|
||||
.map(|commit_details| {
|
||||
let (line, body) = commit_details
|
||||
.trim()
|
||||
.split_once(CommitDetails::BODY_DELIMITER)
|
||||
.expect("Missing body delimiter");
|
||||
CommitDetails::parse(line, body)
|
||||
.expect("Parsing from the output should succeed")
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetVersionTags;
|
||||
|
||||
impl Subcommand for GetVersionTags {
|
||||
type ParsedOutput = VersionTagList;
|
||||
|
||||
fn args(&self) -> impl IntoIterator<Item = String> {
|
||||
["tag", "-l", "v*"].map(ToOwned::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VersionTagList(Vec<VersionTag>);
|
||||
|
||||
impl VersionTagList {
|
||||
pub fn sorted(mut self) -> Self {
|
||||
self.0.sort_by(|a, b| a.version().cmp(b.version()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn find_previous_minor_version(&self, version_tag: &VersionTag) -> Option<&VersionTag> {
|
||||
self.0
|
||||
.iter()
|
||||
.take_while(|tag| tag.version() < version_tag.version())
|
||||
.collect_vec()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|tag| {
|
||||
(tag.version().major < version_tag.version().major
|
||||
|| (tag.version().major == version_tag.version().major
|
||||
&& tag.version().minor < version_tag.version().minor))
|
||||
&& tag.version().patch == 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for VersionTagList {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let version_tags = s.lines().flat_map(VersionTag::parse).collect_vec();
|
||||
|
||||
version_tags
|
||||
.is_empty()
|
||||
.not()
|
||||
.then_some(Self(version_tags))
|
||||
.ok_or_else(|| anyhow::anyhow!("No version tags found"))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InfoForCommit {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
impl InfoForCommit {
|
||||
pub fn new(sha: impl ToString) -> Self {
|
||||
Self {
|
||||
sha: sha.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Subcommand for InfoForCommit {
|
||||
type ParsedOutput = CommitDetails;
|
||||
|
||||
fn args(&self) -> impl IntoIterator<Item = String> {
|
||||
[
|
||||
"log".to_string(),
|
||||
format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
|
||||
format!("{sha}~1..{sha}", sha = self.sha),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CommitsFromVersionToVersion {
|
||||
version_tag: VersionTag,
|
||||
branch: String,
|
||||
}
|
||||
|
||||
impl CommitsFromVersionToVersion {
|
||||
pub fn new(version_tag: VersionTag, branch: String) -> Self {
|
||||
Self {
|
||||
version_tag,
|
||||
branch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Subcommand for CommitsFromVersionToVersion {
|
||||
type ParsedOutput = CommitList;
|
||||
|
||||
fn args(&self) -> impl IntoIterator<Item = String> {
|
||||
[
|
||||
"log".to_string(),
|
||||
format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
|
||||
format!(
|
||||
"{version}..{branch}",
|
||||
version = self.version_tag.to_string(),
|
||||
branch = self.branch
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoOutput;
|
||||
|
||||
impl FromStr for NoOutput {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(_: &str) -> Result<Self, Self::Err> {
|
||||
Ok(NoOutput)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use indoc::indoc;
|
||||
|
||||
#[test]
|
||||
fn parse_stable_version_tag() {
|
||||
let tag = VersionTag::parse("v0.172.8").unwrap();
|
||||
assert_eq!(tag.version().major, 0);
|
||||
assert_eq!(tag.version().minor, 172);
|
||||
assert_eq!(tag.version().patch, 8);
|
||||
assert_eq!(tag.1, ReleaseChannel::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_preview_version_tag() {
|
||||
let tag = VersionTag::parse("v0.172.1-pre").unwrap();
|
||||
assert_eq!(tag.version().major, 0);
|
||||
assert_eq!(tag.version().minor, 172);
|
||||
assert_eq!(tag.version().patch, 1);
|
||||
assert_eq!(tag.1, ReleaseChannel::Preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_tag_without_v_prefix() {
|
||||
let tag = VersionTag::parse("0.172.8").unwrap();
|
||||
assert_eq!(tag.version().major, 0);
|
||||
assert_eq!(tag.version().minor, 172);
|
||||
assert_eq!(tag.version().patch, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_version_tag() {
|
||||
let result = VersionTag::parse("vConradTest");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tag_stable_roundtrip() {
|
||||
let tag = VersionTag::parse("v0.172.8").unwrap();
|
||||
assert_eq!(tag.to_string(), "v0.172.8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_tag_preview_roundtrip() {
|
||||
let tag = VersionTag::parse("v0.172.1-pre").unwrap();
|
||||
assert_eq!(tag.to_string(), "v0.172.1-pre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorted_orders_by_semver() {
|
||||
let input = indoc! {"
|
||||
v0.172.8
|
||||
v0.170.1
|
||||
v0.171.4
|
||||
v0.170.2
|
||||
v0.172.11
|
||||
v0.171.3
|
||||
v0.172.9
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
for window in list.0.windows(2) {
|
||||
assert!(
|
||||
window[0].version() <= window[1].version(),
|
||||
"{} should come before {}",
|
||||
window[0].to_string(),
|
||||
window[1].to_string()
|
||||
);
|
||||
}
|
||||
assert_eq!(list.0[0].to_string(), "v0.170.1");
|
||||
assert_eq!(list.0[list.0.len() - 1].to_string(), "v0.172.11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_previous_minor_for_173_returns_172() {
|
||||
let input = indoc! {"
|
||||
v0.170.1
|
||||
v0.170.2
|
||||
v0.171.3
|
||||
v0.171.4
|
||||
v0.172.0
|
||||
v0.172.8
|
||||
v0.172.9
|
||||
v0.172.11
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
let target = VersionTag::parse("v0.173.0").unwrap();
|
||||
let previous = list.find_previous_minor_version(&target).unwrap();
|
||||
assert_eq!(previous.version().major, 0);
|
||||
assert_eq!(previous.version().minor, 172);
|
||||
assert_eq!(previous.version().patch, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_previous_minor_skips_same_minor() {
|
||||
let input = indoc! {"
|
||||
v0.172.8
|
||||
v0.172.9
|
||||
v0.172.11
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
let target = VersionTag::parse("v0.172.8").unwrap();
|
||||
assert!(list.find_previous_minor_version(&target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_previous_minor_with_major_version_gap() {
|
||||
let input = indoc! {"
|
||||
v0.172.0
|
||||
v0.172.9
|
||||
v0.172.11
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
let target = VersionTag::parse("v1.0.0").unwrap();
|
||||
let previous = list.find_previous_minor_version(&target).unwrap();
|
||||
assert_eq!(previous.to_string(), "v0.172.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_previous_minor_requires_zero_patch_version() {
|
||||
let input = indoc! {"
|
||||
v0.172.1
|
||||
v0.172.9
|
||||
v0.172.11
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap().sorted();
|
||||
let target = VersionTag::parse("v1.0.0").unwrap();
|
||||
assert!(list.find_previous_minor_version(&target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tag_list_from_real_tags() {
|
||||
let input = indoc! {"
|
||||
v0.9999-temporary
|
||||
vConradTest
|
||||
v0.172.8
|
||||
"};
|
||||
let list = VersionTagList::from_str(input).unwrap();
|
||||
assert_eq!(list.0.len(), 1);
|
||||
assert_eq!(list.0[0].to_string(), "v0.172.8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_tag_list_fails() {
|
||||
let result = VersionTagList::from_str("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_number_from_squash_merge_title() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Add cool feature (#12345)",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert_eq!(commit.pr_number(), Some(12345));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_number_missing() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Some commit without PR ref",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert_eq!(commit.pr_number(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pr_number_takes_last_match() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Fix (#123) and refactor (#456)",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert_eq!(commit.pr_number(), Some(456));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn co_authors_parsed_from_body() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Some title",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let body = indoc! {"
|
||||
Co-authored-by: Alice Smith <alice@example.com>
|
||||
Co-authored-by: Bob Jones <bob@example.com>
|
||||
"};
|
||||
let commit = CommitDetails::parse(&line, body).unwrap();
|
||||
let co_authors = commit.co_authors().unwrap();
|
||||
assert_eq!(co_authors.len(), 2);
|
||||
assert_eq!(
|
||||
co_authors[0],
|
||||
Committer::new("Alice Smith", "alice@example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
co_authors[1],
|
||||
Committer::new("Bob Jones", "bob@example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_co_authors_returns_none() {
|
||||
let line = format!(
|
||||
"abc123{d}Author Name{d}author@email.com{d}Some title",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert!(commit.co_authors().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_sha_short_returns_first_8_chars() {
|
||||
let sha = CommitSha("abcdef1234567890abcdef1234567890abcdef12".into());
|
||||
assert_eq!(sha.short(), "abcdef12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_detects_version_bump() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
let (kind, actor) = commit.detect_automated_change().unwrap();
|
||||
assert_eq!(kind, AutomatedChangeKind::VersionBump);
|
||||
assert_eq!(actor, "cole-miller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_detects_stable_release_channel() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}v0.233.x stable for @cole-miller",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
let (kind, actor) = commit.detect_automated_change().unwrap();
|
||||
assert_eq!(kind, AutomatedChangeKind::ReleaseChannelUpdate);
|
||||
assert_eq!(actor, "cole-miller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_detects_preview_release_channel() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}v0.234.x preview for @cole-miller",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
let (kind, actor) = commit.detect_automated_change().unwrap();
|
||||
assert_eq!(kind, AutomatedChangeKind::ReleaseChannelUpdate);
|
||||
assert_eq!(actor, "cole-miller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_returns_none_for_regular_commit() {
|
||||
let line = format!(
|
||||
"abc123{d}Alice{d}alice@test.com{d}Fix a bug",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert!(commit.detect_automated_change().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_rejects_wrong_prefix() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}Fix thing for @cole-miller",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert!(commit.detect_automated_change().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automated_change_rejects_trailing_text() {
|
||||
let line = format!(
|
||||
"abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller extra",
|
||||
d = CommitDetails::FIELD_DELIMITER
|
||||
);
|
||||
let commit = CommitDetails::parse(&line, "").unwrap();
|
||||
assert!(commit.detect_automated_change().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committer_is_zed_zippy() {
|
||||
let committer = Committer::new("Zed Zippy", ZED_ZIPPY_EMAIL);
|
||||
assert!(committer.is_zed_zippy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committer_is_not_zed_zippy() {
|
||||
let committer = Committer::new("Alice", "alice@test.com");
|
||||
assert!(!committer.is_zed_zippy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_commit_list_from_git_log_format() {
|
||||
let fd = CommitDetails::FIELD_DELIMITER;
|
||||
let bd = CommitDetails::BODY_DELIMITER;
|
||||
let cd = CommitDetails::COMMIT_DELIMITER;
|
||||
|
||||
let input = format!(
|
||||
"sha111{fd}Alice{fd}alice@test.com{fd}First commit (#100){bd}First body{cd}sha222{fd}Bob{fd}bob@test.com{fd}Second commit (#200){bd}Second body{cd}"
|
||||
);
|
||||
|
||||
let list = CommitList::from_str(&input).unwrap();
|
||||
assert_eq!(list.0.len(), 2);
|
||||
|
||||
assert_eq!(list.0[0].sha().0, "sha111");
|
||||
assert_eq!(
|
||||
list.0[0].author(),
|
||||
&Committer::new("Alice", "alice@test.com")
|
||||
);
|
||||
assert_eq!(list.0[0].title(), "First commit (#100)");
|
||||
assert_eq!(list.0[0].pr_number(), Some(100));
|
||||
assert_eq!(list.0[0].body, "First body");
|
||||
|
||||
assert_eq!(list.0[1].sha().0, "sha222");
|
||||
assert_eq!(list.0[1].author(), &Committer::new("Bob", "bob@test.com"));
|
||||
assert_eq!(list.0[1].title(), "Second commit (#200)");
|
||||
assert_eq!(list.0[1].pr_number(), Some(200));
|
||||
assert_eq!(list.0[1].body, "Second body");
|
||||
}
|
||||
}
|
||||
725
tooling/compliance/src/github.rs
Normal file
725
tooling/compliance/src/github.rs
Normal file
@@ -0,0 +1,725 @@
|
||||
use std::{borrow::Cow, collections::HashMap, fmt};
|
||||
|
||||
use anyhow::Result;
|
||||
use derive_more::Deref;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::git::CommitSha;
|
||||
|
||||
pub const PR_REVIEW_LABEL: &str = "PR state:needs review";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GithubUser {
|
||||
pub login: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PullRequestData {
|
||||
pub number: u64,
|
||||
pub user: Option<GithubUser>,
|
||||
pub merged_by: Option<GithubUser>,
|
||||
pub labels: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReviewState {
|
||||
Approved,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthorAssociation {
|
||||
Owner,
|
||||
Member,
|
||||
Collaborator,
|
||||
Contributor,
|
||||
FirstTimeContributor,
|
||||
FirstTimer,
|
||||
Mannequin,
|
||||
None,
|
||||
}
|
||||
|
||||
impl AuthorAssociation {
|
||||
pub fn has_write_access(&self) -> bool {
|
||||
matches!(self, Self::Owner | Self::Member | Self::Collaborator)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Approvable {
|
||||
fn author_login(&self) -> Option<&str>;
|
||||
fn review_state(&self) -> Option<ReviewState>;
|
||||
fn body(&self) -> Option<&str>;
|
||||
fn author_association(&self) -> Option<AuthorAssociation>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PullRequestReview {
|
||||
pub user: Option<GithubUser>,
|
||||
pub state: Option<ReviewState>,
|
||||
pub body: Option<String>,
|
||||
pub author_association: Option<AuthorAssociation>,
|
||||
}
|
||||
|
||||
impl PullRequestReview {
|
||||
pub fn with_body(self, body: impl ToString) -> Self {
|
||||
Self {
|
||||
body: Some(body.to_string()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Approvable for PullRequestReview {
|
||||
fn author_login(&self) -> Option<&str> {
|
||||
self.user.as_ref().map(|user| user.login.as_str())
|
||||
}
|
||||
|
||||
fn review_state(&self) -> Option<ReviewState> {
|
||||
self.state
|
||||
}
|
||||
|
||||
fn body(&self) -> Option<&str> {
|
||||
self.body.as_deref()
|
||||
}
|
||||
|
||||
fn author_association(&self) -> Option<AuthorAssociation> {
|
||||
self.author_association
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PullRequestComment {
|
||||
pub user: GithubUser,
|
||||
pub body: Option<String>,
|
||||
pub author_association: Option<AuthorAssociation>,
|
||||
}
|
||||
|
||||
impl Approvable for PullRequestComment {
|
||||
fn author_login(&self) -> Option<&str> {
|
||||
Some(&self.user.login)
|
||||
}
|
||||
|
||||
fn review_state(&self) -> Option<ReviewState> {
|
||||
None
|
||||
}
|
||||
|
||||
fn body(&self) -> Option<&str> {
|
||||
self.body.as_deref()
|
||||
}
|
||||
|
||||
fn author_association(&self) -> Option<AuthorAssociation> {
|
||||
self.author_association
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Deref, PartialEq, Eq)]
|
||||
pub struct GithubLogin {
|
||||
login: String,
|
||||
}
|
||||
|
||||
impl GithubLogin {
|
||||
pub fn new(login: String) -> Self {
|
||||
Self { login }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GithubLogin {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(formatter, "@{}", self.login)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct CommitAuthor {
|
||||
name: String,
|
||||
email: String,
|
||||
user: Option<GithubLogin>,
|
||||
}
|
||||
|
||||
impl CommitAuthor {
|
||||
pub(crate) fn user(&self) -> Option<&GithubLogin> {
|
||||
self.user.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for CommitAuthor {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.user.as_ref().zip(other.user.as_ref()).map_or_else(
|
||||
|| self.email == other.email || self.name == other.name,
|
||||
|(l, r)| l == r,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for CommitAuthor {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.user.as_ref() {
|
||||
Some(user) => write!(formatter, "{} ({user})", self.name),
|
||||
None => write!(formatter, "{} ({})", self.name, self.email),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct CommitSignature {
|
||||
#[serde(rename = "isValid")]
|
||||
is_valid: bool,
|
||||
signer: Option<GithubLogin>,
|
||||
}
|
||||
|
||||
impl CommitSignature {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.is_valid
|
||||
}
|
||||
|
||||
pub fn signer(&self) -> Option<&GithubLogin> {
|
||||
self.signer.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommitFileChange {
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommitMetadata {
|
||||
#[serde(rename = "author")]
|
||||
primary_author: CommitAuthor,
|
||||
#[serde(rename = "authors", deserialize_with = "graph_ql::deserialize_nodes")]
|
||||
co_authors: Vec<CommitAuthor>,
|
||||
#[serde(default)]
|
||||
signature: Option<CommitSignature>,
|
||||
#[serde(default)]
|
||||
additions: u64,
|
||||
#[serde(default)]
|
||||
deletions: u64,
|
||||
}
|
||||
|
||||
impl CommitMetadata {
|
||||
pub fn co_authors(&self) -> Option<impl Iterator<Item = &CommitAuthor>> {
|
||||
let mut co_authors = self
|
||||
.co_authors
|
||||
.iter()
|
||||
.filter(|co_author| *co_author != &self.primary_author)
|
||||
.peekable();
|
||||
|
||||
co_authors.peek().is_some().then_some(co_authors)
|
||||
}
|
||||
|
||||
pub fn primary_author(&self) -> &CommitAuthor {
|
||||
&self.primary_author
|
||||
}
|
||||
|
||||
pub fn signature(&self) -> Option<&CommitSignature> {
|
||||
self.signature.as_ref()
|
||||
}
|
||||
|
||||
pub fn additions(&self) -> u64 {
|
||||
self.additions
|
||||
}
|
||||
|
||||
pub fn deletions(&self) -> u64 {
|
||||
self.deletions
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deref)]
|
||||
pub struct CommitMetadataBySha(HashMap<CommitSha, CommitMetadata>);
|
||||
|
||||
impl CommitMetadataBySha {
|
||||
const SHA_PREFIX: &'static str = "commit";
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for CommitMetadataBySha {
|
||||
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = HashMap::<String, CommitMetadata>::deserialize(deserializer)?;
|
||||
let map = raw
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
let sha = key
|
||||
.strip_prefix(CommitMetadataBySha::SHA_PREFIX)
|
||||
.unwrap_or(&key);
|
||||
(CommitSha::new(sha.to_owned()), value)
|
||||
})
|
||||
.collect();
|
||||
Ok(Self(map))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Repository<'a> {
|
||||
owner: Cow<'a, str>,
|
||||
name: Cow<'a, str>,
|
||||
}
|
||||
|
||||
impl<'a> Repository<'a> {
|
||||
pub const ZED: Repository<'static> = Repository::new_static("zed-industries", "zed");
|
||||
|
||||
pub fn new(owner: &'a str, name: &'a str) -> Self {
|
||||
Self {
|
||||
owner: Cow::Borrowed(owner),
|
||||
name: Cow::Borrowed(name),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn owner(&self) -> &str {
|
||||
&self.owner
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
}
|
||||
|
||||
impl Repository<'static> {
|
||||
pub const fn new_static(owner: &'static str, name: &'static str) -> Self {
|
||||
Self {
|
||||
owner: Cow::Borrowed(owner),
|
||||
name: Cow::Borrowed(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub trait GithubApiClient {
|
||||
async fn get_pull_request(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<PullRequestData>;
|
||||
async fn get_pull_request_reviews(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<Vec<PullRequestReview>>;
|
||||
async fn get_pull_request_comments(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<Vec<PullRequestComment>>;
|
||||
async fn get_commit_metadata(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
commit_shas: &[&CommitSha],
|
||||
) -> Result<CommitMetadataBySha>;
|
||||
async fn get_commit_files(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
sha: &CommitSha,
|
||||
) -> Result<Vec<CommitFileChange>>;
|
||||
async fn check_repo_write_permission(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
login: &GithubLogin,
|
||||
) -> Result<bool>;
|
||||
async fn add_label_to_issue(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
label: &str,
|
||||
issue_number: u64,
|
||||
) -> Result<()>;
|
||||
}
|
||||
|
||||
pub mod graph_ql {
|
||||
use anyhow::{Context as _, Result};
|
||||
use itertools::Itertools as _;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::git::CommitSha;
|
||||
|
||||
use super::CommitMetadataBySha;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GraphQLResponse<T> {
|
||||
pub data: Option<T>,
|
||||
pub errors: Option<Vec<GraphQLError>>,
|
||||
}
|
||||
|
||||
impl<T> GraphQLResponse<T> {
|
||||
pub fn into_data(self) -> Result<T> {
|
||||
if let Some(errors) = &self.errors {
|
||||
if !errors.is_empty() {
|
||||
let messages: String = errors.iter().map(|e| e.message.as_str()).join("; ");
|
||||
anyhow::bail!("GraphQL error: {messages}");
|
||||
}
|
||||
}
|
||||
|
||||
self.data.context("GraphQL response contained no data")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GraphQLError {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommitMetadataResponse {
|
||||
pub repository: CommitMetadataBySha,
|
||||
}
|
||||
|
||||
pub fn deserialize_nodes<'de, T, D>(deserializer: D) -> std::result::Result<Vec<T>, D::Error>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
struct Nodes<T> {
|
||||
nodes: Vec<T>,
|
||||
}
|
||||
Nodes::<T>::deserialize(deserializer).map(|wrapper| wrapper.nodes)
|
||||
}
|
||||
|
||||
pub fn build_commit_metadata_query<'a>(
|
||||
org: &str,
|
||||
repo: &str,
|
||||
shas: impl IntoIterator<Item = &'a CommitSha>,
|
||||
) -> String {
|
||||
const FRAGMENT: &str = r#"
|
||||
... on Commit {
|
||||
author {
|
||||
name
|
||||
email
|
||||
user { login }
|
||||
}
|
||||
authors(first: 10) {
|
||||
nodes {
|
||||
name
|
||||
email
|
||||
user { login }
|
||||
}
|
||||
}
|
||||
signature {
|
||||
isValid
|
||||
signer { login }
|
||||
}
|
||||
additions
|
||||
deletions
|
||||
}
|
||||
"#;
|
||||
|
||||
let objects = shas
|
||||
.into_iter()
|
||||
.map(|commit_sha| {
|
||||
format!(
|
||||
"{sha_prefix}{sha}: object(oid: \"{sha}\") {{ {FRAGMENT} }}",
|
||||
sha_prefix = CommitMetadataBySha::SHA_PREFIX,
|
||||
sha = **commit_sha,
|
||||
)
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
format!("{{ repository(owner: \"{org}\", name: \"{repo}\") {{ {objects} }} }}")
|
||||
.replace("\n", "")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "octo-client")]
|
||||
mod octo_client {
|
||||
use anyhow::{Context, Result};
|
||||
use futures::TryStreamExt as _;
|
||||
use jsonwebtoken::EncodingKey;
|
||||
use octocrab::{
|
||||
Octocrab, Page,
|
||||
models::{
|
||||
AuthorAssociation as OctocrabAuthorAssociation,
|
||||
pulls::ReviewState as OctocrabReviewState,
|
||||
},
|
||||
service::middleware::cache::mem::InMemoryCache,
|
||||
};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::pin;
|
||||
|
||||
use crate::{
|
||||
git::CommitSha,
|
||||
github::{Repository, graph_ql},
|
||||
};
|
||||
|
||||
use super::{
|
||||
AuthorAssociation, CommitFileChange, CommitMetadataBySha, GithubApiClient, GithubLogin,
|
||||
GithubUser, PullRequestComment, PullRequestData, PullRequestReview, ReviewState,
|
||||
};
|
||||
|
||||
fn convert_author_association(association: OctocrabAuthorAssociation) -> AuthorAssociation {
|
||||
match association {
|
||||
OctocrabAuthorAssociation::Owner => AuthorAssociation::Owner,
|
||||
OctocrabAuthorAssociation::Member => AuthorAssociation::Member,
|
||||
OctocrabAuthorAssociation::Collaborator => AuthorAssociation::Collaborator,
|
||||
OctocrabAuthorAssociation::Contributor => AuthorAssociation::Contributor,
|
||||
OctocrabAuthorAssociation::FirstTimeContributor => {
|
||||
AuthorAssociation::FirstTimeContributor
|
||||
}
|
||||
OctocrabAuthorAssociation::FirstTimer => AuthorAssociation::FirstTimer,
|
||||
OctocrabAuthorAssociation::Mannequin => AuthorAssociation::Mannequin,
|
||||
OctocrabAuthorAssociation::None => AuthorAssociation::None,
|
||||
_ => AuthorAssociation::None,
|
||||
}
|
||||
}
|
||||
|
||||
const PAGE_SIZE: u8 = 100;
|
||||
|
||||
pub struct OctocrabClient {
|
||||
client: Octocrab,
|
||||
}
|
||||
|
||||
impl OctocrabClient {
|
||||
pub async fn new(app_id: u64, app_private_key: &str, org: &str) -> Result<Self> {
|
||||
let octocrab = Octocrab::builder()
|
||||
.cache(InMemoryCache::new())
|
||||
.app(
|
||||
app_id.into(),
|
||||
EncodingKey::from_rsa_pem(app_private_key.as_bytes())?,
|
||||
)
|
||||
.build()?;
|
||||
|
||||
let installations = octocrab
|
||||
.apps()
|
||||
.installations()
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch installations")?
|
||||
.take_items();
|
||||
|
||||
let installation_id = installations
|
||||
.into_iter()
|
||||
.find(|installation| installation.account.login == org)
|
||||
.context("Could not find Zed repository in installations")?
|
||||
.id;
|
||||
|
||||
let client = octocrab.installation(installation_id)?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
async fn graphql<R: DeserializeOwned>(&self, query: &serde_json::Value) -> Result<R> {
|
||||
let response: serde_json::Value = self.client.graphql(query).await?;
|
||||
let parsed: graph_ql::GraphQLResponse<R> = serde_json::from_value(response)
|
||||
.context("Failed to parse GraphQL response envelope")?;
|
||||
parsed.into_data()
|
||||
}
|
||||
|
||||
async fn get_all<T: DeserializeOwned + 'static>(
|
||||
&self,
|
||||
page: Page<T>,
|
||||
) -> octocrab::Result<Vec<T>> {
|
||||
self.get_filtered(page, |_| true).await
|
||||
}
|
||||
|
||||
async fn get_filtered<T: DeserializeOwned + 'static>(
|
||||
&self,
|
||||
page: Page<T>,
|
||||
predicate: impl Fn(&T) -> bool,
|
||||
) -> octocrab::Result<Vec<T>> {
|
||||
let stream = page.into_stream(&self.client);
|
||||
pin!(stream);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
while let Some(item) = stream.try_next().await?
|
||||
&& predicate(&item)
|
||||
{
|
||||
results.push(item);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl GithubApiClient for OctocrabClient {
|
||||
async fn get_pull_request(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<PullRequestData> {
|
||||
let pr = self
|
||||
.client
|
||||
.pulls(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.get(pr_number)
|
||||
.await?;
|
||||
Ok(PullRequestData {
|
||||
number: pr.number,
|
||||
user: pr.user.map(|user| GithubUser { login: user.login }),
|
||||
merged_by: pr.merged_by.map(|user| GithubUser { login: user.login }),
|
||||
labels: pr
|
||||
.labels
|
||||
.map(|labels| labels.into_iter().map(|label| label.name).collect()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_pull_request_reviews(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<Vec<PullRequestReview>> {
|
||||
let page = self
|
||||
.client
|
||||
.pulls(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.list_reviews(pr_number)
|
||||
.per_page(PAGE_SIZE)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let reviews = self.get_all(page).await?;
|
||||
|
||||
Ok(reviews
|
||||
.into_iter()
|
||||
.map(|review| PullRequestReview {
|
||||
user: review.user.map(|user| GithubUser { login: user.login }),
|
||||
state: review.state.map(|state| match state {
|
||||
OctocrabReviewState::Approved => ReviewState::Approved,
|
||||
_ => ReviewState::Other,
|
||||
}),
|
||||
body: review.body,
|
||||
author_association: review.author_association.map(convert_author_association),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pull_request_comments(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
pr_number: u64,
|
||||
) -> Result<Vec<PullRequestComment>> {
|
||||
let page = self
|
||||
.client
|
||||
.issues(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.list_comments(pr_number)
|
||||
.per_page(PAGE_SIZE)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let comments = self.get_all(page).await?;
|
||||
|
||||
Ok(comments
|
||||
.into_iter()
|
||||
.map(|comment| PullRequestComment {
|
||||
user: GithubUser {
|
||||
login: comment.user.login,
|
||||
},
|
||||
body: comment.body,
|
||||
author_association: comment.author_association.map(convert_author_association),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_commit_metadata(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
commit_shas: &[&CommitSha],
|
||||
) -> Result<CommitMetadataBySha> {
|
||||
let query = graph_ql::build_commit_metadata_query(
|
||||
repo.owner.as_ref(),
|
||||
repo.name.as_ref(),
|
||||
commit_shas.iter().copied(),
|
||||
);
|
||||
let query = serde_json::json!({ "query": query });
|
||||
self.graphql::<graph_ql::CommitMetadataResponse>(&query)
|
||||
.await
|
||||
.map(|response| response.repository)
|
||||
}
|
||||
|
||||
async fn get_commit_files(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
sha: &CommitSha,
|
||||
) -> Result<Vec<CommitFileChange>> {
|
||||
let response = self
|
||||
.client
|
||||
.commits(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.get(sha.as_str())
|
||||
.await?;
|
||||
|
||||
Ok(response
|
||||
.files
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|file| CommitFileChange {
|
||||
filename: file.filename,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn check_repo_write_permission(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
login: &GithubLogin,
|
||||
) -> Result<bool> {
|
||||
// Check org membership first - we save ourselves a few request that way
|
||||
let page = self
|
||||
.client
|
||||
.orgs(repo.owner.as_ref())
|
||||
.list_members()
|
||||
.per_page(PAGE_SIZE)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let members = self.get_all(page).await?;
|
||||
|
||||
if members
|
||||
.into_iter()
|
||||
.any(|member| member.login == login.as_str())
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// TODO: octocrab fails to deserialize the permission response and
|
||||
// does not adhere to the scheme laid out at
|
||||
// https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2026-03-10#get-repository-permissions-for-a-user
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum RepoPermission {
|
||||
Admin,
|
||||
Write,
|
||||
Read,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RepositoryPermissions {
|
||||
permission: RepoPermission,
|
||||
}
|
||||
|
||||
self.client
|
||||
.get::<RepositoryPermissions, _, _>(
|
||||
format!(
|
||||
"/repos/{owner}/{repo}/collaborators/{user}/permission",
|
||||
owner = repo.owner.as_ref(),
|
||||
repo = repo.name.as_ref(),
|
||||
user = login.as_str()
|
||||
),
|
||||
None::<&()>,
|
||||
)
|
||||
.await
|
||||
.map(|response| {
|
||||
matches!(
|
||||
response.permission,
|
||||
RepoPermission::Write | RepoPermission::Admin
|
||||
)
|
||||
})
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn add_label_to_issue(
|
||||
&self,
|
||||
repo: &Repository<'_>,
|
||||
label: &str,
|
||||
issue_number: u64,
|
||||
) -> Result<()> {
|
||||
self.client
|
||||
.issues(repo.owner.as_ref(), repo.name.as_ref())
|
||||
.add_labels(issue_number, &[label.to_owned()])
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "octo-client")]
|
||||
pub use octo_client::OctocrabClient;
|
||||
4
tooling/compliance/src/lib.rs
Normal file
4
tooling/compliance/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod checks;
|
||||
pub mod git;
|
||||
pub mod github;
|
||||
pub mod report;
|
||||
493
tooling/compliance/src/report.rs
Normal file
493
tooling/compliance/src/report.rs
Normal file
@@ -0,0 +1,493 @@
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{BufWriter, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use derive_more::Display;
|
||||
use itertools::{Either, Itertools};
|
||||
|
||||
use crate::{
|
||||
checks::{ReviewFailure, ReviewResult, ReviewSuccess},
|
||||
git::CommitDetails,
|
||||
};
|
||||
|
||||
const PULL_REQUEST_BASE_URL: &str = "https://github.com/zed-industries/zed/pull";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReportEntry<R> {
|
||||
pub commit: CommitDetails,
|
||||
reason: R,
|
||||
}
|
||||
|
||||
impl<R> ReportEntry<R> {
|
||||
pub fn new(commit: CommitDetails, reason: R) -> Self {
|
||||
Self { commit, reason }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ToString> ReportEntry<R> {
|
||||
fn commit_cell(&self) -> String {
|
||||
let title = escape_markdown_link_text(self.commit.title());
|
||||
|
||||
match self.commit.pr_number() {
|
||||
Some(pr_number) => format!("[{title}]({PULL_REQUEST_BASE_URL}/{pr_number})"),
|
||||
None => escape_markdown_table_text(self.commit.title()),
|
||||
}
|
||||
}
|
||||
|
||||
fn pull_request_cell(&self) -> String {
|
||||
self.commit
|
||||
.pr_number()
|
||||
.map(|pr_number| format!("#{pr_number}"))
|
||||
.unwrap_or_else(|| "—".to_owned())
|
||||
}
|
||||
|
||||
fn author_cell(&self) -> String {
|
||||
escape_markdown_table_text(&self.commit.author().to_string())
|
||||
}
|
||||
|
||||
fn reason_cell(&self) -> String {
|
||||
escape_markdown_table_text(&self.reason.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportEntry<ReviewResult> {
|
||||
pub fn is_unknown_error(&self) -> bool {
|
||||
matches!(self.reason, Err(ReviewFailure::Other(_)))
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportEntry<ReviewFailure> {
|
||||
fn issue_kind(&self) -> IssueKind {
|
||||
match self.reason {
|
||||
ReviewFailure::Other(_) => IssueKind::Error,
|
||||
_ => IssueKind::NotReviewed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportEntry<ReviewSuccess> {
|
||||
fn reviewers_cell(&self) -> String {
|
||||
match &self.reason.reviewers() {
|
||||
Ok(reviewers) => escape_markdown_table_text(&reviewers),
|
||||
Err(_) => "—".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ReportSummary {
|
||||
pub pull_requests: usize,
|
||||
pub reviewed_prs: usize,
|
||||
pub other_checked: usize,
|
||||
pub not_reviewed: usize,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
pub enum ReportReviewSummary {
|
||||
MissingReviews,
|
||||
MissingReviewsWithErrors,
|
||||
NoIssuesFound,
|
||||
}
|
||||
|
||||
impl ReportSummary {
|
||||
fn from_entries(entries: &[ReportEntry<ReviewResult>]) -> Self {
|
||||
Self {
|
||||
pull_requests: entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.commit.pr_number())
|
||||
.unique()
|
||||
.count(),
|
||||
reviewed_prs: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.reason.is_ok() && entry.commit.pr_number().is_some())
|
||||
.count(),
|
||||
other_checked: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.reason.is_ok() && entry.commit.pr_number().is_none())
|
||||
.count(),
|
||||
not_reviewed: entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
entry.reason,
|
||||
Err(ReviewFailure::NoPullRequestFound
|
||||
| ReviewFailure::Unreviewed
|
||||
| ReviewFailure::UnexpectedZippyAction(_))
|
||||
)
|
||||
})
|
||||
.count(),
|
||||
errors: entries
|
||||
.iter()
|
||||
.filter(|entry| entry.is_unknown_error())
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn review_summary(&self) -> ReportReviewSummary {
|
||||
match self.not_reviewed {
|
||||
0 if self.errors == 0 => ReportReviewSummary::NoIssuesFound,
|
||||
1.. if self.errors == 0 => ReportReviewSummary::MissingReviews,
|
||||
_ => ReportReviewSummary::MissingReviewsWithErrors,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_errors(&self) -> bool {
|
||||
self.errors > 0
|
||||
}
|
||||
|
||||
pub fn prs_with_errors(&self) -> usize {
|
||||
self.pull_requests.saturating_sub(self.reviewed_prs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Display, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum IssueKind {
|
||||
#[display("Error")]
|
||||
Error,
|
||||
#[display("Not reviewed")]
|
||||
NotReviewed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
entries: Vec<ReportEntry<ReviewResult>>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn from_entries(entries: impl IntoIterator<Item = ReportEntry<ReviewResult>>) -> Self {
|
||||
Self {
|
||||
entries: entries.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, commit: CommitDetails, result: ReviewResult) {
|
||||
self.entries.push(ReportEntry {
|
||||
commit,
|
||||
reason: result,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn errors(&self) -> impl Iterator<Item = &ReportEntry<ReviewResult>> {
|
||||
self.entries.iter().filter(|entry| entry.reason.is_err())
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> ReportSummary {
|
||||
ReportSummary::from_entries(&self.entries)
|
||||
}
|
||||
|
||||
pub fn write_markdown(self, path: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
let path = path.as_ref();
|
||||
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!(
|
||||
"Failed to create parent directory for markdown report at {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let summary = self.summary();
|
||||
let (successes, mut issues): (Vec<_>, Vec<_>) =
|
||||
self.entries
|
||||
.into_iter()
|
||||
.partition_map(|entry| match entry.reason {
|
||||
Ok(success) => Either::Left(ReportEntry {
|
||||
reason: success,
|
||||
commit: entry.commit,
|
||||
}),
|
||||
Err(fail) => Either::Right(ReportEntry {
|
||||
reason: fail,
|
||||
commit: entry.commit,
|
||||
}),
|
||||
});
|
||||
|
||||
issues.sort_by_key(|entry| entry.issue_kind());
|
||||
|
||||
let file = File::create(path)
|
||||
.with_context(|| format!("Failed to create markdown report at {}", path.display()))?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
writeln!(writer, "# Compliance report")?;
|
||||
writeln!(writer)?;
|
||||
writeln!(writer, "## Overview")?;
|
||||
writeln!(writer)?;
|
||||
writeln!(writer, "- PRs: {}", summary.pull_requests)?;
|
||||
writeln!(writer, "- Reviewed: {}", summary.reviewed_prs)?;
|
||||
writeln!(writer, "- Not reviewed: {}", summary.not_reviewed)?;
|
||||
writeln!(
|
||||
writer,
|
||||
"- Differently validated commits: {}",
|
||||
summary.other_checked
|
||||
)?;
|
||||
if summary.has_errors() {
|
||||
writeln!(writer, "- Errors: {}", summary.errors)?;
|
||||
}
|
||||
writeln!(writer)?;
|
||||
|
||||
write_issue_table(&mut writer, &issues, &summary)?;
|
||||
write_success_table(&mut writer, &successes)?;
|
||||
|
||||
writer
|
||||
.flush()
|
||||
.with_context(|| format!("Failed to flush markdown report to {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_issue_table(
|
||||
writer: &mut impl Write,
|
||||
issues: &[ReportEntry<ReviewFailure>],
|
||||
summary: &ReportSummary,
|
||||
) -> std::io::Result<()> {
|
||||
if summary.has_errors() {
|
||||
writeln!(writer, "## Errors and unreviewed commits")?;
|
||||
} else {
|
||||
writeln!(writer, "## Unreviewed commits")?;
|
||||
}
|
||||
writeln!(writer)?;
|
||||
|
||||
if issues.is_empty() {
|
||||
if summary.has_errors() {
|
||||
writeln!(writer, "No errors or unreviewed commits found.")?;
|
||||
} else {
|
||||
writeln!(writer, "No unreviewed commits found.")?;
|
||||
}
|
||||
writeln!(writer)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
writeln!(writer, "| Commit | PR | Author | Outcome | Reason |")?;
|
||||
writeln!(writer, "| --- | --- | --- | --- | --- |")?;
|
||||
|
||||
for entry in issues {
|
||||
let issue_kind = entry.issue_kind();
|
||||
writeln!(
|
||||
writer,
|
||||
"| {} | {} | {} | {} | {} |",
|
||||
entry.commit_cell(),
|
||||
entry.pull_request_cell(),
|
||||
entry.author_cell(),
|
||||
issue_kind,
|
||||
entry.reason_cell(),
|
||||
)?;
|
||||
}
|
||||
|
||||
writeln!(writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_success_table(
|
||||
writer: &mut impl Write,
|
||||
successful_entries: &[ReportEntry<ReviewSuccess>],
|
||||
) -> std::io::Result<()> {
|
||||
writeln!(writer, "## Successful commits")?;
|
||||
writeln!(writer)?;
|
||||
|
||||
if successful_entries.is_empty() {
|
||||
writeln!(writer, "No successful commits found.")?;
|
||||
writeln!(writer)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
writeln!(writer, "| Commit | PR | Author | Reviewers | Reason |")?;
|
||||
writeln!(writer, "| --- | --- | --- | --- | --- |")?;
|
||||
|
||||
for entry in successful_entries {
|
||||
writeln!(
|
||||
writer,
|
||||
"| {} | {} | {} | {} | {} |",
|
||||
entry.commit_cell(),
|
||||
entry.pull_request_cell(),
|
||||
entry.author_cell(),
|
||||
entry.reviewers_cell(),
|
||||
entry.reason_cell(),
|
||||
)?;
|
||||
}
|
||||
|
||||
writeln!(writer)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn escape_markdown_link_text(input: &str) -> String {
|
||||
escape_markdown_table_text(input)
|
||||
.replace('[', r"\[")
|
||||
.replace(']', r"\]")
|
||||
}
|
||||
|
||||
fn escape_markdown_table_text(input: &str) -> String {
|
||||
input
|
||||
.replace('\\', r"\\")
|
||||
.replace('|', r"\|")
|
||||
.replace('\r', "")
|
||||
.replace('\n', "<br>")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
checks::{ReviewFailure, ReviewSuccess},
|
||||
git::{AutomatedChangeKind, CommitDetails, CommitList},
|
||||
github::{AuthorAssociation, GithubLogin, GithubUser, PullRequestReview, ReviewState},
|
||||
};
|
||||
|
||||
use super::{Report, ReportReviewSummary};
|
||||
|
||||
fn make_commit(
|
||||
sha: &str,
|
||||
author_name: &str,
|
||||
author_email: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
) -> CommitDetails {
|
||||
let formatted = format!(
|
||||
"{sha}|field-delimiter|{author_name}|field-delimiter|{author_email}|field-delimiter|{title}|body-delimiter|{body}|commit-delimiter|"
|
||||
);
|
||||
CommitList::from_str(&formatted)
|
||||
.expect("test commit should parse")
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("should have one commit")
|
||||
}
|
||||
|
||||
fn reviewed() -> ReviewSuccess {
|
||||
ReviewSuccess::PullRequestReviewed(vec![PullRequestReview {
|
||||
user: Some(GithubUser {
|
||||
login: "reviewer".to_owned(),
|
||||
}),
|
||||
state: Some(ReviewState::Approved),
|
||||
body: None,
|
||||
author_association: Some(AuthorAssociation::Member),
|
||||
}])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_counts_are_accurate() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit(
|
||||
"aaa",
|
||||
"Alice",
|
||||
"alice@test.com",
|
||||
"Reviewed commit (#100)",
|
||||
"",
|
||||
),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Unreviewed commit (#200)", ""),
|
||||
Err(ReviewFailure::Unreviewed),
|
||||
);
|
||||
report.add(
|
||||
make_commit("ccc", "Carol", "carol@test.com", "No PR commit", ""),
|
||||
Err(ReviewFailure::NoPullRequestFound),
|
||||
);
|
||||
report.add(
|
||||
make_commit("ddd", "Dave", "dave@test.com", "Error commit (#300)", ""),
|
||||
Err(ReviewFailure::Other(anyhow::anyhow!("some error"))),
|
||||
);
|
||||
report.add(
|
||||
make_commit("ddd", "Dave", "dave@test.com", "Bump Version", ""),
|
||||
Ok(ReviewSuccess::ZedZippyCommit(
|
||||
AutomatedChangeKind::VersionBump,
|
||||
GithubLogin::new("dave".to_string()),
|
||||
)),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert_eq!(summary.pull_requests, 3);
|
||||
assert_eq!(summary.reviewed_prs, 1);
|
||||
assert_eq!(summary.other_checked, 1);
|
||||
assert_eq!(summary.not_reviewed, 2);
|
||||
assert_eq!(summary.errors, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_all_reviewed_is_no_issues() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit("aaa", "Alice", "alice@test.com", "First (#100)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Second (#200)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert!(matches!(
|
||||
summary.review_summary(),
|
||||
ReportReviewSummary::NoIssuesFound
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_missing_reviews_only() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit("aaa", "Alice", "alice@test.com", "Reviewed (#100)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Unreviewed (#200)", ""),
|
||||
Err(ReviewFailure::Unreviewed),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert!(matches!(
|
||||
summary.review_summary(),
|
||||
ReportReviewSummary::MissingReviews
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_errors_and_missing_reviews() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit("aaa", "Alice", "alice@test.com", "Unreviewed (#100)", ""),
|
||||
Err(ReviewFailure::Unreviewed),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Errored (#200)", ""),
|
||||
Err(ReviewFailure::Other(anyhow::anyhow!("check failed"))),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert!(matches!(
|
||||
summary.review_summary(),
|
||||
ReportReviewSummary::MissingReviewsWithErrors
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_summary_deduplicates_pull_requests() {
|
||||
let mut report = Report::new();
|
||||
|
||||
report.add(
|
||||
make_commit("aaa", "Alice", "alice@test.com", "First change (#100)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
report.add(
|
||||
make_commit("bbb", "Bob", "bob@test.com", "Second change (#100)", ""),
|
||||
Ok(reviewed()),
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
assert_eq!(summary.pull_requests, 1);
|
||||
}
|
||||
}
|
||||
32
tooling/perf/Cargo.toml
Normal file
32
tooling/perf/Cargo.toml
Normal 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 Zed test performance, with too many Clippy lints"
|
||||
|
||||
[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
tooling/perf/LICENSE-APACHE
Symbolic link
1
tooling/perf/LICENSE-APACHE
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-APACHE
|
||||
450
tooling/perf/src/implementation.rs
Normal file
450
tooling/perf/src/implementation.rs
Normal 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
tooling/perf/src/lib.rs
Normal file
7
tooling/perf/src/lib.rs
Normal 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
tooling/perf/src/main.rs
Normal file
582
tooling/perf/src/main.rs
Normal 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);
|
||||
}
|
||||
30
tooling/xtask/Cargo.toml
Normal file
30
tooling/xtask/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[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"] }
|
||||
compliance = { workspace = true, features = ["octo-client"] }
|
||||
gh-workflow.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
|
||||
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
1
tooling/xtask/LICENSE-GPL
Symbolic link
1
tooling/xtask/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
48
tooling/xtask/src/main.rs
Normal file
48
tooling/xtask/src/main.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
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),
|
||||
Compliance(tasks::compliance::ComplianceArgs),
|
||||
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),
|
||||
/// Downloads the pinned `webrtc-sys` release and configures `LK_CUSTOM_WEBRTC`.
|
||||
SetupWebrtc(tasks::setup_webrtc::SetupWebrtcArgs),
|
||||
/// 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::Compliance(args) => tasks::compliance::check_compliance(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::SetupWebrtc(args) => tasks::setup_webrtc::run_setup_webrtc(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),
|
||||
}
|
||||
}
|
||||
9
tooling/xtask/src/tasks.rs
Normal file
9
tooling/xtask/src/tasks.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod clippy;
|
||||
pub mod compliance;
|
||||
pub mod licenses;
|
||||
pub mod package_conformity;
|
||||
pub mod publish_gpui;
|
||||
pub mod setup_webrtc;
|
||||
pub mod web_examples;
|
||||
pub mod workflow_checks;
|
||||
pub mod workflows;
|
||||
64
tooling/xtask/src/tasks/clippy.rs
Normal file
64
tooling/xtask/src/tasks/clippy.rs
Normal 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(())
|
||||
}
|
||||
198
tooling/xtask/src/tasks/compliance.rs
Normal file
198
tooling/xtask/src/tasks/compliance.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use std::{path::PathBuf, rc::Rc};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use compliance::{
|
||||
checks::Reporter,
|
||||
git::{CommitsFromVersionToVersion, GetVersionTags, GitCommand, InfoForCommit, VersionTag},
|
||||
github::{GithubApiClient as _, OctocrabClient, Repository},
|
||||
report::ReportReviewSummary,
|
||||
};
|
||||
|
||||
const MAX_CONCURRENT_REQUESTS: usize = 5;
|
||||
|
||||
#[derive(Parser)]
|
||||
pub(crate) struct ComplianceArgs {
|
||||
#[clap(subcommand)]
|
||||
mode: ComplianceMode,
|
||||
}
|
||||
|
||||
const IGNORE_LIST: &[&str] = &[
|
||||
"75fa566511e3ae7d03cfd76008512080291bd81d", // GitHub nuked this PR out of orbit
|
||||
];
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum ComplianceMode {
|
||||
// Check compliance for all commits between two version tags
|
||||
Version(VersionArgs),
|
||||
// Check compliance for a single commit
|
||||
Single {
|
||||
// The full commit SHA to check
|
||||
commit_sha: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
pub(crate) struct VersionArgs {
|
||||
#[arg(value_parser = VersionTag::parse)]
|
||||
// The version to be on the lookout for
|
||||
version_tag: VersionTag,
|
||||
#[arg(long)]
|
||||
// The markdown file to write the compliance report to
|
||||
report_path: PathBuf,
|
||||
#[arg(long)]
|
||||
// An optional branch to use instead of the determined version branch
|
||||
branch: Option<String>,
|
||||
}
|
||||
|
||||
impl VersionArgs {
|
||||
pub(crate) fn version_tag(&self) -> &VersionTag {
|
||||
&self.version_tag
|
||||
}
|
||||
|
||||
fn version_head(&self) -> String {
|
||||
self.branch
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.version_tag().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_compliance_impl(args: ComplianceArgs) -> Result<()> {
|
||||
let app_id = std::env::var("GITHUB_APP_ID").context("Missing GITHUB_APP_ID")?;
|
||||
let key = std::env::var("GITHUB_APP_KEY").context("Missing GITHUB_APP_KEY")?;
|
||||
|
||||
let client = Rc::new(
|
||||
OctocrabClient::new(
|
||||
app_id.parse().context("Failed to parse app ID as int")?,
|
||||
key.as_ref(),
|
||||
Repository::ZED.owner(),
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
println!("Initialized GitHub client for app ID {app_id}");
|
||||
|
||||
let args = match args.mode {
|
||||
ComplianceMode::Version(version) => version,
|
||||
ComplianceMode::Single { commit_sha } => {
|
||||
let commit = GitCommand::run(InfoForCommit::new(&commit_sha))?;
|
||||
|
||||
return match Reporter::result_for_commit(commit, client).await {
|
||||
Ok(review_success) => {
|
||||
println!("Check for commit {commit_sha} succeeded. Result: {review_success}",);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Err(review_failure) => Err(anyhow::anyhow!(
|
||||
"Check for commit {commit_sha} failed. Result: {review_failure}"
|
||||
)),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let tag = args.version_tag();
|
||||
|
||||
let previous_version = GitCommand::run(GetVersionTags)?
|
||||
.sorted()
|
||||
.find_previous_minor_version(&tag)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not find previous version for tag {tag}",
|
||||
tag = tag.to_string()
|
||||
)
|
||||
})?;
|
||||
|
||||
println!(
|
||||
"Checking compliance for version {} with version {} as base",
|
||||
tag.version(),
|
||||
previous_version.version()
|
||||
);
|
||||
|
||||
let commits = GitCommand::run(CommitsFromVersionToVersion::new(
|
||||
previous_version,
|
||||
args.version_head(),
|
||||
))?;
|
||||
|
||||
let Some(range) = commits.range() else {
|
||||
anyhow::bail!("No commits found to check");
|
||||
};
|
||||
|
||||
println!("Checking commit range {range}, {} total", commits.len());
|
||||
|
||||
let report = Reporter::new(commits, client.clone())
|
||||
.generate_report(MAX_CONCURRENT_REQUESTS)
|
||||
.await;
|
||||
|
||||
println!(
|
||||
"Generated report for version {}",
|
||||
args.version_tag().to_string()
|
||||
);
|
||||
|
||||
let summary = report.summary();
|
||||
|
||||
println!(
|
||||
"Applying compliance labels to {} pull requests",
|
||||
summary.prs_with_errors()
|
||||
);
|
||||
|
||||
let all_errors_known = report.errors().all(|error| {
|
||||
error.is_unknown_error() && IGNORE_LIST.contains(&error.commit.sha().as_str())
|
||||
});
|
||||
|
||||
for report in report.errors() {
|
||||
if let Some(pr_number) = report.commit.pr_number()
|
||||
&& let Ok(pull_request) = client.get_pull_request(&Repository::ZED, pr_number).await
|
||||
&& pull_request.labels.is_none_or(|labels| {
|
||||
labels
|
||||
.iter()
|
||||
.all(|label| label != compliance::github::PR_REVIEW_LABEL)
|
||||
})
|
||||
{
|
||||
println!("Adding review label to PR {}...", pr_number);
|
||||
|
||||
client
|
||||
.add_label_to_issue(
|
||||
&Repository::ZED,
|
||||
compliance::github::PR_REVIEW_LABEL,
|
||||
pr_number,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
report.write_markdown(&args.report_path)?;
|
||||
|
||||
println!("Wrote compliance report to {}", args.report_path.display());
|
||||
|
||||
match summary.review_summary() {
|
||||
ReportReviewSummary::MissingReviews => Err(anyhow::anyhow!(
|
||||
"Compliance check failed, found {} commits not reviewed",
|
||||
summary.not_reviewed
|
||||
)),
|
||||
ReportReviewSummary::MissingReviewsWithErrors if all_errors_known => {
|
||||
println!(
|
||||
"Compliance check failed with {} unreviewed commits, but all errors are known.",
|
||||
summary.not_reviewed
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
ReportReviewSummary::MissingReviewsWithErrors => Err(anyhow::anyhow!(
|
||||
"Compliance check failed with {} unreviewed commits and {} other issues",
|
||||
summary.not_reviewed,
|
||||
summary.errors
|
||||
)),
|
||||
ReportReviewSummary::NoIssuesFound => {
|
||||
println!("No issues found, compliance check passed.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_compliance(args: ComplianceArgs) -> Result<()> {
|
||||
tokio::runtime::Runtime::new()
|
||||
.context("Failed to create tokio runtime")
|
||||
.and_then(|handle| handle.block_on(check_compliance_impl(args)))
|
||||
}
|
||||
45
tooling/xtask/src/tasks/licenses.rs
Normal file
45
tooling/xtask/src/tasks/licenses.rs
Normal 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
|
||||
}
|
||||
75
tooling/xtask/src/tasks/package_conformity.rs
Normal file
75
tooling/xtask/src/tasks/package_conformity.rs
Normal file
@@ -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
tooling/xtask/src/tasks/publish_gpui.rs
Normal file
442
tooling/xtask/src/tasks/publish_gpui.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
249
tooling/xtask/src/tasks/setup_webrtc.rs
Normal file
249
tooling/xtask/src/tasks/setup_webrtc.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
#![allow(clippy::disallowed_methods, reason = "tooling is exempt")]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use cargo_toml::Manifest;
|
||||
use clap::Parser;
|
||||
use regex::Regex;
|
||||
use toml_edit::{DocumentMut, Item, Table, value};
|
||||
|
||||
use crate::workspace::load_workspace;
|
||||
|
||||
const GITIGNORE_ENTRY: &str = ".webrtc-sys/";
|
||||
const LOCAL_DIR_NAME: &str = ".webrtc-sys";
|
||||
const ENV_VAR: &str = "LK_CUSTOM_WEBRTC";
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct SetupWebrtcArgs {
|
||||
/// Re-download even if the target directory already exists.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Override the host triple component used for the release artifact
|
||||
/// (e.g. `mac-arm64-release`). Defaults to the current host.
|
||||
#[arg(long)]
|
||||
triple: Option<String>,
|
||||
|
||||
/// Skip writing to `~/.cargo/config.toml`. Useful when you only want the
|
||||
/// archive on disk and intend to set `LK_CUSTOM_WEBRTC` yourself.
|
||||
#[arg(long)]
|
||||
no_cargo_config: bool,
|
||||
}
|
||||
|
||||
pub fn run_setup_webrtc(args: SetupWebrtcArgs) -> Result<()> {
|
||||
let metadata = load_workspace()?;
|
||||
let workspace_root = metadata.workspace_root.as_std_path().to_path_buf();
|
||||
|
||||
let rev = read_webrtc_sys_rev(&workspace_root)?;
|
||||
eprintln!("Pinned livekit-rust-sdks rev: {rev}");
|
||||
|
||||
let tag = fetch_webrtc_tag(&rev)?;
|
||||
eprintln!("WEBRTC_TAG for that rev: {tag}");
|
||||
|
||||
let triple = match args.triple {
|
||||
Some(triple) => triple,
|
||||
None => host_webrtc_triple()?,
|
||||
};
|
||||
eprintln!("Target triple: {triple}");
|
||||
|
||||
let local_root = workspace_root.join(LOCAL_DIR_NAME);
|
||||
let tag_dir = local_root.join(&tag);
|
||||
let extracted_dir = tag_dir.join(&triple);
|
||||
|
||||
if extracted_dir.exists() && !args.force {
|
||||
eprintln!(
|
||||
"Already present at {}, skipping download.",
|
||||
extracted_dir.display()
|
||||
);
|
||||
} else {
|
||||
if extracted_dir.exists() {
|
||||
fs::remove_dir_all(&extracted_dir)
|
||||
.with_context(|| format!("removing stale {}", extracted_dir.display()))?;
|
||||
}
|
||||
fs::create_dir_all(&tag_dir).with_context(|| format!("creating {}", tag_dir.display()))?;
|
||||
download_and_extract(&tag, &triple, &tag_dir)?;
|
||||
}
|
||||
|
||||
let absolute = extracted_dir
|
||||
.canonicalize()
|
||||
.with_context(|| format!("canonicalizing {}", extracted_dir.display()))?;
|
||||
|
||||
ensure_gitignore_entry(&workspace_root)?;
|
||||
|
||||
if args.no_cargo_config {
|
||||
eprintln!(
|
||||
"Skipping ~/.cargo/config.toml update. Set {ENV_VAR}={} yourself.",
|
||||
absolute.display()
|
||||
);
|
||||
} else {
|
||||
update_cargo_config(&absolute)?;
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
eprintln!("Done. {ENV_VAR} -> {}", absolute.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_webrtc_sys_rev(workspace_root: &Path) -> Result<String> {
|
||||
let manifest_path = workspace_root.join("Cargo.toml");
|
||||
let manifest = Manifest::from_path(&manifest_path)
|
||||
.with_context(|| format!("parsing {}", manifest_path.display()))?;
|
||||
|
||||
let patch = manifest
|
||||
.patch
|
||||
.get("crates-io")
|
||||
.context("workspace Cargo.toml has no [patch.crates-io] section")?;
|
||||
let dep = patch
|
||||
.get("webrtc-sys")
|
||||
.context("[patch.crates-io] is missing webrtc-sys")?;
|
||||
let detail = dep
|
||||
.detail()
|
||||
.context("webrtc-sys patch entry is not a table")?;
|
||||
detail
|
||||
.git
|
||||
.as_ref()
|
||||
.context("webrtc-sys patch is missing a git source")?;
|
||||
detail
|
||||
.rev
|
||||
.clone()
|
||||
.context("webrtc-sys patch is missing a `rev`")
|
||||
}
|
||||
|
||||
fn fetch_webrtc_tag(rev: &str) -> Result<String> {
|
||||
let url = format!(
|
||||
"https://raw.githubusercontent.com/zed-industries/livekit-rust-sdks/{rev}/webrtc-sys/build/src/lib.rs"
|
||||
);
|
||||
let body = curl_text(&url).with_context(|| format!("fetching {url}"))?;
|
||||
|
||||
let re =
|
||||
Regex::new(r#"pub\s+const\s+WEBRTC_TAG\s*:\s*&str\s*=\s*"([^"]+)""#).expect("static regex");
|
||||
let captures = re
|
||||
.captures(&body)
|
||||
.with_context(|| format!("could not find WEBRTC_TAG in {url}"))?;
|
||||
Ok(captures[1].to_string())
|
||||
}
|
||||
|
||||
fn host_webrtc_triple() -> Result<String> {
|
||||
let os = match std::env::consts::OS {
|
||||
"macos" => "mac",
|
||||
"linux" => "linux",
|
||||
"windows" => "win",
|
||||
other => bail!("unsupported host OS: {other}"),
|
||||
};
|
||||
let arch = match std::env::consts::ARCH {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "x64",
|
||||
other => bail!("unsupported host arch: {other}"),
|
||||
};
|
||||
Ok(format!("{os}-{arch}-release"))
|
||||
}
|
||||
|
||||
fn download_and_extract(tag: &str, triple: &str, into: &Path) -> Result<()> {
|
||||
let url = format!(
|
||||
"https://github.com/zed-industries/livekit-rust-sdks/releases/download/{tag}/webrtc-{triple}.zip"
|
||||
);
|
||||
let zip_path = into.join(format!("webrtc-{triple}.zip"));
|
||||
|
||||
eprintln!("Downloading {url}");
|
||||
let status = Command::new("curl")
|
||||
.args(["-fL", "--retry", "3", "--progress-bar", "-o"])
|
||||
.arg(&zip_path)
|
||||
.arg(&url)
|
||||
.status()
|
||||
.context("running curl")?;
|
||||
if !status.success() {
|
||||
bail!("curl exited with {status} while downloading {url}");
|
||||
}
|
||||
|
||||
eprintln!("Extracting into {}", into.display());
|
||||
let status = Command::new("unzip")
|
||||
.arg("-q")
|
||||
.arg("-o")
|
||||
.arg(&zip_path)
|
||||
.arg("-d")
|
||||
.arg(into)
|
||||
.status()
|
||||
.context("running unzip")?;
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"unzip exited with {status} while extracting {}",
|
||||
zip_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
fs::remove_file(&zip_path).ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn curl_text(url: &str) -> Result<String> {
|
||||
let output = Command::new("curl")
|
||||
.args(["-fsSL", url])
|
||||
.output()
|
||||
.context("running curl")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"curl failed for {url} (exit {}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).trim(),
|
||||
);
|
||||
}
|
||||
String::from_utf8(output.stdout).context("curl returned non-UTF-8 body")
|
||||
}
|
||||
|
||||
fn ensure_gitignore_entry(workspace_root: &Path) -> Result<()> {
|
||||
let path = workspace_root.join(".gitignore");
|
||||
let existing =
|
||||
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
|
||||
if existing
|
||||
.lines()
|
||||
.any(|line| line.trim() == GITIGNORE_ENTRY || line.trim() == LOCAL_DIR_NAME)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let mut updated = existing;
|
||||
if !updated.ends_with('\n') {
|
||||
updated.push('\n');
|
||||
}
|
||||
updated.push_str(GITIGNORE_ENTRY);
|
||||
updated.push('\n');
|
||||
fs::write(&path, updated).with_context(|| format!("writing {}", path.display()))?;
|
||||
eprintln!("Added {GITIGNORE_ENTRY} to .gitignore");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_cargo_config(webrtc_path: &Path) -> Result<()> {
|
||||
let home = std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.context("could not determine home directory")?;
|
||||
let config_path = PathBuf::from(home).join(".cargo").join("config.toml");
|
||||
if config_path.exists() {
|
||||
bail!(
|
||||
"{} already exists; refusing to modify it. \
|
||||
Add `[env]\\n{ENV_VAR} = \"{}\"` yourself, \
|
||||
or re-run with --no-cargo-config.",
|
||||
config_path.display(),
|
||||
webrtc_path.display(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parent) = config_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let mut doc = DocumentMut::new();
|
||||
let mut env_table = Table::new();
|
||||
env_table.set_implicit(false);
|
||||
let path_str = webrtc_path
|
||||
.to_str()
|
||||
.context("webrtc path is not valid UTF-8")?;
|
||||
env_table.insert(ENV_VAR, value(path_str));
|
||||
doc.insert("env", Item::Table(env_table));
|
||||
|
||||
fs::write(&config_path, doc.to_string())
|
||||
.with_context(|| format!("writing {}", config_path.display()))?;
|
||||
eprintln!("Wrote {} with {ENV_VAR}={path_str}", config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
338
tooling/xtask/src/tasks/web_examples.rs
Normal file
338
tooling/xtask/src/tasks/web_examples.rs
Normal 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
tooling/xtask/src/tasks/workflow_checks.rs
Normal file
118
tooling/xtask/src/tasks/workflow_checks.rs
Normal 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(()),
|
||||
}
|
||||
}
|
||||
124
tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs
Normal file
124
tooling/xtask/src/tasks/workflow_checks/check_run_patterns.rs
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
257
tooling/xtask/src/tasks/workflows.rs
Normal file
257
tooling/xtask/src/tasks/workflows.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use gh_workflow::Workflow;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use crate::tasks::workflow_checks::{self};
|
||||
|
||||
mod after_release;
|
||||
mod autofix_pr;
|
||||
mod bump_patch_version;
|
||||
mod bump_zed_version;
|
||||
mod cherry_pick;
|
||||
mod compare_perf;
|
||||
mod compliance_check;
|
||||
mod danger;
|
||||
mod deploy_collab;
|
||||
mod deploy_docs;
|
||||
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 {
|
||||
const PREAMBLE: &str = "# Generated from xtask::workflows::";
|
||||
|
||||
fn disclaimer(&self, workflow_name: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"{preamble}{workflow_name}{external_disclaimer}\n",
|
||||
"# Rebuild with `cargo xtask workflows`.",
|
||||
),
|
||||
preamble = Self::PREAMBLE,
|
||||
workflow_name = workflow_name,
|
||||
external_disclaimer = (*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"),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_generated_workflows() -> Result<()> {
|
||||
for workflow_type in Self::iter() {
|
||||
for path in fs::read_dir(workflow_type.folder_path())? {
|
||||
let entry = path?;
|
||||
if !entry.file_type().is_ok_and(|file_type| file_type.is_file()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
if fs::read_to_string(&path)
|
||||
.is_ok_and(|content| content.starts_with(Self::PREAMBLE))
|
||||
{
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// Remove all previously generated workflows to ensure these do not become stale.
|
||||
WorkflowType::remove_generated_workflows()?;
|
||||
|
||||
let workflows = [
|
||||
WorkflowFile::zed(after_release::after_release),
|
||||
WorkflowFile::zed(autofix_pr::autofix_pr),
|
||||
WorkflowFile::zed(bump_patch_version::bump_patch_version),
|
||||
WorkflowFile::zed(bump_zed_version::bump_zed_version),
|
||||
WorkflowFile::zed(cherry_pick::cherry_pick),
|
||||
WorkflowFile::zed(compare_perf::compare_perf),
|
||||
WorkflowFile::zed(compliance_check::compliance_check),
|
||||
WorkflowFile::zed(danger::danger),
|
||||
WorkflowFile::zed(deploy_collab::deploy_collab),
|
||||
WorkflowFile::zed(deploy_docs::deploy_docs),
|
||||
WorkflowFile::zed(deploy_docs::deploy_nightly_docs),
|
||||
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_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())
|
||||
}
|
||||
195
tooling/xtask/src/tasks/workflows/after_release.rs
Normal file
195
tooling/xtask/src/tasks/workflows/after_release.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use gh_workflow::*;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
deploy_docs::deploy_docs_workflow_call,
|
||||
release::{self, notify_on_failure},
|
||||
runners,
|
||||
steps::{CommonJobConditions, NamedJob, checkout_repo, dependant_job, named},
|
||||
vars::{self, StepOutput, WorkflowInput},
|
||||
};
|
||||
|
||||
const TAG_NAME_ENV: &str = "${{ github.event.release.tag_name || inputs.tag_name }}";
|
||||
const IS_PRERELEASE_ENV: &str = "${{ github.event.release.prerelease || inputs.prerelease }}";
|
||||
const TAG_NAME: &str = "${{ env.TAG_NAME }}";
|
||||
const RELEASE_BODY: &str = "${{ github.event.release.body || inputs.body }}";
|
||||
const DOCS_CHANNEL: &str =
|
||||
"${{ (github.event.release.prerelease || inputs.prerelease) && 'preview' || 'stable' }}";
|
||||
|
||||
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 deploy_docs = deploy_docs_workflow_call(DOCS_CHANNEL, TAG_NAME_ENV);
|
||||
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 = {
|
||||
let notify_on_failure = notify_on_failure(&[
|
||||
&refresh_zed_dev,
|
||||
&post_to_discord,
|
||||
&publish_winget,
|
||||
&create_sentry_release,
|
||||
]);
|
||||
NamedJob {
|
||||
name: notify_on_failure.name,
|
||||
job: notify_on_failure.job.add_need(deploy_docs.name.clone()),
|
||||
}
|
||||
};
|
||||
|
||||
named::workflow()
|
||||
.add_env(("TAG_NAME", TAG_NAME_ENV))
|
||||
.add_env(("IS_PRERELEASE", IS_PRERELEASE_ENV))
|
||||
.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(deploy_docs.name, deploy_docs.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("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(
|
||||
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 = r#"if ($env: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)
|
||||
}
|
||||
177
tooling/xtask/src/tasks/workflows/autofix_pr.rs
Normal file
177
tooling/xtask/src/tasks/workflows/autofix_pr.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use gh_workflow::*;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{self, FluentBuilder, NamedJob, RepositoryTarget, TokenPermissions, named, use_clang},
|
||||
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> {
|
||||
steps::taiki_install_action("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 --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 --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(use_clang(
|
||||
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()
|
||||
.for_repository(RepositoryTarget::current())
|
||||
.with_permissions([
|
||||
(TokenPermissions::Contents, Level::Write),
|
||||
(TokenPermissions::Workflows, Level::Write),
|
||||
])
|
||||
.into();
|
||||
|
||||
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)),
|
||||
)
|
||||
}
|
||||
96
tooling/xtask/src/tasks/workflows/bump_patch_version.rs
Normal file
96
tooling/xtask/src/tasks/workflows/bump_patch_version.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use gh_workflow::*;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{self, CheckoutStep, CommonJobConditions, 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 read_channel() -> Step<Run> {
|
||||
named::bash(indoc::indoc! {r#"
|
||||
channel="$(cat crates/zed/RELEASE_CHANNEL)"
|
||||
|
||||
tag_suffix=""
|
||||
case $channel in
|
||||
stable)
|
||||
;;
|
||||
preview)
|
||||
tag_suffix="-pre"
|
||||
;;
|
||||
*)
|
||||
echo "::error::must be run on a stable or preview release branch"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
version=$(script/get-crate-version zed)
|
||||
|
||||
{
|
||||
echo "channel=$channel"
|
||||
echo "version=$version"
|
||||
echo "tag_suffix=$tag_suffix"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
"#})
|
||||
.id("channel")
|
||||
}
|
||||
|
||||
fn bump_version() -> Step<Run> {
|
||||
named::bash(indoc::indoc! {r#"
|
||||
version="$(cargo set-version -p zed --bump patch 2>&1 | sed 's/.* //')"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
"#})
|
||||
.id("bump-version")
|
||||
}
|
||||
|
||||
let (authenticate, token) = steps::authenticate_as_zippy().into();
|
||||
let channel_step = read_channel();
|
||||
let tag_suffix = StepOutput::new(&channel_step, "tag_suffix");
|
||||
let bump_version_step = bump_version();
|
||||
let version = StepOutput::new(&bump_version_step, "version");
|
||||
let commit_step: Step<Use> = steps::BotCommitStep::new(
|
||||
format!("Bump to {version} for @${{{{ github.actor }}}}"),
|
||||
branch,
|
||||
&token,
|
||||
)
|
||||
.into();
|
||||
let commit_sha = StepOutput::new_unchecked(&commit_step, "commit");
|
||||
|
||||
named::job(
|
||||
Job::default()
|
||||
.with_repository_owner_guard()
|
||||
.runs_on(runners::LINUX_DEFAULT)
|
||||
.add_step(authenticate)
|
||||
.add_step(checkout_branch(branch, &token))
|
||||
.add_step(channel_step)
|
||||
.add_step(steps::install_cargo_edit())
|
||||
.add_step(bump_version_step)
|
||||
.add_step(commit_step)
|
||||
.add_step(steps::create_ref(
|
||||
steps::GitRef::tag(format!("v{version}{tag_suffix}")),
|
||||
&commit_sha,
|
||||
&token,
|
||||
)),
|
||||
)
|
||||
}
|
||||
270
tooling/xtask/src/tasks/workflows/bump_zed_version.rs
Normal file
270
tooling/xtask/src/tasks/workflows/bump_zed_version.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
use gh_workflow::*;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{self, named},
|
||||
vars::{self, StepOutput, WorkflowInput},
|
||||
};
|
||||
|
||||
pub fn bump_zed_version() -> Workflow {
|
||||
let target = WorkflowInput::string("target", Some("all".to_string()))
|
||||
.description("Which channels to bump: all, main, preview, or stable");
|
||||
|
||||
let (versions_job, outputs) = resolve_versions();
|
||||
|
||||
let bump_main_job = bump_main(&target, &versions_job, &outputs);
|
||||
let preview_job = create_preview_branch(&target, &versions_job, &outputs);
|
||||
let stable_job = promote_to_stable(&target, &versions_job, &outputs);
|
||||
|
||||
named::workflow()
|
||||
.on(Event::default()
|
||||
.workflow_dispatch(WorkflowDispatch::default().add_input(target.name, target.input())))
|
||||
.add_job(versions_job.name, versions_job.job)
|
||||
.add_job(bump_main_job.name, bump_main_job.job)
|
||||
.add_job(preview_job.name, preview_job.job)
|
||||
.add_job(stable_job.name, stable_job.job)
|
||||
}
|
||||
|
||||
struct ResolvedOutputs {
|
||||
next_version: vars::JobOutput,
|
||||
pr_branch: vars::JobOutput,
|
||||
preview_branch: vars::JobOutput,
|
||||
preview_tag: vars::JobOutput,
|
||||
stable_branch: vars::JobOutput,
|
||||
}
|
||||
|
||||
fn resolve_versions() -> (steps::NamedJob, ResolvedOutputs) {
|
||||
fn extract_versions() -> Step<Run> {
|
||||
named::bash(indoc::indoc! {r#"
|
||||
version=$(script/get-crate-version zed)
|
||||
major=$(echo "$version" | cut -d. -f1)
|
||||
minor=$(echo "$version" | cut -d. -f2)
|
||||
|
||||
channel=$(cat crates/zed/RELEASE_CHANNEL)
|
||||
if [[ "$channel" != "dev" && "$channel" != "nightly" ]]; then
|
||||
echo "::error::release channel on main should be dev or nightly, found: $channel"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Next main version after bump
|
||||
next_version="${major}.$((minor + 1)).0"
|
||||
next_major=$(echo "$next_version" | cut -d. -f1)
|
||||
next_minor=$(echo "$next_version" | cut -d. -f2)
|
||||
pr_branch="bump-zed-to-v${next_major}.${next_minor}.0"
|
||||
|
||||
# New preview branch from current main
|
||||
preview_branch="v${major}.${minor}.x"
|
||||
preview_tag="v${version}-pre"
|
||||
|
||||
# Current preview to promote to stable — derive branch from released preview version
|
||||
released_preview=$(script/get-released-version preview)
|
||||
if [[ -z "$released_preview" ]]; then
|
||||
echo "::error::could not determine released preview version"
|
||||
exit 1
|
||||
fi
|
||||
stable_major=$(echo "$released_preview" | cut -d. -f1)
|
||||
stable_minor=$(echo "$released_preview" | cut -d. -f2)
|
||||
stable_branch="v${stable_major}.${stable_minor}.x"
|
||||
|
||||
# Final validation
|
||||
for var in next_version pr_branch preview_branch preview_tag stable_branch; do
|
||||
if [[ -z "${!var}" ]]; then
|
||||
echo "::error::failed to compute $var"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
{
|
||||
echo "next_version=$next_version"
|
||||
echo "pr_branch=$pr_branch"
|
||||
echo "preview_branch=$preview_branch"
|
||||
echo "preview_tag=$preview_tag"
|
||||
echo "stable_branch=$stable_branch"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved: next=$next_version preview=$preview_branch($preview_tag) stable=$stable_branch pr=$pr_branch"
|
||||
"#})
|
||||
.id("versions")
|
||||
}
|
||||
|
||||
let (authenticate, token) = steps::authenticate_as_zippy().into();
|
||||
let versions_step = extract_versions();
|
||||
let next_version = StepOutput::new(&versions_step, "next_version");
|
||||
let pr_branch = StepOutput::new(&versions_step, "pr_branch");
|
||||
let preview_branch = StepOutput::new(&versions_step, "preview_branch");
|
||||
let preview_tag = StepOutput::new(&versions_step, "preview_tag");
|
||||
let stable_branch = StepOutput::new(&versions_step, "stable_branch");
|
||||
|
||||
let job = named::job(
|
||||
Job::default()
|
||||
.cond(Expression::new(
|
||||
"github.repository_owner == 'zed-industries'",
|
||||
))
|
||||
.runs_on(runners::LINUX_XL)
|
||||
.add_step(authenticate)
|
||||
.add_step(steps::checkout_repo().with_token(&token).with_ref("main"))
|
||||
.add_step(versions_step)
|
||||
.outputs([
|
||||
(next_version.name.to_owned(), next_version.to_string()),
|
||||
(pr_branch.name.to_owned(), pr_branch.to_string()),
|
||||
(preview_branch.name.to_owned(), preview_branch.to_string()),
|
||||
(preview_tag.name.to_owned(), preview_tag.to_string()),
|
||||
(stable_branch.name.to_owned(), stable_branch.to_string()),
|
||||
]),
|
||||
);
|
||||
|
||||
let outputs = ResolvedOutputs {
|
||||
next_version: next_version.as_job_output(&job),
|
||||
pr_branch: pr_branch.as_job_output(&job),
|
||||
preview_branch: preview_branch.as_job_output(&job),
|
||||
preview_tag: preview_tag.as_job_output(&job),
|
||||
stable_branch: stable_branch.as_job_output(&job),
|
||||
};
|
||||
|
||||
(job, outputs)
|
||||
}
|
||||
|
||||
fn bump_main(
|
||||
target: &WorkflowInput,
|
||||
versions_job: &steps::NamedJob,
|
||||
outputs: &ResolvedOutputs,
|
||||
) -> steps::NamedJob {
|
||||
fn bump_version() -> Step<Run> {
|
||||
named::bash("cargo set-version -p zed --bump minor")
|
||||
}
|
||||
|
||||
let (authenticate, token) = steps::authenticate_as_zippy().into();
|
||||
|
||||
named::job(
|
||||
Job::default()
|
||||
.cond(Expression::new(format!(
|
||||
"{} == 'all' || {} == 'main'",
|
||||
target.expr(),
|
||||
target.expr(),
|
||||
)))
|
||||
.needs(vec![versions_job.name.clone()])
|
||||
.runs_on(runners::LINUX_DEFAULT)
|
||||
.add_step(authenticate)
|
||||
.add_step(steps::checkout_repo().with_token(&token).with_ref("main"))
|
||||
.add_step(steps::install_cargo_edit())
|
||||
.add_step(bump_version())
|
||||
.add_step(steps::CreatePrStep::new(
|
||||
format!("Bump Zed to v{}", outputs.next_version),
|
||||
&outputs.pr_branch,
|
||||
&token,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn create_preview_branch(
|
||||
target: &WorkflowInput,
|
||||
versions_job: &steps::NamedJob,
|
||||
outputs: &ResolvedOutputs,
|
||||
) -> steps::NamedJob {
|
||||
fn promote_to_preview() -> Step<Run> {
|
||||
named::bash("echo -n preview > crates/zed/RELEASE_CHANNEL")
|
||||
}
|
||||
|
||||
fn get_main_sha() -> Step<Run> {
|
||||
named::bash("echo \"main_sha=$(git rev-parse HEAD)\" >> \"$GITHUB_OUTPUT\"").id("main-sha")
|
||||
}
|
||||
|
||||
let (authenticate, token) = steps::authenticate_as_zippy().into();
|
||||
|
||||
let main_sha_step = get_main_sha();
|
||||
let main_sha = StepOutput::new(&main_sha_step, "main_sha");
|
||||
|
||||
let commit_step: Step<Use> = steps::BotCommitStep::new(
|
||||
format!(
|
||||
"{} preview for @${{{{ github.actor }}}}",
|
||||
outputs.preview_branch
|
||||
),
|
||||
&outputs.preview_branch,
|
||||
&token,
|
||||
)
|
||||
.with_files("crates/zed/RELEASE_CHANNEL")
|
||||
.into();
|
||||
let commit_sha = StepOutput::new_unchecked(&commit_step, "commit");
|
||||
|
||||
named::job(
|
||||
Job::default()
|
||||
.cond(Expression::new(format!(
|
||||
"{} == 'all' || {} == 'preview'",
|
||||
target.expr(),
|
||||
target.expr(),
|
||||
)))
|
||||
.needs(vec![versions_job.name.clone()])
|
||||
.runs_on(runners::LINUX_DEFAULT)
|
||||
.add_step(authenticate)
|
||||
.add_step(steps::checkout_repo().with_token(&token).with_ref("main"))
|
||||
.add_step(main_sha_step)
|
||||
.add_step(promote_to_preview())
|
||||
.add_step(steps::create_ref(
|
||||
steps::GitRef::branch(&outputs.preview_branch),
|
||||
&main_sha,
|
||||
&token,
|
||||
))
|
||||
.add_step(commit_step)
|
||||
.add_step(steps::create_ref(
|
||||
steps::GitRef::tag(&outputs.preview_tag),
|
||||
&commit_sha,
|
||||
&token,
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
fn promote_to_stable(
|
||||
target: &WorkflowInput,
|
||||
versions_job: &steps::NamedJob,
|
||||
outputs: &ResolvedOutputs,
|
||||
) -> steps::NamedJob {
|
||||
let (authenticate, token) = steps::authenticate_as_zippy().into();
|
||||
|
||||
let read_version_step = named::bash(indoc::indoc! {r#"
|
||||
stable_version=$(script/get-crate-version zed)
|
||||
{
|
||||
echo "stable_tag=v${stable_version}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
"#})
|
||||
.id("stable-info");
|
||||
let stable_tag = StepOutput::new(&read_version_step, "stable_tag");
|
||||
|
||||
let write_channel = named::bash("echo -n stable > crates/zed/RELEASE_CHANNEL");
|
||||
|
||||
let commit_step: Step<Use> = steps::BotCommitStep::new(
|
||||
format!(
|
||||
"{} stable for @${{{{ github.actor }}}}",
|
||||
outputs.stable_branch
|
||||
),
|
||||
&outputs.stable_branch,
|
||||
&token,
|
||||
)
|
||||
.with_files("crates/zed/RELEASE_CHANNEL")
|
||||
.into();
|
||||
let commit_sha = StepOutput::new_unchecked(&commit_step, "commit");
|
||||
|
||||
named::job(
|
||||
Job::default()
|
||||
.cond(Expression::new(format!(
|
||||
"{} == 'all' || {} == 'stable'",
|
||||
target.expr(),
|
||||
target.expr(),
|
||||
)))
|
||||
.needs(vec![versions_job.name.clone()])
|
||||
.runs_on(runners::LINUX_DEFAULT)
|
||||
.add_step(authenticate)
|
||||
.add_step(
|
||||
steps::checkout_repo()
|
||||
.with_token(&token)
|
||||
.with_ref(outputs.stable_branch.to_string()),
|
||||
)
|
||||
.add_step(read_version_step)
|
||||
.add_step(write_channel)
|
||||
.add_step(commit_step)
|
||||
.add_step(steps::create_ref(
|
||||
steps::GitRef::tag(&stable_tag),
|
||||
&commit_sha,
|
||||
&token,
|
||||
)),
|
||||
)
|
||||
}
|
||||
71
tooling/xtask/src/tasks/workflows/cherry_pick.rs
Normal file
71
tooling/xtask/src/tasks/workflows/cherry_pick.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use gh_workflow::*;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{self, NamedJob, RepositoryTarget, TokenPermissions, 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_AUTHOR_NAME", "zed-zippy[bot]"))
|
||||
.add_env((
|
||||
"GIT_AUTHOR_EMAIL",
|
||||
"<234243425+zed-zippy[bot]@users.noreply.github.com>",
|
||||
))
|
||||
.add_env(("GIT_COMMITTER_NAME", "zed-zippy[bot]"))
|
||||
.add_env((
|
||||
"GIT_COMMITTER_EMAIL",
|
||||
"<234243425+zed-zippy[bot]@users.noreply.github.com>",
|
||||
))
|
||||
.add_env(("GITHUB_TOKEN", token))
|
||||
}
|
||||
|
||||
let (authenticate, token) = steps::authenticate_as_zippy()
|
||||
.for_repository(RepositoryTarget::current())
|
||||
.with_permissions([
|
||||
(TokenPermissions::Contents, Level::Write),
|
||||
(TokenPermissions::Workflows, Level::Write),
|
||||
(TokenPermissions::PullRequests, Level::Write),
|
||||
])
|
||||
.into();
|
||||
|
||||
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)),
|
||||
)
|
||||
}
|
||||
73
tooling/xtask/src/tasks/workflows/compare_perf.rs
Normal file
73
tooling/xtask/src/tasks/workflows/compare_perf.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
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",
|
||||
"b4f2d5cb8597b15997c8ede873eb6185efc5f0ad", // 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)),
|
||||
)
|
||||
}
|
||||
52
tooling/xtask/src/tasks/workflows/compliance_check.rs
Normal file
52
tooling/xtask/src/tasks/workflows/compliance_check.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use gh_workflow::{Event, Job, Schedule, Workflow, WorkflowDispatch};
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
release::{ComplianceContext, add_compliance_steps},
|
||||
runners,
|
||||
steps::{self, CommonJobConditions, named},
|
||||
vars::StepOutput,
|
||||
};
|
||||
|
||||
pub fn compliance_check() -> Workflow {
|
||||
let check = scheduled_compliance_check();
|
||||
|
||||
named::workflow()
|
||||
.on(Event::default()
|
||||
.schedule([Schedule::new("30 17 * * 2")])
|
||||
.workflow_dispatch(WorkflowDispatch::default()))
|
||||
.add_env(("CARGO_TERM_COLOR", "always"))
|
||||
.add_job(check.name, check.job)
|
||||
}
|
||||
|
||||
fn scheduled_compliance_check() -> steps::NamedJob {
|
||||
let determine_version_step = named::bash(indoc::indoc! {r#"
|
||||
VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/zed/Cargo.toml | tr -d '[:space:]')
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Could not determine version from crates/zed/Cargo.toml"
|
||||
exit 1
|
||||
fi
|
||||
TAG="v${VERSION}-pre"
|
||||
echo "Checking compliance for $TAG"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
"#})
|
||||
.id("determine-version");
|
||||
|
||||
let tag_output = StepOutput::new(&determine_version_step, "tag");
|
||||
|
||||
let job = Job::default()
|
||||
.with_repository_owner_guard()
|
||||
.runs_on(runners::LINUX_SMALL)
|
||||
.add_step(steps::checkout_repo().with_full_history())
|
||||
.add_step(steps::cache_rust_dependencies_namespace())
|
||||
.add_step(determine_version_step);
|
||||
|
||||
named::job(
|
||||
add_compliance_steps(
|
||||
job,
|
||||
ComplianceContext::Scheduled {
|
||||
tag_source: tag_output,
|
||||
},
|
||||
)
|
||||
.0,
|
||||
)
|
||||
}
|
||||
56
tooling/xtask/src/tasks/workflows/danger.rs
Normal file
56
tooling/xtask/src/tasks/workflows/danger.rs
Normal file
@@ -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()),
|
||||
}
|
||||
}
|
||||
174
tooling/xtask/src/tasks/workflows/deploy_collab.rs
Normal file
174
tooling/xtask/src/tasks/workflows/deploy_collab.rs
Normal file
@@ -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()),
|
||||
)
|
||||
}
|
||||
360
tooling/xtask/src/tasks/workflows/deploy_docs.rs
Normal file
360
tooling/xtask/src/tasks/workflows/deploy_docs.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
use gh_workflow::{
|
||||
Event, Expression, Input, Job, Level, Permissions, Push, Run, Step, Use, UsesJob, Workflow,
|
||||
WorkflowCall, WorkflowCallSecret, WorkflowDispatch,
|
||||
};
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{self, CommonJobConditions, FluentBuilder as _, NamedJob, named, release_job},
|
||||
vars::{self, StepOutput, WorkflowInput},
|
||||
};
|
||||
|
||||
const BUILD_OUTPUT_DIR: &str = "target/deploy";
|
||||
|
||||
pub(crate) enum DocsChannel {
|
||||
Nightly,
|
||||
Preview,
|
||||
Stable,
|
||||
}
|
||||
|
||||
impl DocsChannel {
|
||||
pub(crate) fn site_url(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Nightly => "/docs/nightly/",
|
||||
Self::Preview => "/docs/preview/",
|
||||
Self::Stable => "/docs/",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn project_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Nightly => "docs-nightly",
|
||||
Self::Preview => "docs-preview",
|
||||
Self::Stable => "docs",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn channel_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Nightly => "nightly",
|
||||
Self::Preview => "preview",
|
||||
Self::Stable => "stable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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))
|
||||
}
|
||||
|
||||
pub(crate) fn install_mdbook() -> Step<Use> {
|
||||
named::uses(
|
||||
"peaceiris",
|
||||
"actions-mdbook",
|
||||
"ee69d230fe19748b7abf22df32acaa93833fad08", // v2
|
||||
)
|
||||
.with(("mdbook-version", "0.4.37"))
|
||||
}
|
||||
|
||||
pub(crate) fn build_docs_book(docs_channel: String, site_url: String) -> Step<Run> {
|
||||
named::bash(indoc::formatdoc! {r#"
|
||||
mkdir -p {BUILD_OUTPUT_DIR}
|
||||
mdbook build ./docs --dest-dir=../{BUILD_OUTPUT_DIR}/docs/
|
||||
"#})
|
||||
.add_env(("DOCS_CHANNEL", docs_channel))
|
||||
.add_env(("MDBOOK_BOOK__SITE_URL", site_url))
|
||||
}
|
||||
|
||||
fn docs_build_steps(
|
||||
job: Job,
|
||||
checkout_ref: Option<String>,
|
||||
docs_channel: impl Into<String>,
|
||||
site_url: impl Into<String>,
|
||||
) -> Job {
|
||||
let docs_channel = docs_channel.into();
|
||||
let site_url = site_url.into();
|
||||
|
||||
steps::use_clang(
|
||||
job.add_env(("DOCS_AMPLITUDE_API_KEY", vars::DOCS_AMPLITUDE_API_KEY))
|
||||
.add_step(
|
||||
steps::checkout_repo().when_some(checkout_ref, |step, checkout_ref| {
|
||||
step.with_ref(checkout_ref)
|
||||
}),
|
||||
)
|
||||
.runs_on(runners::LINUX_XL)
|
||||
.add_step(steps::setup_cargo_config(runners::Platform::Linux))
|
||||
.add_step(steps::cache_rust_dependencies_namespace())
|
||||
.map(steps::install_linux_dependencies)
|
||||
.add_step(steps::script("./script/generate-action-metadata"))
|
||||
.add_step(lychee_link_check("./docs/src/**/*"))
|
||||
.add_step(install_mdbook())
|
||||
.add_step(build_docs_book(docs_channel, site_url))
|
||||
.add_step(lychee_link_check(&format!("{BUILD_OUTPUT_DIR}/docs"))),
|
||||
)
|
||||
}
|
||||
|
||||
fn docs_deploy_steps(job: Job, project_name: &StepOutput) -> Job {
|
||||
fn deploy_to_cf_pages(project_name: &StepOutput) -> Step<Use> {
|
||||
named::uses(
|
||||
"cloudflare",
|
||||
"wrangler-action",
|
||||
"da0e0dfe58b7a431659754fdf3f186c529afbe65",
|
||||
) // v3
|
||||
.add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
|
||||
.add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
|
||||
.add_with((
|
||||
"command",
|
||||
format!(
|
||||
"pages deploy {BUILD_OUTPUT_DIR} --project-name=${{{{ {} }}}} --branch main",
|
||||
project_name.expr()
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn upload_install_script() -> Step<Use> {
|
||||
named::uses(
|
||||
"cloudflare",
|
||||
"wrangler-action",
|
||||
"da0e0dfe58b7a431659754fdf3f186c529afbe65",
|
||||
) // v3
|
||||
.add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
|
||||
.add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
|
||||
.add_with((
|
||||
"command",
|
||||
"r2 object put -f script/install.sh zed-open-source-website-assets/install.sh",
|
||||
))
|
||||
}
|
||||
|
||||
fn deploy_docs_worker() -> Step<Use> {
|
||||
named::uses(
|
||||
"cloudflare",
|
||||
"wrangler-action",
|
||||
"da0e0dfe58b7a431659754fdf3f186c529afbe65",
|
||||
) // v3
|
||||
.add_with(("apiToken", vars::CLOUDFLARE_API_TOKEN))
|
||||
.add_with(("accountId", vars::CLOUDFLARE_ACCOUNT_ID))
|
||||
.add_with(("command", "deploy .cloudflare/docs-proxy/src/worker.js"))
|
||||
}
|
||||
|
||||
fn upload_wrangler_logs() -> Step<Use> {
|
||||
named::uses(
|
||||
"actions",
|
||||
"upload-artifact",
|
||||
"ea165f8d65b6e75b540449e92b4886f43607fa02",
|
||||
) // v4
|
||||
.if_condition(Expression::new("always()"))
|
||||
.add_with(("name", "wrangler_logs"))
|
||||
.add_with(("path", "/home/runner/.config/.wrangler/logs/"))
|
||||
}
|
||||
|
||||
job.add_step(deploy_to_cf_pages(project_name))
|
||||
.add_step(upload_install_script())
|
||||
.add_step(deploy_docs_worker())
|
||||
.add_step(upload_wrangler_logs())
|
||||
}
|
||||
|
||||
pub(crate) fn check_docs() -> NamedJob {
|
||||
NamedJob {
|
||||
name: "check_docs".to_owned(),
|
||||
job: docs_build_steps(
|
||||
release_job(&[]),
|
||||
None,
|
||||
DocsChannel::Stable.channel_name(),
|
||||
DocsChannel::Stable.site_url(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_channel_step(
|
||||
channel_expr: impl Into<String>,
|
||||
) -> (Step<Run>, StepOutput, StepOutput, StepOutput) {
|
||||
let step = Step::new("deploy_docs::resolve_channel_step").run(format!(
|
||||
indoc::indoc! {r#"
|
||||
if [ -z "$CHANNEL" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
CHANNEL="nightly"
|
||||
else
|
||||
echo "::error::channel input is required when ref is not main."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$CHANNEL" in
|
||||
"nightly")
|
||||
SITE_URL="{nightly_site_url}"
|
||||
PROJECT_NAME="{nightly_project_name}"
|
||||
;;
|
||||
"preview")
|
||||
SITE_URL="{preview_site_url}"
|
||||
PROJECT_NAME="{preview_project_name}"
|
||||
;;
|
||||
"stable")
|
||||
SITE_URL="{stable_site_url}"
|
||||
PROJECT_NAME="{stable_project_name}"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Invalid docs channel '$CHANNEL'. Expected one of: nightly, preview, stable."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
{{
|
||||
echo "channel=$CHANNEL"
|
||||
echo "site_url=$SITE_URL"
|
||||
echo "project_name=$PROJECT_NAME"
|
||||
}} >> "$GITHUB_OUTPUT"
|
||||
"#},
|
||||
nightly_site_url = DocsChannel::Nightly.site_url(),
|
||||
preview_site_url = DocsChannel::Preview.site_url(),
|
||||
stable_site_url = DocsChannel::Stable.site_url(),
|
||||
nightly_project_name = DocsChannel::Nightly.project_name(),
|
||||
preview_project_name = DocsChannel::Preview.project_name(),
|
||||
stable_project_name = DocsChannel::Stable.project_name(),
|
||||
))
|
||||
.id("resolve-channel")
|
||||
.add_env(("CHANNEL", channel_expr.into()));
|
||||
|
||||
let channel = StepOutput::new(&step, "channel");
|
||||
let site_url = StepOutput::new(&step, "site_url");
|
||||
let project_name = StepOutput::new(&step, "project_name");
|
||||
(step, channel, site_url, project_name)
|
||||
}
|
||||
|
||||
fn docs_job(channel_expr: impl Into<String>, checkout_ref: Option<String>) -> NamedJob {
|
||||
let (resolve_step, channel, site_url, project_name) = resolve_channel_step(channel_expr);
|
||||
|
||||
NamedJob {
|
||||
name: "deploy_docs".to_owned(),
|
||||
job: docs_deploy_steps(
|
||||
docs_build_steps(
|
||||
release_job(&[])
|
||||
.cond(Expression::new(
|
||||
"github.repository_owner == 'zed-industries'",
|
||||
))
|
||||
.name("Build and Deploy Docs")
|
||||
.add_step(resolve_step),
|
||||
checkout_ref,
|
||||
channel.to_string(),
|
||||
site_url.to_string(),
|
||||
),
|
||||
&project_name,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deploy_docs_workflow_call(
|
||||
channel: impl Into<String>,
|
||||
checkout_ref: impl Into<String>,
|
||||
) -> NamedJob<UsesJob> {
|
||||
let job = Job::default()
|
||||
.with_repository_owner_guard()
|
||||
.permissions(Permissions::default().contents(Level::Read))
|
||||
.uses(
|
||||
"zed-industries",
|
||||
"zed",
|
||||
".github/workflows/deploy_docs.yml",
|
||||
"main",
|
||||
)
|
||||
.with(
|
||||
Input::default()
|
||||
.add("channel", channel.into())
|
||||
.add("checkout_ref", checkout_ref.into()),
|
||||
)
|
||||
.secrets(indexmap::IndexMap::from([
|
||||
(
|
||||
"DOCS_AMPLITUDE_API_KEY".to_owned(),
|
||||
vars::DOCS_AMPLITUDE_API_KEY.to_owned(),
|
||||
),
|
||||
(
|
||||
"CLOUDFLARE_API_TOKEN".to_owned(),
|
||||
vars::CLOUDFLARE_API_TOKEN.to_owned(),
|
||||
),
|
||||
(
|
||||
"CLOUDFLARE_ACCOUNT_ID".to_owned(),
|
||||
vars::CLOUDFLARE_ACCOUNT_ID.to_owned(),
|
||||
),
|
||||
]));
|
||||
|
||||
NamedJob {
|
||||
name: "deploy_docs".to_owned(),
|
||||
job,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn deploy_docs_job(
|
||||
channel_input: &WorkflowInput,
|
||||
checkout_ref_input: &WorkflowInput,
|
||||
) -> NamedJob {
|
||||
docs_job(
|
||||
channel_input.to_string(),
|
||||
Some(format!(
|
||||
"${{{{ {} != '' && {} || github.sha }}}}",
|
||||
checkout_ref_input.expr(),
|
||||
checkout_ref_input.expr()
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn deploy_docs() -> Workflow {
|
||||
let channel = WorkflowInput::string("channel", Some(String::new()))
|
||||
.description("Docs channel to deploy: nightly, preview, or stable");
|
||||
let checkout_ref = WorkflowInput::string("checkout_ref", Some(String::new()))
|
||||
.description("Git ref to checkout and deploy. Defaults to event SHA when omitted.");
|
||||
let deploy_docs = deploy_docs_job(&channel, &checkout_ref);
|
||||
|
||||
named::workflow()
|
||||
.add_event(
|
||||
Event::default().workflow_dispatch(
|
||||
WorkflowDispatch::default()
|
||||
.add_input(channel.name, channel.input())
|
||||
.add_input(checkout_ref.name, checkout_ref.input()),
|
||||
),
|
||||
)
|
||||
.add_event(
|
||||
Event::default().workflow_call(
|
||||
WorkflowCall::default()
|
||||
.add_input(channel.name, channel.call_input())
|
||||
.add_input(checkout_ref.name, checkout_ref.call_input())
|
||||
.secrets([
|
||||
(
|
||||
"DOCS_AMPLITUDE_API_KEY".to_owned(),
|
||||
WorkflowCallSecret {
|
||||
description: "DOCS_AMPLITUDE_API_KEY".to_owned(),
|
||||
required: true,
|
||||
},
|
||||
),
|
||||
(
|
||||
"CLOUDFLARE_API_TOKEN".to_owned(),
|
||||
WorkflowCallSecret {
|
||||
description: "CLOUDFLARE_API_TOKEN".to_owned(),
|
||||
required: true,
|
||||
},
|
||||
),
|
||||
(
|
||||
"CLOUDFLARE_ACCOUNT_ID".to_owned(),
|
||||
WorkflowCallSecret {
|
||||
description: "CLOUDFLARE_ACCOUNT_ID".to_owned(),
|
||||
required: true,
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.add_job(deploy_docs.name, deploy_docs.job)
|
||||
}
|
||||
|
||||
pub(crate) fn deploy_nightly_docs() -> Workflow {
|
||||
let deploy_docs = deploy_docs_workflow_call("nightly", "${{ github.sha }}");
|
||||
|
||||
named::workflow()
|
||||
.name("deploy_nightly_docs")
|
||||
.add_event(Event::default().push(Push::default().add_branch("main")))
|
||||
.add_job(deploy_docs.name, deploy_docs.job)
|
||||
}
|
||||
113
tooling/xtask/src/tasks/workflows/extension_auto_bump.rs
Normal file
113
tooling/xtask/src/tasks/workflows/extension_auto_bump.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
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/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_newly_added = indoc! {r#"
|
||||
# Filter out newly added extensions
|
||||
FILTERED="[]"
|
||||
for ext in $(echo "$EXTENSIONS_JSON" | jq -r '.[]'); do
|
||||
if git show HEAD~1:"$ext/extension.toml" >/dev/null 2>&1; 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_newly_added,
|
||||
);
|
||||
|
||||
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)
|
||||
}
|
||||
481
tooling/xtask/src/tasks/workflows/extension_bump.rs
Normal file
481
tooling/xtask/src/tasks/workflows/extension_bump.rs
Normal file
@@ -0,0 +1,481 @@
|
||||
use gh_workflow::*;
|
||||
use indoc::{formatdoc, indoc};
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
extension_tests::{self},
|
||||
runners,
|
||||
steps::{
|
||||
self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob,
|
||||
RepositoryTarget, cache_rust_dependencies_namespace, checkout_repo, dependant_job,
|
||||
generate_token, 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,
|
||||
¤t_version,
|
||||
&bump_type,
|
||||
&version_changed,
|
||||
&force_bump,
|
||||
&app_id,
|
||||
&app_secret,
|
||||
);
|
||||
let (create_label, tag) = create_version_label(
|
||||
&dependencies,
|
||||
&version_changed,
|
||||
¤t_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()).into();
|
||||
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",
|
||||
"f28e40c7f34bde8b3046d885e986cb6290c5673b", // 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()).into();
|
||||
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)
|
||||
}
|
||||
|
||||
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> {
|
||||
steps::CreatePrStep::new(title.to_string(), branch_name, &generated_token)
|
||||
.with_body(body)
|
||||
.into()
|
||||
}
|
||||
|
||||
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())
|
||||
.for_repository(extension_registry)
|
||||
.into();
|
||||
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",
|
||||
"f28e40c7f34bde8b3046d885e986cb6290c5673b", // 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)
|
||||
}
|
||||
204
tooling/xtask/src/tasks/workflows/extension_tests.rs
Normal file
204
tooling/xtask/src/tasks/workflows/extension_tests.rs
Normal file
@@ -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 = "1fa7f1a3ec28ea1eae6db2e937d7a538fb10c0c7";
|
||||
|
||||
// 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.and_always().then(check_rust()),
|
||||
should_check_extension.and_always().then(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 }}"))
|
||||
}
|
||||
389
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs
Normal file
389
tooling/xtask/src/tasks/workflows/extension_workflow_rollout.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
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::TokenPermissions;
|
||||
use crate::tasks::workflows::steps::cache_rust_dependencies_namespace;
|
||||
use crate::tasks::workflows::vars::JobOutput;
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{
|
||||
self, DEFAULT_REPOSITORY_OWNER_GUARD, NamedJob, RepositoryTarget, generate_token, 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,
|
||||
&filter_repos_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", "f28e40c7f34bde8b3046d885e986cb6290c5673b")
|
||||
.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,
|
||||
filter_repos_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,
|
||||
filter_repos_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}
|
||||
"#,
|
||||
};
|
||||
|
||||
let pr_step: Step<Use> = steps::CreatePrStep::new(title, "update-workflows", token)
|
||||
.with_body(body)
|
||||
.with_path("extension")
|
||||
// Save my inbox from exploding on rollout
|
||||
.with_assignee(format!(
|
||||
"${{{{ {repos_expr} != '' && github.actor || '' }}}}",
|
||||
repos_expr = filter_repos_input.expr()
|
||||
))
|
||||
.into();
|
||||
pr_step.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)
|
||||
.for_repository(RepositoryTarget::new(
|
||||
"zed-extensions",
|
||||
&["${{ matrix.repo }}"],
|
||||
))
|
||||
.with_permissions([
|
||||
(TokenPermissions::PullRequests, Level::Write),
|
||||
(TokenPermissions::Contents, Level::Write),
|
||||
(TokenPermissions::Workflows, Level::Write),
|
||||
])
|
||||
.into();
|
||||
|
||||
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, filter_repos_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)
|
||||
.for_repository(RepositoryTarget::current())
|
||||
.with_permissions([(TokenPermissions::Contents, Level::Write)])
|
||||
.into();
|
||||
|
||||
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)
|
||||
}
|
||||
23
tooling/xtask/src/tasks/workflows/extensions.rs
Normal file
23
tooling/xtask/src/tasks/workflows/extensions.rs
Normal file
@@ -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(),
|
||||
),
|
||||
]))
|
||||
}
|
||||
}
|
||||
108
tooling/xtask/src/tasks/workflows/extensions/bump_version.rs
Normal file
108
tooling/xtask/src/tasks/workflows/extensions/bump_version.rs
Normal file
@@ -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)
|
||||
}
|
||||
30
tooling/xtask/src/tasks/workflows/extensions/run_tests.rs
Normal file
30
tooling/xtask/src/tasks/workflows/extensions/run_tests.rs
Normal file
@@ -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)
|
||||
}
|
||||
125
tooling/xtask/src/tasks/workflows/nix_build.rs
Normal file
125
tooling/xtask/src/tasks/workflows/nix_build.rs
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
164
tooling/xtask/src/tasks/workflows/publish_extension_cli.rs
Normal file
164
tooling/xtask/src/tasks/workflows/publish_extension_cli.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use gh_workflow::*;
|
||||
use indoc::indoc;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
runners,
|
||||
steps::{self, CommonJobConditions, NamedJob, RepositoryTarget, generate_token, 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_DEFAULT)
|
||||
.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).into();
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
steps::CreatePrStep::new(title, "update-extension-cli-sha", generated_token)
|
||||
.with_body(indoc::indoc! {r#"
|
||||
This PR bumps the extension CLI version used in the extension workflows to `${{ github.sha }}`.
|
||||
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
"#})
|
||||
.into()
|
||||
}
|
||||
|
||||
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)
|
||||
.for_repository(extensions_repo)
|
||||
.into();
|
||||
|
||||
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);
|
||||
|
||||
steps::CreatePrStep::new(title, "update-extension-cli-sha", generated_token)
|
||||
.with_body(indoc::indoc! {r#"
|
||||
This PR bumps the extension CLI version to https://github.com/zed-industries/zed/commit/${{ github.sha }}.
|
||||
"#})
|
||||
.with_labels("allow-no-extension")
|
||||
.into()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
714
tooling/xtask/src/tasks/workflows/release.rs
Normal file
714
tooling/xtask/src/tasks/workflows/release.rs
Normal file
@@ -0,0 +1,714 @@
|
||||
use gh_workflow::{Event, Expression, Level, Push, Run, Step, Use, Workflow, ctx::Context};
|
||||
use indoc::formatdoc;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
run_bundling::{bundle_linux, bundle_mac, bundle_windows, upload_artifact},
|
||||
run_tests,
|
||||
runners::{self, Arch, Platform},
|
||||
steps::{self, FluentBuilder, NamedJob, TokenPermissions, dependant_job, named, release_job},
|
||||
vars::{self, JobOutput, 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 (non_blocking_compliance_run, job_output) = compliance_check();
|
||||
|
||||
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 release_compliance = release_compliance_check(
|
||||
&[&upload_release_assets, &non_blocking_compliance_run],
|
||||
job_output,
|
||||
);
|
||||
|
||||
let (auto_release_preview, auto_release_published) =
|
||||
auto_release_preview(&[&validate_release_assets, &release_compliance]);
|
||||
|
||||
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,
|
||||
&release_compliance,
|
||||
&auto_release_preview,
|
||||
&auto_release_published,
|
||||
&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)
|
||||
.add_job(
|
||||
non_blocking_compliance_run.name,
|
||||
non_blocking_compliance_run.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(release_compliance.name, release_compliance.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"))
|
||||
}
|
||||
|
||||
pub(crate) const COMPLIANCE_REPORT_PATH: &str = "compliance-report-${GITHUB_REF_NAME}.md";
|
||||
pub(crate) const COMPLIANCE_REPORT_ARTIFACT_PATH: &str =
|
||||
"compliance-report-${{ github.ref_name }}.md";
|
||||
pub(crate) const COMPLIANCE_STEP_ID: &str = "run-compliance-check";
|
||||
const NEEDS_REVIEW_PULLS_URL: &str = "https://github.com/zed-industries/zed/pulls?q=is%3Apr+is%3Aclosed+label%3A%22PR+state%3Aneeds+review%22";
|
||||
|
||||
pub(crate) enum ComplianceContext {
|
||||
Release { non_blocking_outcome: JobOutput },
|
||||
ReleaseNonBlocking,
|
||||
Scheduled { tag_source: StepOutput },
|
||||
}
|
||||
|
||||
impl ComplianceContext {
|
||||
fn tag_source(&self) -> Option<&StepOutput> {
|
||||
match self {
|
||||
ComplianceContext::Scheduled { tag_source } => Some(tag_source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_compliance_steps(
|
||||
job: gh_workflow::Job,
|
||||
context: ComplianceContext,
|
||||
) -> (gh_workflow::Job, StepOutput) {
|
||||
fn run_compliance_check(context: &ComplianceContext) -> (Step<Run>, StepOutput) {
|
||||
let job = named::bash(
|
||||
formatdoc! {r#"
|
||||
cargo xtask compliance version {target} --report-path "{COMPLIANCE_REPORT_PATH}"
|
||||
"#,
|
||||
target = if context.tag_source().is_some() { r#""$LATEST_TAG" --branch main"# } else { r#""$GITHUB_REF_NAME""# },
|
||||
}
|
||||
)
|
||||
.id(COMPLIANCE_STEP_ID)
|
||||
.add_env(("GITHUB_APP_ID", vars::ZED_ZIPPY_APP_ID))
|
||||
.add_env(("GITHUB_APP_KEY", vars::ZED_ZIPPY_APP_PRIVATE_KEY))
|
||||
.when_some(context.tag_source(), |step, tag_source| {
|
||||
step.add_env(("LATEST_TAG", tag_source.to_string()))
|
||||
})
|
||||
.when(
|
||||
matches!(
|
||||
context,
|
||||
ComplianceContext::Scheduled { .. } | ComplianceContext::ReleaseNonBlocking
|
||||
),
|
||||
|step| step.continue_on_error(true),
|
||||
);
|
||||
|
||||
let result = StepOutput::new_unchecked(&job, "outcome");
|
||||
(job, result)
|
||||
}
|
||||
|
||||
let upload_step = upload_artifact(COMPLIANCE_REPORT_ARTIFACT_PATH)
|
||||
.if_condition(Expression::new("always()"))
|
||||
.when(
|
||||
matches!(context, ComplianceContext::Release { .. }),
|
||||
|step| step.add_with(("overwrite", true)),
|
||||
);
|
||||
|
||||
let (success_prefix, failure_prefix) = match context {
|
||||
ComplianceContext::Release { .. } => {
|
||||
("✅ Compliance check passed", "❌ Compliance check failed")
|
||||
}
|
||||
ComplianceContext::ReleaseNonBlocking => (
|
||||
"✅ Compliance check passed",
|
||||
"❌ Preliminary compliance check failed (but this can still be fixed while the builds are running!)",
|
||||
),
|
||||
ComplianceContext::Scheduled { .. } => (
|
||||
"✅ Scheduled compliance check passed",
|
||||
"⚠️ Scheduled compliance check failed",
|
||||
),
|
||||
};
|
||||
|
||||
let script = formatdoc! {r#"
|
||||
if [ "$COMPLIANCE_OUTCOME" == "success" ]; then
|
||||
STATUS="{success_prefix} for $COMPLIANCE_TAG"
|
||||
MESSAGE=$(printf "%s\n\nReport: %s" "$STATUS" "$ARTIFACT_URL")
|
||||
else
|
||||
STATUS="{failure_prefix} for $COMPLIANCE_TAG"
|
||||
MESSAGE=$(printf "%s\n\nReport: %s\nPRs needing review: %s" "$STATUS" "$ARTIFACT_URL" "{NEEDS_REVIEW_PULLS_URL}")
|
||||
fi
|
||||
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data "$(jq -n --arg text "$MESSAGE" '{{"text": $text}}')" \
|
||||
"$SLACK_WEBHOOK"
|
||||
"#,
|
||||
};
|
||||
|
||||
let notification_step = Step::new("send_compliance_slack_notification")
|
||||
.run(&script)
|
||||
.if_condition(match &context {
|
||||
ComplianceContext::Release {
|
||||
non_blocking_outcome,
|
||||
} => Expression::new(format!(
|
||||
"${{{{ failure() || {prior_outcome} != 'success' }}}}",
|
||||
prior_outcome = non_blocking_outcome.expr()
|
||||
)),
|
||||
ComplianceContext::Scheduled { .. } | ComplianceContext::ReleaseNonBlocking => {
|
||||
Expression::new("${{ always() }}")
|
||||
}
|
||||
})
|
||||
.add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
|
||||
.add_env((
|
||||
"COMPLIANCE_OUTCOME",
|
||||
format!("${{{{ steps.{COMPLIANCE_STEP_ID}.outcome }}}}"),
|
||||
))
|
||||
.add_env((
|
||||
"COMPLIANCE_TAG",
|
||||
match &context {
|
||||
ComplianceContext::Release { .. } | ComplianceContext::ReleaseNonBlocking => {
|
||||
Context::github().ref_name().to_string()
|
||||
}
|
||||
ComplianceContext::Scheduled { tag_source } => tag_source.to_string(),
|
||||
},
|
||||
))
|
||||
.add_env((
|
||||
"ARTIFACT_URL",
|
||||
format!("{CURRENT_ACTION_RUN_URL}#artifacts"),
|
||||
));
|
||||
|
||||
let (compliance_step, check_result) = run_compliance_check(&context);
|
||||
|
||||
(
|
||||
job.add_step(compliance_step)
|
||||
.add_step(upload_step)
|
||||
.add_step(notification_step)
|
||||
.when(
|
||||
matches!(context, ComplianceContext::ReleaseNonBlocking),
|
||||
|step| step.outputs([("outcome".to_string(), check_result.to_string())]),
|
||||
),
|
||||
check_result,
|
||||
)
|
||||
}
|
||||
|
||||
fn compliance_check() -> (NamedJob, JobOutput) {
|
||||
let job = release_job(&[])
|
||||
.runs_on(runners::LINUX_SMALL)
|
||||
.add_step(
|
||||
steps::checkout_repo()
|
||||
.with_full_history()
|
||||
.with_ref(Context::github().ref_()),
|
||||
)
|
||||
.add_step(steps::cache_rust_dependencies_namespace());
|
||||
|
||||
let (compliance_job, check_result) =
|
||||
add_compliance_steps(job, ComplianceContext::ReleaseNonBlocking);
|
||||
let compliance_job = named::job(compliance_job);
|
||||
let check_result = check_result.as_job_output(&compliance_job);
|
||||
|
||||
(compliance_job, check_result)
|
||||
}
|
||||
|
||||
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 release_compliance_check(deps: &[&NamedJob], non_blocking_outcome: JobOutput) -> NamedJob {
|
||||
let job = dependant_job(deps)
|
||||
.runs_on(runners::LINUX_LARGE)
|
||||
.add_step(
|
||||
steps::checkout_repo()
|
||||
.with_full_history()
|
||||
.with_ref(Context::github().ref_()),
|
||||
)
|
||||
.add_step(steps::cache_rust_dependencies_namespace());
|
||||
|
||||
let (job, _) = add_compliance_steps(
|
||||
job,
|
||||
ComplianceContext::Release {
|
||||
non_blocking_outcome,
|
||||
},
|
||||
);
|
||||
|
||||
named::job(job)
|
||||
}
|
||||
|
||||
fn auto_release_preview(deps: &[&NamedJob]) -> (NamedJob, JobOutput) {
|
||||
fn auto_release_preview(token: &StepOutput) -> Step<Run> {
|
||||
named::bash(indoc::indoc! {r#"
|
||||
tag="$GITHUB_REF_NAME"
|
||||
release_published=false
|
||||
|
||||
if [[ ! "$tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)-pre$ ]]; then
|
||||
echo "::error::expected preview release tag in the form vMAJOR.MINOR.PATCH-pre, got $tag"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
major="${BASH_REMATCH[1]}"
|
||||
minor="${BASH_REMATCH[2]}"
|
||||
should_release=true
|
||||
|
||||
released_preview="$(script/get-released-version preview)"
|
||||
if [[ -z "$released_preview" || "$released_preview" == "null" ]]; then
|
||||
echo "::error::could not determine released preview version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
released_preview_major="$(echo "$released_preview" | cut -d. -f1)"
|
||||
released_preview_minor="$(echo "$released_preview" | cut -d. -f2)"
|
||||
|
||||
if [[ "$released_preview_major" != "$major" || "$released_preview_minor" != "$minor" ]]; then
|
||||
should_release=false
|
||||
echo "Leaving $tag as a draft because it is the first preview release for v${major}.${minor}.x"
|
||||
fi
|
||||
|
||||
if [[ "$should_release" == "true" ]]; then
|
||||
gh release edit "$tag" --repo=zed-industries/zed --draft=false
|
||||
release_published=true
|
||||
fi
|
||||
|
||||
echo "release_published=$release_published" >> "$GITHUB_OUTPUT"
|
||||
"#})
|
||||
.id("auto-release-preview")
|
||||
.add_env(("GITHUB_TOKEN", token))
|
||||
}
|
||||
|
||||
let (authenticate, token) = steps::authenticate_as_zippy().into();
|
||||
let auto_release_preview_step = auto_release_preview(&token);
|
||||
let release_published = StepOutput::new(&auto_release_preview_step, "release_published");
|
||||
|
||||
let job = 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')"#
|
||||
)))
|
||||
.add_step(authenticate)
|
||||
.add_step(
|
||||
steps::checkout_repo()
|
||||
.with_token(&token)
|
||||
.with_ref(Context::github().ref_()),
|
||||
)
|
||||
.add_step(auto_release_preview_step)
|
||||
.outputs([(
|
||||
release_published.name.to_owned(),
|
||||
release_published.to_string(),
|
||||
)]),
|
||||
);
|
||||
let release_published = release_published.as_job_output(&job);
|
||||
(job, release_published)
|
||||
}
|
||||
|
||||
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(token: StepOutput) -> Step<Run> {
|
||||
named::bash("script/create-draft-release target/release-notes.md")
|
||||
.add_env(("GITHUB_TOKEN", token.to_string()))
|
||||
}
|
||||
|
||||
let (authenticate_step, token) = steps::authenticate_as_zippy()
|
||||
.with_permissions([(TokenPermissions::Contents, Level::Write)])
|
||||
.into();
|
||||
|
||||
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(authenticate_step)
|
||||
.add_step(
|
||||
steps::checkout_repo()
|
||||
.with_custom_fetch_depth(25)
|
||||
.with_ref(Context::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(token)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn push_release_update_notification(
|
||||
create_draft_release_job: &NamedJob,
|
||||
upload_assets_job: &NamedJob,
|
||||
validate_assets_job: &NamedJob,
|
||||
compliance_job: &NamedJob,
|
||||
auto_release_preview: &NamedJob,
|
||||
auto_release_published: &JobOutput,
|
||||
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),
|
||||
),
|
||||
(
|
||||
"COMPLIANCE_RESULT".into(),
|
||||
format!("${{{{ needs.{}.result }}}}", compliance_job.name),
|
||||
),
|
||||
(
|
||||
"AUTO_RELEASE_RESULT".into(),
|
||||
format!("${{{{ needs.{}.result }}}}", auto_release_preview.name),
|
||||
),
|
||||
(
|
||||
"AUTO_RELEASE_PUBLISHED".into(),
|
||||
auto_release_published.to_string(),
|
||||
),
|
||||
("RUN_URL".into(), CURRENT_ACTION_RUN_URL.to_string()),
|
||||
("TAG".into(), Context::github().ref_name().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#"
|
||||
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 [ "$COMPLIANCE_RESULT" == "failure" ]; then
|
||||
# We already notify within that job
|
||||
echo ""
|
||||
elif [ "$VALIDATE_RESULT" == "failure" ]; then
|
||||
echo "❌ Release validation failed for $TAG: missing assets: $RUN_URL"
|
||||
elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
|
||||
echo "❌ Auto release failed for $TAG: $RUN_URL"
|
||||
elif [ "$AUTO_RELEASE_RESULT" == "success" ] && [ "$AUTO_RELEASE_PUBLISHED" == "true" ]; then
|
||||
echo "✅ Release $TAG was auto-released successfully: $RELEASE_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,
|
||||
compliance_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)>,
|
||||
},
|
||||
}
|
||||
|
||||
enum MessageSource {
|
||||
String(String),
|
||||
StepOutput(StepOutput),
|
||||
}
|
||||
|
||||
impl MessageSource {
|
||||
fn message(self) -> String {
|
||||
match self {
|
||||
MessageSource::String(string) => string,
|
||||
MessageSource::StepOutput(output) => output.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_slack(message: MessageType) -> Vec<Step<Run>> {
|
||||
match message {
|
||||
MessageType::Static(message) => vec![send_slack_message(MessageSource::String(message))],
|
||||
MessageType::Evaluated { script, env } => {
|
||||
let (generate_step, generated_message) = generate_slack_message(script, env);
|
||||
|
||||
vec![
|
||||
generate_step,
|
||||
send_slack_message(MessageSource::StepOutput(generated_message)),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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_source: MessageSource) -> Step<Run> {
|
||||
named::bash(
|
||||
r#"curl -X POST -H 'Content-type: application/json' --data "$(jq -n --arg text "$SLACK_MESSAGE" '{"text": $text}')" "$SLACK_WEBHOOK""#
|
||||
)
|
||||
.map(|this| match &message_source {
|
||||
MessageSource::String(_) => this,
|
||||
MessageSource::StepOutput(output) => this
|
||||
.if_condition(Expression::new(format!("{message} != ''", message = output.expr()))),
|
||||
})
|
||||
.add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
|
||||
.add_env(("SLACK_MESSAGE", message_source.message()))
|
||||
}
|
||||
129
tooling/xtask/src/tasks/workflows/release_nightly.rs
Normal file
129
tooling/xtask/src/tasks/workflows/release_nightly.rs
Normal file
@@ -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()),
|
||||
}
|
||||
}
|
||||
124
tooling/xtask/src/tasks/workflows/run_agent_evals.rs
Normal file
124
tooling/xtask/src/tasks/workflows/run_agent_evals.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
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},
|
||||
vars::{self, WorkflowInput},
|
||||
};
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
226
tooling/xtask/src/tasks/workflows/run_bundling.rs
Normal file
226
tooling/xtask/src/tasks/workflows/run_bundling.rs
Normal file
@@ -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 }}"),
|
||||
}
|
||||
}
|
||||
779
tooling/xtask/src/tasks/workflows/run_tests.rs
Normal file
779
tooling/xtask/src/tasks/workflows/run_tests.rs
Normal file
@@ -0,0 +1,779 @@
|
||||
use gh_workflow::{
|
||||
Container, Event, Expression, Input, Job, Level, MergeGroup, 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::{
|
||||
deploy_docs,
|
||||
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
|
||||
.and_not_in_merge_queue()
|
||||
.then(clippy(Platform::Windows, None)),
|
||||
should_run_tests
|
||||
.and_always()
|
||||
.then(clippy(Platform::Linux, None)),
|
||||
should_run_tests
|
||||
.and_not_in_merge_queue()
|
||||
.then(clippy(Platform::Mac, None)),
|
||||
should_run_tests
|
||||
.and_not_in_merge_queue()
|
||||
.then(clippy(Platform::Mac, Some(Arch::X86_64))),
|
||||
should_run_tests
|
||||
.and_not_in_merge_queue()
|
||||
.then(run_platform_tests(Platform::Windows)),
|
||||
should_run_tests
|
||||
.and_not_in_merge_queue()
|
||||
.then(run_platform_tests(Platform::Linux)),
|
||||
should_run_tests
|
||||
.and_not_in_merge_queue()
|
||||
.then(run_platform_tests(Platform::Mac)),
|
||||
should_run_tests.and_not_in_merge_queue().then(doctests()),
|
||||
should_run_tests
|
||||
.and_not_in_merge_queue()
|
||||
.then(check_workspace_binaries()),
|
||||
should_run_tests
|
||||
.and_not_in_merge_queue()
|
||||
.then(build_visual_tests_binary()),
|
||||
should_run_tests.and_not_in_merge_queue().then(check_wasm()),
|
||||
should_run_tests
|
||||
.and_not_in_merge_queue()
|
||||
.then(check_dependencies()), // could be more specific here?
|
||||
should_check_docs
|
||||
.and_not_in_merge_queue()
|
||||
.then(deploy_docs::check_docs()),
|
||||
should_check_licences
|
||||
.and_not_in_merge_queue()
|
||||
.then(check_licenses()),
|
||||
should_check_scripts.and_always().then(check_scripts()),
|
||||
];
|
||||
let ext_tests = extension_tests();
|
||||
let tests_pass = tests_pass(&jobs, &[&ext_tests.name]);
|
||||
|
||||
// TODO: For merge queues, this should fail in the merge queue context
|
||||
jobs.push(
|
||||
should_run_tests
|
||||
.and_always()
|
||||
.then(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("**"))
|
||||
.merge_group(MergeGroup::default()),
|
||||
)
|
||||
.concurrency(vars::one_workflow_per_non_main_branch())
|
||||
.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' "$FILE_CHANGED_PKGS" "settings" "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)
|
||||
# Filter out deleted extensions
|
||||
EXISTING_EXTENSIONS=""
|
||||
for ext in $CHANGED_EXTENSIONS; do
|
||||
if [ -f "$ext/extension.toml" ]; then
|
||||
EXISTING_EXTENSIONS=$(printf '%s\n%s' "$EXISTING_EXTENSIONS" "$ext")
|
||||
fi
|
||||
done
|
||||
CHANGED_EXTENSIONS=$(echo "$EXISTING_EXTENSIONS" | sed '/^$/d')
|
||||
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> {
|
||||
steps::taiki_install_action("cargo-machete@0.7.0")
|
||||
}
|
||||
|
||||
fn run_cargo_machete() -> Step<Run> {
|
||||
named::bash("cargo 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 -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",
|
||||
))
|
||||
.add_env(("RUSTC_BOOTSTRAP", "1"))
|
||||
}
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_visual_tests_binary() -> NamedJob {
|
||||
pub fn cargo_build_visual_tests() -> Step<Run> {
|
||||
named::bash("cargo build -p zed --bin zed_visual_test_runner --features visual-tests")
|
||||
}
|
||||
|
||||
named::job(
|
||||
Job::default()
|
||||
.runs_on(runners::MAC_DEFAULT)
|
||||
.add_step(steps::checkout_repo())
|
||||
.add_step(steps::setup_cargo_config(Platform::Mac))
|
||||
.add_step(steps::cache_rust_dependencies_namespace())
|
||||
.add_step(cargo_build_visual_tests())
|
||||
.add_step(steps::cleanup_cargo_config(Platform::Mac)),
|
||||
)
|
||||
}
|
||||
|
||||
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")),
|
||||
)
|
||||
}
|
||||
|
||||
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_LARGE)
|
||||
.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)
|
||||
}
|
||||
69
tooling/xtask/src/tasks/workflows/runners.rs
Normal file
69
tooling/xtask/src/tasks/workflows/runners.rs
Normal file
@@ -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,
|
||||
}
|
||||
902
tooling/xtask/src/tasks/workflows/steps.rs
Normal file
902
tooling/xtask/src/tasks/workflows/steps.rs
Normal file
@@ -0,0 +1,902 @@
|
||||
use gh_workflow::{ctx::Context, *};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::tasks::workflows::{
|
||||
runners::Platform,
|
||||
steps::named::function_name,
|
||||
vars::{self, 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",
|
||||
"93cb6efe18208431cddfb8368fd83d5badbf9bfd", // v5.0.1
|
||||
)
|
||||
// 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)))
|
||||
}
|
||||
}
|
||||
|
||||
impl FluentBuilder for CheckoutStep {}
|
||||
|
||||
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 install_cargo_edit() -> Step<Use> {
|
||||
taiki_install_action("cargo-edit")
|
||||
}
|
||||
|
||||
pub fn taiki_install_action(tool: &str) -> Step<Use> {
|
||||
Step::new(named::function_name(1))
|
||||
.uses(
|
||||
"taiki-e",
|
||||
"install-action",
|
||||
"02cc5f8ca9f2301050c0c099055816a41ee05507", // v2
|
||||
)
|
||||
.add_with(("tool", tool))
|
||||
}
|
||||
|
||||
pub fn cargo_install_nextest() -> Step<Use> {
|
||||
named::uses(
|
||||
"taiki-e",
|
||||
"install-action",
|
||||
"921e2c9f7148d7ba14cd819f417db338f63e733c", // 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 350 200"),
|
||||
Platform::Linux => named::bash("./script/clear-target-dir-if-larger-than 350 200"),
|
||||
Platform::Mac => named::bash("./script/clear-target-dir-if-larger-than 350 200"),
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
"a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // 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",
|
||||
"a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // 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",
|
||||
"a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9", // 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()))
|
||||
}
|
||||
|
||||
/// Non-exhaustive list of the permissions to be set for a GitHub app token.
|
||||
///
|
||||
/// See https://github.com/actions/create-github-app-token?tab=readme-ov-file#permission-permission-name
|
||||
/// and beyond for a full list of available permissions.
|
||||
#[allow(unused)]
|
||||
pub(crate) enum TokenPermissions {
|
||||
Contents,
|
||||
Issues,
|
||||
PullRequests,
|
||||
Workflows,
|
||||
}
|
||||
|
||||
impl TokenPermissions {
|
||||
pub fn environment_name(&self) -> &'static str {
|
||||
match self {
|
||||
TokenPermissions::Contents => "permission-contents",
|
||||
TokenPermissions::Issues => "permission-issues",
|
||||
TokenPermissions::PullRequests => "permission-pull-requests",
|
||||
TokenPermissions::Workflows => "permission-workflows",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct GenerateAppToken<'a> {
|
||||
job_name: String,
|
||||
app_id: &'a str,
|
||||
app_secret: &'a str,
|
||||
repository_target: Option<RepositoryTarget>,
|
||||
permissions: Option<Vec<(TokenPermissions, Level)>>,
|
||||
}
|
||||
|
||||
impl<'a> GenerateAppToken<'a> {
|
||||
pub fn for_repository(self, repository_target: RepositoryTarget) -> Self {
|
||||
Self {
|
||||
repository_target: Some(repository_target),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_permissions(self, permissions: impl Into<Vec<(TokenPermissions, Level)>>) -> Self {
|
||||
Self {
|
||||
permissions: Some(permissions.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<GenerateAppToken<'a>> for (Step<Use>, StepOutput) {
|
||||
fn from(token: GenerateAppToken<'a>) -> Self {
|
||||
let step = Step::new(token.job_name)
|
||||
.uses(
|
||||
"actions",
|
||||
"create-github-app-token",
|
||||
"f8d387b68d61c58ab83c6c016672934102569859",
|
||||
)
|
||||
.id("generate-token")
|
||||
.add_with(
|
||||
Input::default()
|
||||
.add("app-id", token.app_id)
|
||||
.add("private-key", token.app_secret)
|
||||
.when_some(
|
||||
token.repository_target,
|
||||
|input,
|
||||
RepositoryTarget {
|
||||
owner,
|
||||
repositories,
|
||||
}| {
|
||||
input
|
||||
.when_some(owner, |input, owner| input.add("owner", owner))
|
||||
.when_some(repositories, |input, repositories| {
|
||||
input.add("repositories", repositories)
|
||||
})
|
||||
},
|
||||
)
|
||||
.when_some(token.permissions, |input, permissions| {
|
||||
permissions
|
||||
.into_iter()
|
||||
.fold(input, |input, (permission, level)| {
|
||||
input.add(
|
||||
permission.environment_name(),
|
||||
serde_json::to_value(&level).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
let generated_token = StepOutput::new(&step, "token");
|
||||
(step, generated_token)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RepositoryTarget {
|
||||
owner: Option<String>,
|
||||
repositories: Option<String>,
|
||||
}
|
||||
|
||||
impl RepositoryTarget {
|
||||
pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
|
||||
Self {
|
||||
owner: Some(owner.to_string()),
|
||||
repositories: Some(repositories.join("\n")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current() -> Self {
|
||||
Self {
|
||||
owner: None,
|
||||
repositories: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate_token<'a>(
|
||||
app_id_source: &'a str,
|
||||
app_secret_source: &'a str,
|
||||
) -> GenerateAppToken<'a> {
|
||||
generate_token_with_job_name(app_id_source, app_secret_source)
|
||||
}
|
||||
|
||||
pub fn authenticate_as_zippy() -> GenerateAppToken<'static> {
|
||||
generate_token_with_job_name(vars::ZED_ZIPPY_APP_ID, vars::ZED_ZIPPY_APP_PRIVATE_KEY)
|
||||
}
|
||||
|
||||
fn generate_token_with_job_name<'a>(
|
||||
app_id_source: &'a str,
|
||||
app_secret_source: &'a str,
|
||||
) -> GenerateAppToken<'a> {
|
||||
GenerateAppToken {
|
||||
job_name: function_name(1),
|
||||
app_id: app_id_source,
|
||||
app_secret: app_secret_source,
|
||||
repository_target: None,
|
||||
permissions: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BotCommitStep {
|
||||
message: String,
|
||||
branch: String,
|
||||
files: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl BotCommitStep {
|
||||
pub fn new(message: impl ToString, branch: impl ToString, token: &StepOutput) -> Self {
|
||||
Self {
|
||||
message: message.to_string(),
|
||||
branch: branch.to_string(),
|
||||
files: "**".to_string(),
|
||||
token: token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_files(self, files: impl ToString) -> Self {
|
||||
Self {
|
||||
files: files.to_string(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BotCommitStep> for Step<Use> {
|
||||
fn from(step: BotCommitStep) -> Self {
|
||||
Step::new("steps::bot_commit")
|
||||
.uses(
|
||||
"IAreKyleW00t",
|
||||
"verified-bot-commit",
|
||||
"126a6a11889ab05bcff72ec2403c326cd249b84c", // v2.3.0
|
||||
)
|
||||
.id("commit")
|
||||
.add_with(("message", step.message))
|
||||
.add_with(("ref", format!("refs/heads/{}", step.branch)))
|
||||
.add_with(("files", step.files))
|
||||
.add_with(("token", step.token))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum GitRef {
|
||||
Tag(String),
|
||||
Branch(String),
|
||||
}
|
||||
|
||||
impl GitRef {
|
||||
pub fn tag(name: impl ToString) -> Self {
|
||||
Self::Tag(name.to_string())
|
||||
}
|
||||
|
||||
pub fn branch(name: impl ToString) -> Self {
|
||||
Self::Branch(name.to_string())
|
||||
}
|
||||
|
||||
fn create_ref_path(&self) -> String {
|
||||
match self {
|
||||
Self::Tag(name) => format!("refs/tags/{name}"),
|
||||
Self::Branch(name) => format!("refs/heads/{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_ref_path(&self) -> String {
|
||||
match self {
|
||||
Self::Tag(name) => format!("tags/{name}"),
|
||||
Self::Branch(name) => format!("heads/{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Tag(_) => "tag",
|
||||
Self::Branch(_) => "branch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
enum RefOperation {
|
||||
Create,
|
||||
Update { force: bool },
|
||||
}
|
||||
|
||||
struct RefOp {
|
||||
git_ref: GitRef,
|
||||
operation: RefOperation,
|
||||
sha: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl From<RefOp> for Step<Use> {
|
||||
fn from(op: RefOp) -> Self {
|
||||
let (api_method, ref_path, force_line) = match &op.operation {
|
||||
RefOperation::Create => ("createRef", op.git_ref.create_ref_path(), String::new()),
|
||||
RefOperation::Update { force } => (
|
||||
"updateRef",
|
||||
op.git_ref.update_ref_path(),
|
||||
format!(",\n force: {force}"),
|
||||
),
|
||||
};
|
||||
let step_name = match &op.operation {
|
||||
RefOperation::Create => format!("steps::create_{}", op.git_ref.kind()),
|
||||
RefOperation::Update { .. } => format!("steps::update_{}", op.git_ref.kind()),
|
||||
};
|
||||
let sha = &op.sha;
|
||||
let script = indoc::formatdoc! {r#"
|
||||
github.rest.git.{api_method}({{
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: '{ref_path}',
|
||||
sha: '{sha}'{force_line}
|
||||
}})
|
||||
"#};
|
||||
Step::new(step_name)
|
||||
.uses(
|
||||
"actions",
|
||||
"github-script",
|
||||
"f28e40c7f34bde8b3046d885e986cb6290c5673b", // v7
|
||||
)
|
||||
.with(
|
||||
Input::default()
|
||||
.add("script", script)
|
||||
.add("github-token", op.token),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_ref(
|
||||
git_ref: GitRef,
|
||||
sha: impl ToString,
|
||||
token: &StepOutput,
|
||||
) -> impl Into<Step<Use>> {
|
||||
RefOp {
|
||||
git_ref,
|
||||
operation: RefOperation::Create,
|
||||
sha: sha.to_string(),
|
||||
token: token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub(crate) fn update_ref(
|
||||
git_ref: GitRef,
|
||||
sha: impl ToString,
|
||||
token: &StepOutput,
|
||||
force: bool,
|
||||
) -> impl Into<Step<Use>> {
|
||||
RefOp {
|
||||
git_ref,
|
||||
operation: RefOperation::Update { force },
|
||||
sha: sha.to_string(),
|
||||
token: token.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const ZED_ZIPPY_COMMITTER: &str =
|
||||
"zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>";
|
||||
|
||||
pub(crate) struct CreatePrStep {
|
||||
title: String,
|
||||
body: String,
|
||||
branch: String,
|
||||
base: String,
|
||||
token: String,
|
||||
assignees: Option<String>,
|
||||
labels: Option<String>,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
impl CreatePrStep {
|
||||
pub fn new(title: impl ToString, branch: impl ToString, token: &StepOutput) -> Self {
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
body: "Release Notes:\n\n- N/A".to_string(),
|
||||
branch: branch.to_string(),
|
||||
base: "main".to_string(),
|
||||
token: token.to_string(),
|
||||
assignees: Some(Context::github().actor().to_string()),
|
||||
labels: None,
|
||||
path: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_body(self, body: impl ToString) -> Self {
|
||||
Self {
|
||||
body: body.to_string(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_assignee(self, assignee: impl ToString) -> Self {
|
||||
Self {
|
||||
assignees: Some(assignee.to_string()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_labels(self, labels: impl ToString) -> Self {
|
||||
Self {
|
||||
labels: Some(labels.to_string()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_path(self, path: impl ToString) -> Self {
|
||||
Self {
|
||||
path: Some(path.to_string()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreatePrStep> for Step<Use> {
|
||||
fn from(step: CreatePrStep) -> Self {
|
||||
Step::new("steps::create_pull_request")
|
||||
.uses(
|
||||
"peter-evans",
|
||||
"create-pull-request",
|
||||
"98357b18bf14b5342f975ff684046ec3b2a07725", // v7
|
||||
)
|
||||
.add_with(("title", step.title.clone()))
|
||||
.add_with(("body", step.body))
|
||||
.add_with(("commit-message", step.title))
|
||||
.add_with(("branch", step.branch))
|
||||
.add_with(("committer", ZED_ZIPPY_COMMITTER))
|
||||
.add_with(("author", ZED_ZIPPY_COMMITTER))
|
||||
.add_with(("base", step.base))
|
||||
.add_with(("delete-branch", true))
|
||||
.add_with(("token", step.token))
|
||||
.add_with(("sign-commits", true))
|
||||
.when_some(step.assignees, |s, v| s.add_with(("assignees", v)))
|
||||
.when_some(step.labels, |s, v| s.add_with(("labels", v)))
|
||||
.when_some(step.path, |s, v| s.add_with(("path", v)))
|
||||
}
|
||||
}
|
||||
413
tooling/xtask/src/tasks/workflows/vars.rs
Normal file
413
tooling/xtask/src/tasks/workflows/vars.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
use std::{cell::RefCell, ops::Not};
|
||||
|
||||
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);
|
||||
secret!(CLOUDFLARE_API_TOKEN);
|
||||
secret!(CLOUDFLARE_ACCOUNT_ID);
|
||||
secret!(DOCS_AMPLITUDE_API_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 and_always<'a>(&'a self) -> PathContextCondition<'a> {
|
||||
PathContextCondition {
|
||||
condition: self,
|
||||
run_in_merge_queue: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn and_not_in_merge_queue<'a>(&'a self) -> PathContextCondition<'a> {
|
||||
PathContextCondition {
|
||||
condition: self,
|
||||
run_in_merge_queue: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PathContextCondition<'a> {
|
||||
condition: &'a PathCondition,
|
||||
run_in_merge_queue: bool,
|
||||
}
|
||||
|
||||
impl<'a> PathContextCondition<'a> {
|
||||
pub fn then(&'a self, job: NamedJob) -> NamedJob {
|
||||
let set_by_step = self
|
||||
.condition
|
||||
.set_by_step
|
||||
.borrow()
|
||||
.clone()
|
||||
.unwrap_or_else(|| panic!("condition {},is never set", self.condition.name));
|
||||
NamedJob {
|
||||
name: job.name,
|
||||
job: job.job.add_need(set_by_step.clone()).cond(Expression::new(
|
||||
format!(
|
||||
"needs.{}.outputs.{} == 'true' {merge_queue_condition}",
|
||||
&set_by_step,
|
||||
self.condition.name,
|
||||
merge_queue_condition = self
|
||||
.run_in_merge_queue
|
||||
.not()
|
||||
.then_some("&& github.event_name != 'merge_group'")
|
||||
.unwrap_or_default()
|
||||
)
|
||||
.trim(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 with 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
tooling/xtask/src/workspace.rs
Normal file
9
tooling/xtask/src/workspace.rs
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user