Files
fluxer/tools/ci/src/ci_workflow.rs
T

439 lines
14 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::common::{CommandSpec, run_command};
use crate::desktop::write_build_channel_file;
use crate::gateway::{GatewayStep, run_gateway_step};
use anyhow::{Context, Result};
use clap::{Args, ValueEnum};
use std::env;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
#[derive(Debug, Args, Clone)]
pub struct CiArgs {
#[arg(long, value_enum)]
step: CiStep,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
#[clap(rename_all = "snake_case")]
enum CiStep {
InstallDependencies,
Typecheck,
Test,
Knip,
GatewayFmt,
GatewayCompile,
GatewayDialyzer,
GatewayEunit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AppWasm {
Build,
ReuseIfPresent,
}
pub async fn run_ci(args: CiArgs) -> Result<()> {
let root = repo_root()?;
match args.step {
CiStep::InstallDependencies => run_command(
CommandSpec::new("pnpm")
.args(["install", "--frozen-lockfile"])
.current_dir(root),
),
CiStep::Typecheck => {
run_generators(&root, true)?;
run_app_test_artifact_generators(&root, AppWasm::Build)?;
run_command(
CommandSpec::new("pnpm")
.args(["-r", "--if-present", "typecheck"])
.current_dir(root),
)
}
CiStep::Test => {
run_generators(&root, false)?;
run_app_test_artifact_generators(&root, AppWasm::ReuseIfPresent)?;
run_workspace_tests(&root)?;
run_command(
CommandSpec::new("pnpm")
.args(["--filter", "fluxer_desktop", "test:main"])
.current_dir(&root),
)?;
run_command(with_test_env(
CommandSpec::new("pnpm")
.args(["--filter", "fluxer_api", "test"])
.current_dir(root),
))
}
CiStep::Knip => {
run_app_test_artifact_generators(&root, AppWasm::ReuseIfPresent)?;
ensure_desktop_build_channel_file(&root)?;
run_fluxer_app_script(&root, "i18n:compile")?;
run_command(
CommandSpec::new("pnpm")
.args(["exec", "knip"])
.current_dir(root),
)
}
CiStep::GatewayFmt => {
run_gateway_step(&root.join("fluxer_gateway"), GatewayStep::FmtCheck, "test")
}
CiStep::GatewayCompile => {
run_gateway_step(&root.join("fluxer_gateway"), GatewayStep::Compile, "test")
}
CiStep::GatewayDialyzer => {
run_gateway_step(&root.join("fluxer_gateway"), GatewayStep::Dialyzer, "test")
}
CiStep::GatewayEunit => {
run_gateway_step(&root.join("fluxer_gateway"), GatewayStep::Eunit, "test")
}
}
}
fn ensure_desktop_build_channel_file(root: &Path) -> Result<()> {
let channel = env::var("BUILD_CHANNEL").unwrap_or_else(|_| "stable".to_string());
write_build_channel_file(&root.join("fluxer_desktop"), &channel)
}
fn run_app_test_artifact_generators(root: &Path, wasm: AppWasm) -> Result<()> {
match (wasm, missing_app_wasm_artifact(root)) {
(AppWasm::ReuseIfPresent, None) => {
println!("Reusing restored fluxer_app wasm artifacts");
}
(AppWasm::ReuseIfPresent, Some(missing)) => {
println!(
"Rebuilding fluxer_app wasm artifacts: {} is missing",
missing.display()
);
run_fluxer_app_script(root, "wasm:codegen")?;
}
(AppWasm::Build, _) => run_fluxer_app_script(root, "wasm:codegen")?,
}
run_fluxer_app_script(root, "generate:masks")
}
fn app_wasm_artifacts(root: &Path) -> Vec<PathBuf> {
let app_dir = root.join("fluxer_app");
vec![
app_dir.join("pkgs/libfluxcore/libfluxcore.js"),
app_dir.join("pkgs/libfluxcore/libfluxcore.d.ts"),
app_dir.join("pkgs/libfluxcore/libfluxcore_bindgen.js"),
app_dir.join("pkgs/libfluxcore/libfluxcore_bindgen.d.ts"),
app_dir.join("pkgs/libfluxcore/libfluxcore_bg.wasm"),
app_dir.join("pkgs/libfluxcore/libfluxcore_bg.wasm.d.ts"),
app_dir.join("pkgs/libfluxcore/package.json"),
app_dir.join("pkgs/libfluxwebp/libfluxwebp.js"),
app_dir.join("pkgs/libfluxwebp/libfluxwebp.d.ts"),
app_dir.join("pkgs/libfluxwebp/libfluxwebp_bg.wasm"),
app_dir.join("pkgs/libfluxwebp/libfluxwebp_bg.wasm.d.ts"),
app_dir.join("pkgs/libfluxwebp/libfluxwebp_simd_bg.wasm"),
app_dir.join("pkgs/libfluxwebp/libfluxwebp_simd_bg.wasm.d.ts"),
app_dir.join("src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts"),
]
}
fn missing_app_wasm_artifact(root: &Path) -> Option<PathBuf> {
app_wasm_artifacts(root)
.into_iter()
.find(|path| !path.exists())
}
fn run_fluxer_app_script(root: &Path, script: &str) -> Result<()> {
run_command(
CommandSpec::new("pnpm")
.args(["--filter", "fluxer_app", script])
.current_dir(root),
)
}
fn run_generators(root: &Path, for_typecheck: bool) -> Result<()> {
for command in generator_commands(for_typecheck) {
run_command(command.current_dir(root))?;
}
Ok(())
}
fn generator_commands(for_typecheck: bool) -> Vec<CommandSpec> {
let mut commands =
vec![CommandSpec::new("pnpm").args(["--filter", "@fluxer/schema", "generate"])];
if for_typecheck {
commands.push(CommandSpec::new("pnpm").args([
"--filter",
"@fluxer/i18n",
"generate:types",
]));
} else {
commands.push(CommandSpec::new("pnpm").args(["--filter", "fluxer_app", "i18n:compile"]));
}
commands
}
fn workspace_test_args(concurrency: Option<&str>) -> Vec<OsString> {
let mut args = vec![OsString::from("-r")];
if let Some(concurrency) = concurrency {
args.push(OsString::from(format!(
"--workspace-concurrency={concurrency}"
)));
}
args.extend(
[
"--filter",
"!fluxer_api",
"--filter",
"!fluxer",
"--filter",
"!fluxer_desktop",
"--if-present",
"test",
]
.into_iter()
.map(OsString::from),
);
args
}
fn run_workspace_tests(root: &Path) -> Result<()> {
let concurrency = env::var("PNPM_TEST_WORKSPACE_CONCURRENCY").ok();
run_command(with_test_env(
CommandSpec::new("pnpm")
.args(workspace_test_args(concurrency.as_deref()))
.current_dir(root),
))
}
fn with_test_env(spec: CommandSpec) -> CommandSpec {
let nats_url = env::var("FLUXER_NATS_URL").unwrap_or_else(|_| default_test_nats_url());
spec.env("FLUXER_NATS_URL", &nats_url)
.env(
"FLUXER_NATS_CORE_URL",
env::var("FLUXER_NATS_CORE_URL").unwrap_or_else(|_| nats_url.clone()),
)
.env(
"FLUXER_NATS_JETSTREAM_URL",
env::var("FLUXER_NATS_JETSTREAM_URL").unwrap_or_else(|_| nats_url.clone()),
)
}
fn default_test_nats_url() -> String {
if Path::new("/.dockerenv").exists() {
"nats://nats:4222".to_string()
} else {
"nats://127.0.0.1:4222".to_string()
}
}
fn repo_root() -> Result<PathBuf> {
env::var("GITHUB_WORKSPACE")
.map(PathBuf::from)
.or_else(|_| env::current_dir())
.context("Failed to resolve repository root")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generator_commands_split_i18n_types_and_compile_by_step() {
let typecheck = generator_commands(true)
.into_iter()
.map(|command| command.args)
.collect::<Vec<_>>();
let test = generator_commands(false)
.into_iter()
.map(|command| command.args)
.collect::<Vec<_>>();
assert!(typecheck.contains(&vec![
OsString::from("--filter"),
OsString::from("@fluxer/i18n"),
OsString::from("generate:types"),
]));
assert!(!test.contains(&vec![
OsString::from("--filter"),
OsString::from("@fluxer/i18n"),
OsString::from("generate:types"),
]));
assert!(!typecheck.contains(&vec![
OsString::from("--filter"),
OsString::from("fluxer_app"),
OsString::from("i18n:compile"),
]));
assert!(test.contains(&vec![
OsString::from("--filter"),
OsString::from("fluxer_app"),
OsString::from("i18n:compile"),
]));
}
#[test]
fn with_test_env_sets_all_nats_urls_and_leaves_worker_counts_to_vitest() {
let spec = with_test_env(CommandSpec::new("pnpm"));
let env = spec
.env
.into_iter()
.collect::<std::collections::BTreeMap<_, _>>();
let default_nats_url = OsString::from(default_test_nats_url());
assert_eq!(
env.get(&OsString::from("FLUXER_NATS_URL")),
Some(&default_nats_url)
);
assert_eq!(
env.get(&OsString::from("FLUXER_NATS_CORE_URL")),
Some(&default_nats_url)
);
assert_eq!(
env.get(&OsString::from("FLUXER_NATS_JETSTREAM_URL")),
Some(&default_nats_url)
);
assert!(!env.contains_key(&OsString::from("API_TEST_MAX_WORKERS")));
assert!(!env.contains_key(&OsString::from("API_TEST_MAX_CONCURRENCY")));
}
#[test]
fn workspace_tests_exclude_desktop_and_leave_concurrency_to_pnpm() {
let args = workspace_test_args(None);
assert_eq!(args[0], OsString::from("-r"));
assert!(
!args
.iter()
.any(|arg| arg.to_string_lossy().starts_with("--workspace-concurrency"))
);
assert!(args.windows(2).any(|pair| pair
== [
OsString::from("--filter"),
OsString::from("!fluxer_desktop")
]));
}
#[test]
fn workspace_tests_forward_an_explicit_concurrency_override() {
let args = workspace_test_args(Some("4"));
assert_eq!(args[1], OsString::from("--workspace-concurrency=4"));
}
#[test]
fn missing_app_wasm_artifact_reports_the_first_absent_output() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
assert_eq!(
missing_app_wasm_artifact(root),
Some(root.join("fluxer_app/pkgs/libfluxcore/libfluxcore.js"))
);
for path in app_wasm_artifacts(root) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "").unwrap();
}
assert_eq!(missing_app_wasm_artifact(root), None);
}
#[test]
fn image_dockerfiles_carry_the_release_label_block() {
const REQUIRED: [&str; 9] = [
"LABEL org.opencontainers.image.licenses=\"AGPL-3.0-or-later\"",
"LABEL org.opencontainers.image.vendor=\"Fluxer\"",
"LABEL org.opencontainers.image.url=\"https://fluxer.app\"",
"LABEL org.opencontainers.image.documentation=\"https://docs.fluxer.app\"",
"LABEL org.opencontainers.image.source=\"https://github.com/fluxerapp/fluxer\"",
"LABEL org.opencontainers.image.version=\"${BUILD_VERSION}\"",
"LABEL org.opencontainers.image.revision=\"${SOURCE_SHA}\"",
"LABEL org.opencontainers.image.created=\"${SOURCE_DATE}\"",
"LABEL app.fluxer.build-version=\"${BUILD_VERSION}\"",
];
for (name, title, dockerfile) in [
(
"fluxer_admin",
"fluxer-admin",
include_str!("../../../fluxer_admin/Dockerfile"),
),
(
"fluxer_api",
"fluxer-api",
include_str!("../../../fluxer_api/Dockerfile"),
),
(
"fluxer_app_proxy",
"fluxer-app-proxy",
include_str!("../../../fluxer_app_proxy/Dockerfile"),
),
(
"fluxer_docs",
"fluxer-docs",
include_str!("../../../fluxer_docs/Dockerfile"),
),
(
"fluxer_gateway",
"fluxer-gateway",
include_str!("../../../fluxer_gateway/Dockerfile"),
),
(
"fluxer_gifs",
"fluxer-gifs",
include_str!("../../../fluxer_gifs/Dockerfile"),
),
(
"fluxer_media_proxy",
"fluxer-media-proxy",
include_str!("../../../fluxer_media_proxy/Dockerfile"),
),
(
"fluxer_messages",
"fluxer-messages",
include_str!("../../../fluxer_messages/Dockerfile"),
),
(
"fluxer_push",
"fluxer-push",
include_str!("../../../fluxer_push/Dockerfile"),
),
(
"fluxer_snowflakes",
"fluxer-snowflakes",
include_str!("../../../fluxer_snowflakes/Dockerfile"),
),
(
"fluxer_static",
"fluxer-static",
include_str!("../../../fluxer_static/Dockerfile"),
),
(
"fluxer_unfurl",
"fluxer-unfurl",
include_str!("../../../fluxer_unfurl/Dockerfile"),
),
(
"fluxer_users",
"fluxer-users",
include_str!("../../../fluxer_users/Dockerfile"),
),
] {
assert!(
dockerfile.contains("ARG SOURCE_SHA"),
"{name}/Dockerfile must declare ARG SOURCE_SHA"
);
assert!(
dockerfile.contains("ARG SOURCE_DATE"),
"{name}/Dockerfile must declare ARG SOURCE_DATE"
);
assert!(
dockerfile.contains(&format!("LABEL org.opencontainers.image.title=\"{title}\"")),
"{name}/Dockerfile must declare the title {title}"
);
for label in REQUIRED {
assert!(
dockerfile.contains(label),
"{name}/Dockerfile must declare {label}"
);
}
}
}
}