refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
[package]
name = "cassandra-migrate"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.4", features = ["derive"] }
scylla = "0.9"
tokio = { version = "1.0", features = ["full"] }
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
md5 = "0.7"
regex = "1.10"
lazy_static = "1.4"
[profile.release]
lto = true
panic = "abort"
codegen-units = 1
opt-level = "z"

View File

@@ -1,21 +0,0 @@
FROM rustlang/rust:nightly AS builder
WORKDIR /workspace
COPY scripts/cassandra-migrate/Cargo.toml scripts/cassandra-migrate/Cargo.toml
COPY scripts/cassandra-migrate/Cargo.lock scripts/cassandra-migrate/Cargo.lock
COPY scripts/cassandra-migrate/src scripts/cassandra-migrate/src
WORKDIR /workspace/scripts/cassandra-migrate
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /workspace
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /workspace/scripts/cassandra-migrate/target/release/cassandra-migrate /usr/local/bin/cassandra-migrate
COPY fluxer_devops/cassandra/migrations fluxer_devops/cassandra/migrations
ENTRYPOINT ["cassandra-migrate"]

View File

@@ -1,110 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::env;
mod migrate;
fn get_env_or_default(key: &str, default: &str) -> String {
env::var(key).unwrap_or_else(|_| default.to_string())
}
#[derive(Parser)]
#[command(name = "cassandra-migrate")]
#[command(about = "Forward-only Cassandra migration tool for Fluxer", long_about = Some("A simple, forward-only migration tool for Cassandra.\nMigrations are stored in fluxer_devops/cassandra/migrations.\nMigration metadata is stored in the 'fluxer' keyspace."))]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, default_value_t = get_env_or_default("CASSANDRA_HOST", "localhost"))]
host: String,
#[arg(long, default_value = "9042")]
port: u16,
#[arg(long, default_value_t = get_env_or_default("CASSANDRA_USERNAME", "cassandra"))]
username: String,
#[arg(long, default_value_t = get_env_or_default("CASSANDRA_PASSWORD", "cassandra"))]
password: String,
}
#[derive(Subcommand)]
enum Commands {
/// Create a new migration file
Create {
/// Name of the migration
name: String,
},
/// Validate all migration files
Check,
/// Run pending migrations
Up,
/// Acknowledge a failed migration to skip it
Ack {
/// Filename of the migration to acknowledge
filename: String,
},
/// Show migration status
Status,
/// Test Cassandra connection
Test,
/// Debug Cassandra connection
Debug,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Create { name } => {
migrate::create_migration(&name)?;
}
Commands::Check => {
migrate::check_migrations()?;
}
Commands::Up => {
migrate::run_migrations(&cli.host, cli.port, &cli.username, &cli.password).await?;
}
Commands::Ack { filename } => {
migrate::acknowledge_migration(
&cli.host,
cli.port,
&cli.username,
&cli.password,
&filename,
)
.await?;
}
Commands::Status => {
migrate::show_status(&cli.host, cli.port, &cli.username, &cli.password).await?;
}
Commands::Test => {
migrate::test_connection(&cli.host, cli.port, &cli.username, &cli.password).await?;
}
Commands::Debug => {
migrate::debug_connection(&cli.host, cli.port, &cli.username, &cli.password).await?;
}
}
Ok(())
}

View File

@@ -1,682 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use regex::Regex;
use scylla::Session;
use scylla::SessionBuilder;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::time::{Duration, Instant};
const MIGRATION_TABLE: &str = "schema_migrations";
lazy_static::lazy_static! {
static ref MIGRATION_KEYSPACE: String = env::var("CASSANDRA_KEYSPACE").unwrap_or_else(|_| "fluxer".to_string());
}
fn migration_keyspace() -> &'static str {
MIGRATION_KEYSPACE.as_str()
}
const MIGRATION_TEMPLATE: &str = "";
struct ForbiddenPattern {
pattern: Regex,
message: &'static str,
}
lazy_static::lazy_static! {
static ref FORBIDDEN_PATTERNS: Vec<ForbiddenPattern> = vec![
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bCREATE\s+INDEX\b").unwrap(),
message: "Secondary indexes are forbidden (CREATE INDEX)",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bCREATE\s+CUSTOM\s+INDEX\b").unwrap(),
message: "Custom indexes are forbidden (CREATE CUSTOM INDEX)",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bCREATE\s+MATERIALIZED\s+VIEW\b").unwrap(),
message: "Materialized views are forbidden (CREATE MATERIALIZED VIEW)",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bDROP\s+TABLE\b").unwrap(),
message: "DROP TABLE is forbidden",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bDROP\s+KEYSPACE\b").unwrap(),
message: "DROP KEYSPACE is forbidden",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bDROP\s+TYPE\b").unwrap(),
message: "DROP TYPE is forbidden",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bDROP\s+INDEX\b").unwrap(),
message: "DROP INDEX is forbidden",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bDROP\s+MATERIALIZED\s+VIEW\b").unwrap(),
message: "DROP MATERIALIZED VIEW is forbidden",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bDROP\s+COLUMN\b").unwrap(),
message: "DROP COLUMN is forbidden (use ALTER TABLE ... DROP ...)",
},
ForbiddenPattern {
pattern: Regex::new(r"(?i)\bTRUNCATE\b").unwrap(),
message: "TRUNCATE is forbidden",
},
];
}
pub async fn test_connection(host: &str, port: u16, username: &str, password: &str) -> Result<()> {
println!("Testing Cassandra connection to {host}:{port}...");
let session = create_session(host, port, username, password).await?;
let result = session
.query("SELECT release_version FROM system.local", &[])
.await?;
if let Some(rows) = result.rows
&& let Some(row) = rows.into_iter().next()
&& let Some(cql_value) = &row.columns[0]
&& let Some(version) = cql_value.as_text()
{
println!("✓ Connection successful - Cassandra version: {version}");
return Ok(());
}
println!("✓ Connection successful");
Ok(())
}
pub fn create_migration(name: &str) -> Result<()> {
let sanitized = sanitize_name(name);
if sanitized.is_empty() {
return Err(anyhow!("Invalid migration name: {name}"));
}
let timestamp = Utc::now().format("%Y%m%d%H%M%S");
let filename = format!("{timestamp}_{sanitized}.cql");
let filepath = get_migration_path(&filename);
if filepath.exists() {
return Err(anyhow!("Migration file already exists: {filename}"));
}
fs::write(&filepath, MIGRATION_TEMPLATE)?;
println!("✓ Created migration: {filename}");
println!(" Path: {}", filepath.display());
Ok(())
}
pub fn check_migrations() -> Result<()> {
let migrations = get_migration_files()?;
if migrations.is_empty() {
println!("No migration files found");
return Ok(());
}
println!("Checking {} migration file(s)...\n", migrations.len());
let mut errors = Vec::new();
let mut valid_count = 0;
for migration in &migrations {
let content = fs::read_to_string(get_migration_path(migration))?;
let file_errors = validate_migration_content(migration, &content);
if file_errors.is_empty() {
valid_count += 1;
println!("{migration}");
} else {
errors.extend(file_errors);
}
}
if !errors.is_empty() {
println!("\nValidation errors:");
for error in &errors {
println!("{error}");
}
return Err(anyhow!("Validation failed with {} error(s)", errors.len()));
}
println!("\n✓ All {valid_count} migration(s) are valid!");
Ok(())
}
pub async fn run_migrations(host: &str, port: u16, username: &str, password: &str) -> Result<()> {
println!("Starting Cassandra migration process...");
println!("Host: {host}, Port: {port}");
let session = create_session(host, port, username, password).await?;
setup_migration_infrastructure(&session).await?;
let migrations = get_migration_files()?;
let applied = get_applied_migrations(&session).await?;
if migrations.is_empty() {
println!("No migration files found");
return Ok(());
}
let mut pending = Vec::new();
let mut skipped = Vec::new();
for migration in migrations {
if !applied.contains_key(&migration) {
if has_skip_ci(&migration)? {
skipped.push(migration);
} else {
pending.push(migration);
}
}
}
if !skipped.is_empty() {
println!(
"Found {} migration(s) with '-- skip ci' annotation:",
skipped.len()
);
for migration in &skipped {
println!(" - {migration}");
}
println!("\nAuto-acknowledging skipped migrations...");
for migration in &skipped {
auto_acknowledge_migration(&session, migration).await?;
println!(" ✓ Acknowledged: {migration}");
}
println!();
}
if pending.is_empty() {
println!("✓ No pending migrations");
return Ok(());
}
println!("Found {} pending migration(s) to apply:", pending.len());
for migration in &pending {
println!(" - {migration}");
}
println!();
let pending_count = pending.len();
for migration in pending {
apply_migration(&session, &migration).await?;
}
println!("✓ Successfully applied {pending_count} migration(s)");
Ok(())
}
pub async fn show_status(host: &str, port: u16, username: &str, password: &str) -> Result<()> {
let session = create_session(host, port, username, password).await?;
let migrations = get_migration_files()?;
let applied = get_applied_migrations(&session).await?;
println!("Migration Status");
println!("================\n");
println!("Total migrations: {}", migrations.len());
println!("Applied: {}", applied.len());
println!("Pending: {}\n", migrations.len() - applied.len());
if !migrations.is_empty() {
println!("Migrations:");
for migration in migrations {
let status = if applied.contains_key(&migration) {
"[✓]"
} else {
"[ ]"
};
let suffix = if has_skip_ci(&migration)? {
" (skip ci)"
} else {
""
};
println!(" {status} {migration}{suffix}");
}
}
Ok(())
}
pub async fn acknowledge_migration(
host: &str,
port: u16,
username: &str,
password: &str,
filename: &str,
) -> Result<()> {
let session = create_session(host, port, username, password).await?;
let applied = get_applied_migrations(&session).await?;
if applied.contains_key(filename) {
return Err(anyhow!("Migration {filename} is already applied"));
}
let content = fs::read_to_string(get_migration_path(filename))?;
let checksum = calculate_checksum(&content);
session
.query(
format!(
"INSERT INTO {}.{} (filename, applied_at, checksum) VALUES (?, ?, ?)",
migration_keyspace(),
MIGRATION_TABLE
),
(filename, Utc::now(), checksum),
)
.await?;
println!("✓ Migration acknowledged: {filename}");
Ok(())
}
async fn create_session(host: &str, port: u16, username: &str, password: &str) -> Result<Session> {
let max_retries = 5;
let retry_delay = Duration::from_secs(10);
let mut last_error = None;
for attempt in 1..=max_retries {
if attempt > 1 {
println!("Retrying connection (attempt {attempt}/{max_retries})...");
}
let result = SessionBuilder::new()
.known_node(format!("{host}:{port}"))
.user(username, password)
.connection_timeout(Duration::from_secs(60))
.build()
.await;
match result {
Ok(session) => {
let _ = session
.query(
format!(
"CREATE KEYSPACE IF NOT EXISTS {} WITH REPLICATION = {{'class': 'SimpleStrategy', 'replication_factor': 1}}",
migration_keyspace()
),
&[],
)
.await;
return Ok(session);
}
Err(e) => {
last_error = Some(e);
if attempt < max_retries {
tokio::time::sleep(retry_delay).await;
}
}
}
}
Err(anyhow!(
"Failed to connect to Cassandra after {} attempts: {}",
max_retries,
last_error.unwrap()
))
}
async fn setup_migration_infrastructure(session: &Session) -> Result<()> {
session
.query(
format!(
"CREATE TABLE IF NOT EXISTS {}.{} (filename text PRIMARY KEY, applied_at timestamp, checksum text)",
migration_keyspace(),
MIGRATION_TABLE
),
&[],
)
.await
.map_err(|e| anyhow!("Failed to create migrations table: {e}"))?;
Ok(())
}
async fn apply_migration(session: &Session, filename: &str) -> Result<()> {
println!("Applying migration: {filename}");
let content = fs::read_to_string(get_migration_path(filename))?;
let statements = parse_statements(&content);
if statements.is_empty() {
return Err(anyhow!("No valid statements found in migration"));
}
println!(" Executing {} statement(s)...", statements.len());
for (i, statement) in statements.iter().enumerate() {
println!(" [{}/{}] Executing...", i + 1, statements.len());
session
.query(statement.as_str(), &[])
.await
.map_err(|e| anyhow!("Statement {} failed: {}\n{}", i + 1, e, statement))?;
}
let checksum = calculate_checksum(&content);
session
.query(
format!(
"INSERT INTO {}.{} (filename, applied_at, checksum) VALUES (?, ?, ?)",
migration_keyspace(),
MIGRATION_TABLE
),
(filename, Utc::now(), checksum),
)
.await
.map_err(|e| anyhow!("Failed to record migration: {e}"))?;
println!(" ✓ Migration applied successfully");
Ok(())
}
async fn auto_acknowledge_migration(session: &Session, filename: &str) -> Result<()> {
let content = fs::read_to_string(get_migration_path(filename))?;
let checksum = calculate_checksum(&content);
session
.query(
format!(
"INSERT INTO {}.{} (filename, applied_at, checksum) VALUES (?, ?, ?)",
migration_keyspace(),
MIGRATION_TABLE
),
(filename, Utc::now(), checksum),
)
.await
.map_err(|e| anyhow!("Failed to record migration: {e}"))?;
Ok(())
}
async fn get_applied_migrations(session: &Session) -> Result<HashMap<String, DateTime<Utc>>> {
let mut applied = HashMap::new();
let result = session
.query(
format!(
"SELECT filename, applied_at FROM {}.{}",
migration_keyspace(),
MIGRATION_TABLE
),
&[],
)
.await?;
if let Some(rows) = result.rows {
for row in rows {
if let Some(filename_cql) = &row.columns[0]
&& let Some(filename) = filename_cql.as_text()
&& let Some(applied_at_cql) = &row.columns[1]
{
use scylla::frame::response::result::CqlValue;
if let CqlValue::Timestamp(duration) = applied_at_cql {
let millis = duration.num_milliseconds();
let applied_at: DateTime<Utc> = DateTime::from_timestamp(
millis / 1000,
((millis % 1000) * 1_000_000) as u32,
)
.unwrap_or(Utc::now());
applied.insert(filename.to_string(), applied_at);
}
}
}
}
Ok(applied)
}
fn get_migration_files() -> Result<Vec<String>> {
let migrations_dir = get_migrations_dir();
let mut migrations = Vec::new();
for entry in fs::read_dir(migrations_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file()
&& path.extension().is_some_and(|ext| ext == "cql")
&& let Some(filename) = path.file_name().and_then(|n| n.to_str())
{
migrations.push(filename.to_string());
}
}
migrations.sort();
Ok(migrations)
}
fn get_migrations_dir() -> PathBuf {
PathBuf::from("fluxer_devops/cassandra/migrations")
}
fn get_migration_path(filename: &str) -> PathBuf {
get_migrations_dir().join(filename)
}
fn has_skip_ci(filename: &str) -> Result<bool> {
let content = fs::read_to_string(get_migration_path(filename))?;
let lines: Vec<&str> = content.lines().take(10).collect();
for line in lines {
let line = line.trim().to_lowercase();
if line.contains("-- skip ci") || line.contains("--skip ci") {
return Ok(true);
}
}
Ok(false)
}
fn parse_statements(content: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current_statement = String::new();
for line in content.lines() {
let line = if let Some(idx) = line.find("--") {
&line[..idx]
} else {
line
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
current_statement.push_str(trimmed);
current_statement.push(' ');
if trimmed.ends_with(';') {
let statement = current_statement.trim().to_string();
if !statement.is_empty() {
statements.push(statement);
}
current_statement.clear();
}
}
if !current_statement.trim().is_empty() {
statements.push(current_statement.trim().to_string());
}
statements
}
fn calculate_checksum(content: &str) -> String {
format!("{:x}", md5::compute(content.as_bytes()))
}
fn sanitize_name(name: &str) -> String {
let mut name = name.replace(' ', "_");
name = name.replace('-', "_");
name = name.to_lowercase();
let result: String = name
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
let mut result = result.replace("__", "_");
while result.contains("__") {
result = result.replace("__", "_");
}
result.trim_matches('_').to_string()
}
fn validate_migration_content(filename: &str, content: &str) -> Vec<String> {
let mut errors = Vec::new();
let clean_content = remove_comments(content);
for forbidden in FORBIDDEN_PATTERNS.iter() {
if forbidden.pattern.is_match(&clean_content) {
errors.push(format!(" {}: {}", filename, forbidden.message));
}
}
if clean_content.trim().is_empty() {
errors.push(format!(" {filename}: migration file is empty"));
}
errors
}
fn remove_comments(content: &str) -> String {
content
.lines()
.map(|line| {
if let Some(idx) = line.find("--") {
&line[..idx]
} else {
line
}
})
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
pub async fn debug_connection(host: &str, port: u16, username: &str, password: &str) -> Result<()> {
println!("=== Cassandra Connection Debug ===");
println!("Host: {host}:{port}");
println!("Username: {username}");
println!("\n[1/3] Testing TCP connectivity...");
let start = Instant::now();
match tokio::time::timeout(
Duration::from_secs(5),
tokio::net::TcpStream::connect(format!("{host}:{port}")),
)
.await
{
Ok(Ok(_)) => {
println!(
" ✓ TCP connection successful ({:.2}s)",
start.elapsed().as_secs_f64()
);
}
Ok(Err(e)) => {
println!(" ✗ TCP connection failed: {e}");
return Err(anyhow!("TCP connection failed: {e}"));
}
Err(_) => {
println!(" ✗ TCP connection timed out");
return Err(anyhow!("TCP connection timed out"));
}
}
println!("\n[2/3] Creating Cassandra session...");
let start = Instant::now();
let session = match tokio::time::timeout(
Duration::from_secs(30),
SessionBuilder::new()
.known_node(format!("{host}:{port}"))
.user(username, password)
.connection_timeout(Duration::from_secs(20))
.build(),
)
.await
{
Ok(Ok(session)) => {
println!(
" ✓ Session created ({:.2}s)",
start.elapsed().as_secs_f64()
);
session
}
Ok(Err(e)) => {
println!(" ✗ Session creation failed: {e}");
return Err(anyhow!("Failed to create session: {e}"));
}
Err(_) => {
println!(" ✗ Session creation timed out");
return Err(anyhow!("Session creation timed out"));
}
};
println!("\n[3/3] Testing queries...");
let start = Instant::now();
let result = session
.query("SELECT release_version FROM system.local", &[])
.await?;
if let Some(rows) = result.rows
&& let Some(row) = rows.into_iter().next()
&& let Some(version_col) = &row.columns[0]
&& let Some(version) = version_col.as_text()
{
println!(
" ✓ Cassandra version: {} ({:.2}s)",
version,
start.elapsed().as_secs_f64()
);
} else {
println!(
" ✓ Query successful ({:.2}s)",
start.elapsed().as_secs_f64()
);
}
println!("\n✓ All debug checks passed");
Ok(())
}

215
scripts/ci/ci_steps.py Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Mapping
from ci_utils import write_github_output, write_github_summary
ADD_KNOWN_HOSTS_SCRIPT = """
set -euo pipefail
mkdir -p ~/.ssh
ssh-keyscan -H "${SERVER_IP}" >> ~/.ssh/known_hosts
"""
INSTALL_DOCKER_PUSSH_SCRIPT = """
set -euo pipefail
mkdir -p ~/.docker/cli-plugins
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
-o ~/.docker/cli-plugins/docker-pussh
chmod +x ~/.docker/cli-plugins/docker-pussh
"""
INSTALL_RCLONE_SCRIPT = """
set -euo pipefail
if ! command -v rclone >/dev/null 2>&1; then
curl -fsSL https://rclone.org/install.sh | sudo bash
fi
"""
def rclone_config_script(
*,
endpoint: str,
acl: str,
profile: str = "ovh",
env_auth: bool = True,
expand_vars: bool = False,
) -> str:
heredoc = "RCLONEEOF" if expand_vars else "'RCLONEEOF'"
env_auth_value = "true" if env_auth else "false"
lines = [
"set -euo pipefail",
"mkdir -p ~/.config/rclone",
f"cat > ~/.config/rclone/rclone.conf <<{heredoc}",
f"[{profile}]",
"type = s3",
"provider = Other",
f"env_auth = {env_auth_value}",
f"endpoint = {endpoint}",
f"acl = {acl}",
"RCLONEEOF",
]
return "\n".join(lines) + "\n"
def bot_user_id_script() -> str:
return (
"set -euo pipefail\n"
"echo \"user-id=$(gh api \"/users/${APP_SLUG}[bot]\" --jq .id)\" >> \"$GITHUB_OUTPUT\"\n"
)
def record_deploy_commit_script(*, include_env: bool, include_sentry: bool) -> str:
lines = [
"set -euo pipefail",
"sha=$(git rev-parse HEAD)",
"echo \"Deploying commit ${sha}\"",
]
if include_env:
lines.append("printf 'DEPLOY_SHA=%s\\n' \"$sha\" >> \"$GITHUB_ENV\"")
if include_sentry:
lines.extend(
[
"printf 'SENTRY_BUILD_SHA=%s\\n' \"$sha\" >> \"$GITHUB_ENV\"",
"printf 'SENTRY_BUILD_NUMBER=%s\\n' \"$GITHUB_RUN_NUMBER\" >> \"$GITHUB_ENV\"",
"printf 'SENTRY_BUILD_TIMESTAMP=%s\\n' \"$(date +%s)\" >> \"$GITHUB_ENV\"",
]
)
return "\n".join(lines) + "\n"
def set_build_timestamp_script(*, env_name: str = "BUILD_TIMESTAMP") -> str:
return (
"set -euo pipefail\n"
f"echo \"{env_name}=$(date -u +%s)\" >> \"$GITHUB_ENV\"\n"
)
@dataclass(frozen=True)
class ReleaseMetadata:
version: str
channel: str
source_ref: str
sha_short: str
timestamp: str
date_ymd: str
build_number: str
def build_release_metadata(
*,
version_input: str,
channel: str,
source_ref: str,
env: Mapping[str, str],
now: datetime | None = None,
) -> ReleaseMetadata:
run_number = env.get("GITHUB_RUN_NUMBER", "")
sha = env.get("GITHUB_SHA", "")
version = version_input or f"0.0.{run_number}"
instant = now or datetime.now(timezone.utc)
timestamp = instant.strftime("%Y-%m-%dT%H:%M:%SZ")
date_ymd = instant.strftime("%Y%m%d")
sha_short = sha[:7]
return ReleaseMetadata(
version=version,
channel=channel,
source_ref=source_ref,
sha_short=sha_short,
timestamp=timestamp,
date_ymd=date_ymd,
build_number=run_number,
)
def write_release_metadata(metadata: ReleaseMetadata) -> None:
write_github_output(
{
"version": metadata.version,
"channel": metadata.channel,
"source_ref": metadata.source_ref,
"sha_short": metadata.sha_short,
"timestamp": metadata.timestamp,
"date": metadata.date_ymd,
"build_number": metadata.build_number,
}
)
def build_release_summary(
*,
title: str,
channel: str,
version: str,
build_number: str,
sha: str,
sha_short: str,
timestamp: str,
source_ref: str,
build_result: str,
image_tags: str,
image_digest: str,
registry: str,
image_name: str,
date_ymd: str,
) -> str:
lines: list[str] = [
f"## {title}",
"",
f"channel: {channel}",
f"version: v{version}",
f"build: {build_number}",
f"sha: {sha} (short: {sha_short})",
f"time: {timestamp}",
f"source_ref: {source_ref}",
"",
f"build result: {build_result}",
"",
]
if build_result == "success":
lines.extend(
[
"tags:",
"```",
image_tags,
"```",
f"digest: `{image_digest}`",
"",
]
)
if channel == "nightly":
lines.extend(
[
"pull:",
"```bash",
f"docker pull {registry}/{image_name}:nightly",
f"docker pull {registry}/{image_name}:nightly-{date_ymd}",
f"docker pull {registry}/{image_name}:sha-{sha_short}",
"```",
]
)
else:
lines.extend(
[
"pull:",
"```bash",
f"docker pull {registry}/{image_name}:stable",
f"docker pull {registry}/{image_name}:latest",
f"docker pull {registry}/{image_name}:v{version}",
"```",
]
)
return "\n".join(lines) + "\n"
def write_release_summary(summary: str, *, build_result: str) -> None:
write_github_summary(summary)
if build_result == "failure":
raise SystemExit(1)

80
scripts/ci/ci_utils.py Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
import os
import subprocess
import sys
from typing import Callable, Iterable, Mapping, Sequence
Step = str | Callable[[], None]
def run(cmd: Sequence[str], *, env: Mapping[str, str] | None = None) -> None:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
subprocess.run(cmd, check=True, env=merged_env)
def run_bash(script: str, *, env: Mapping[str, str] | None = None) -> None:
run(["bash", "-lc", script], env=env)
def run_pwsh(script: str, *, env: Mapping[str, str] | None = None) -> None:
run(["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], env=env)
def require_env(keys: Iterable[str]) -> None:
missing = [key for key in keys if not os.environ.get(key)]
if missing:
joined = ", ".join(missing)
raise SystemExit(f"Missing required environment variables: {joined}")
def write_github_env(pairs: Mapping[str, str]) -> None:
path = os.environ.get("GITHUB_ENV")
if not path:
raise SystemExit("GITHUB_ENV is not set")
with open(path, "a", encoding="utf-8") as handle:
for key, value in pairs.items():
handle.write(f"{key}={value}\n")
def write_github_output(pairs: Mapping[str, str]) -> None:
path = os.environ.get("GITHUB_OUTPUT")
if not path:
raise SystemExit("GITHUB_OUTPUT is not set")
with open(path, "a", encoding="utf-8") as handle:
for key, value in pairs.items():
handle.write(f"{key}={value}\n")
def write_github_summary(text: str) -> None:
path = os.environ.get("GITHUB_STEP_SUMMARY")
if not path:
raise SystemExit("GITHUB_STEP_SUMMARY is not set")
with open(path, "a", encoding="utf-8") as handle:
handle.write(text)
def read_text(path: str) -> str:
with open(path, "r", encoding="utf-8") as handle:
return handle.read()
def main_error(message: str) -> None:
print(message, file=sys.stderr)
raise SystemExit(1)
def run_step(steps: Mapping[str, Step], step: str) -> None:
selected = steps.get(step)
if selected is None:
main_error(f"Unknown step: {step}")
if isinstance(selected, str):
run_bash(selected)
return
selected()
def pwsh_step(script: str) -> Step:
return lambda: run_pwsh(script)

70
scripts/ci/ci_workflow.py Normal file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
from dataclasses import dataclass
from typing import Iterable, Sequence
@dataclass(frozen=True)
class EnvArg:
flag: str
env: str
default: str = ""
dest: str | None = None
def dest_name(self) -> str:
if self.dest is not None:
return self.dest
return self.flag.lstrip("-").replace("-", "_")
def build_step_parser(
env_args: Sequence[EnvArg] | None = None,
*,
include_server_ip: bool = False,
step_required: bool = True,
) -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
if step_required:
parser.add_argument("--step", required=True)
if include_server_ip:
parser.add_argument("--server-ip", default="")
for arg in env_args or []:
parser.add_argument(arg.flag, default=arg.default, dest=arg.dest_name())
return parser
def apply_env_args(args: argparse.Namespace, env_args: Iterable[EnvArg]) -> None:
for arg in env_args:
value = getattr(args, arg.dest_name(), "")
if value:
os.environ[arg.env] = value
def apply_server_ip(args: argparse.Namespace) -> None:
value = getattr(args, "server_ip", "")
if value:
os.environ["SERVER_IP"] = value
def parse_step_env_args(
env_args: Sequence[EnvArg] | None = None,
*,
include_server_ip: bool = False,
) -> argparse.Namespace:
parser = build_step_parser(env_args, include_server_ip=include_server_ip)
args = parser.parse_args()
apply_env_args(args, env_args or [])
if include_server_ip:
apply_server_ip(args)
return args
def parse_env_args(env_args: Sequence[EnvArg]) -> argparse.Namespace:
parser = build_step_parser(env_args, step_required=False)
args = parser.parse_args()
apply_env_args(args, env_args)
return args

79
scripts/ci/cli_release.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
from __future__ import annotations
from dataclasses import dataclass
import hashlib
from pathlib import Path
from typing import Iterable
from ci_utils import write_github_output
@dataclass(frozen=True)
class CliVersion:
version: str
tag: str
def determine_cli_version(
*,
event_name: str,
input_version: str,
ref_name: str,
tag_prefix: str,
) -> CliVersion:
if event_name == "workflow_dispatch":
version = input_version
if not version:
raise SystemExit("Missing version input")
tag = f"{tag_prefix}{version}"
return CliVersion(version=version, tag=tag)
if not ref_name:
raise SystemExit("Missing ref name")
version = ref_name
if version.startswith(tag_prefix):
version = version[len(tag_prefix) :]
if not version:
raise SystemExit("Unable to determine version from ref")
return CliVersion(version=version, tag=ref_name)
def write_cli_version_outputs(info: CliVersion) -> None:
write_github_output({"version": info.version, "tag": info.tag})
def prepare_release_assets(
*,
artifacts_dir: Path,
release_dir: Path,
binary_prefix: str,
) -> list[Path]:
release_dir.mkdir(parents=True, exist_ok=True)
output_files: list[Path] = []
for entry in sorted(artifacts_dir.glob(f"{binary_prefix}-*")):
if not entry.is_dir():
continue
name = entry.name
source = entry / name
if not source.exists():
raise SystemExit(f"Missing binary {source}")
target = release_dir / name
target.write_bytes(source.read_bytes())
target.chmod(0o755)
output_files.append(target)
if not output_files:
raise SystemExit("No release assets found")
return output_files
def generate_checksums(files: Iterable[Path], checksums_path: Path) -> None:
lines: list[str] = []
for path in sorted(files):
digest = hashlib.sha256(path.read_bytes()).hexdigest()
lines.append(f"{digest} {path.name}")
checksums_path.write_text("\n".join(lines) + "\n", encoding="utf-8")

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
from __future__ import annotations
from collections.abc import Mapping, Sequence
from ci_steps import (
ADD_KNOWN_HOSTS_SCRIPT,
INSTALL_DOCKER_PUSSH_SCRIPT,
record_deploy_commit_script,
set_build_timestamp_script,
)
from ci_workflow import EnvArg, parse_step_env_args
from ci_utils import Step, run_step
def build_standard_deploy_steps(
*,
push_and_deploy_script: str,
include_sentry: bool = False,
include_build_timestamp: bool = True,
) -> dict[str, Step]:
steps: dict[str, Step] = {
"record_deploy_commit": record_deploy_commit_script(
include_env=True,
include_sentry=include_sentry,
),
}
if include_build_timestamp:
steps["set_build_timestamp"] = set_build_timestamp_script()
steps["install_docker_pussh"] = INSTALL_DOCKER_PUSSH_SCRIPT
steps["add_known_hosts"] = ADD_KNOWN_HOSTS_SCRIPT
steps["push_and_deploy"] = push_and_deploy_script
return steps
def run_deploy_workflow(
steps: Mapping[str, Step],
*,
env_args: Sequence[EnvArg] | None = None,
) -> int:
args = parse_step_env_args(env_args, include_server_ip=True)
run_step(steps, args.step)
return 0

162
scripts/ci/erlang_hot_reload.py Executable file
View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
import argparse
import base64
import json
import os
import sys
def diff_md5(local_path: str, remote_path: str, out_path: str) -> None:
remote: dict[str, str] = {}
with open(remote_path, "r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
mod, md5 = parts
remote[mod] = md5.strip()
changed_paths: list[str] = []
with open(local_path, "r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
parts = line.split(" ", 2)
if len(parts) != 3:
continue
mod, md5, path = parts
remote_md5 = remote.get(mod)
if remote_md5 is None or remote_md5 == "null" or remote_md5 != md5:
changed_paths.append(path)
with open(out_path, "w", encoding="utf-8") as handle:
for path in changed_paths:
handle.write(f"{path}\n")
def build_json(list_path: str) -> None:
beams: list[dict[str, str]] = []
with open(list_path, "r", encoding="utf-8") as handle:
for path in handle:
path = path.strip()
if not path:
continue
mod = os.path.basename(path)
if not mod.endswith(".beam"):
continue
mod = mod[:-5]
with open(path, "rb") as beam_file:
beam_data = beam_file.read()
beams.append({
"module": mod,
"beam_b64": base64.b64encode(beam_data).decode("ascii"),
})
payload = {"beams": beams, "purge": "soft"}
print(json.dumps(payload, separators=(",", ":")))
def verify(mode: str) -> int:
raw = sys.stdin.read()
if not raw.strip():
print("::error::Empty reload response")
return 1
try:
data = json.loads(raw)
except Exception as exc:
print(f"::error::Invalid JSON reload response: {exc}")
return 1
results = data.get("results", [])
if not isinstance(results, list):
print("::error::Reload response missing results array")
return 1
if mode == "strict":
bad = [
result for result in results
if result.get("status") != "ok" or result.get("verified") is not True
]
if bad:
print("::error::Hot reload verification failed")
print(json.dumps(bad, indent=2))
return 1
warns = [
result for result in results
if result.get("purged_old_code") is not True
or (result.get("lingering_count") or 0) != 0
]
if warns:
print("::warning::Old code is still lingering for some modules after reload")
print(json.dumps(warns, indent=2))
print(f"Verified {len(results)} modules")
return 0
if mode == "self":
bad = [
result for result in results
if result.get("status") != "ok" or result.get("verified") is not True
]
if bad:
print("::error::Hot reload verification failed")
print(json.dumps(bad, indent=2))
return 1
warns = [
result for result in results
if result.get("purged_old_code") is not True
or (result.get("lingering_count") or 0) != 0
]
if warns:
print("::warning::Self-reload modules may linger until request completes")
print(json.dumps(warns, indent=2))
print(f"Verified {len(results)} self modules")
return 0
print(f"::error::Unknown verify mode: {mode}")
return 1
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
diff_parser = subparsers.add_parser("diff-md5")
diff_parser.add_argument("local_path")
diff_parser.add_argument("remote_path")
diff_parser.add_argument("out_path")
build_parser = subparsers.add_parser("build-json")
build_parser.add_argument("list_path")
verify_parser = subparsers.add_parser("verify")
verify_parser.add_argument("--mode", choices=("strict", "self"), required=True)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.command == "diff-md5":
diff_md5(args.local_path, args.remote_path, args.out_path)
return 0
if args.command == "build-json":
build_json(args.list_path)
return 0
if args.command == "verify":
return verify(args.mode)
print(f"::error::Unknown command: {args.command}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

18
scripts/ci/pyproject.toml Normal file
View File

@@ -0,0 +1,18 @@
[project]
name = "fluxer_ci"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = []
[dependency-groups]
dev = [
"pytest==8.3.4",
]
[tool.pytest.ini_options]
testpaths = [
"tests",
]
pythonpath = [
".",
]

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from ci_steps import (
build_release_metadata,
build_release_summary,
write_release_metadata,
write_release_summary,
)
from ci_workflow import EnvArg, parse_step_env_args
from ci_utils import require_env, run_step
RELEASE_METADATA_REQUIRED_ENV = (
"GITHUB_RUN_NUMBER",
"GITHUB_SHA",
"CHANNEL",
"SOURCE_REF",
)
RELEASE_SUMMARY_REQUIRED_ENV = (
"GITHUB_SHA",
"CHANNEL",
"VERSION",
"BUILD_NUMBER",
"SHA_SHORT",
"TIMESTAMP",
"SOURCE_REF",
"BUILD_RESULT",
"REGISTRY",
"DATE_YMD",
)
BASE_RELEASE_ENV_ARGS = (
EnvArg("--version-input", "VERSION_INPUT"),
EnvArg("--channel", "CHANNEL"),
EnvArg("--source-ref", "SOURCE_REF"),
EnvArg("--build-result", "BUILD_RESULT"),
EnvArg("--version", "VERSION"),
EnvArg("--build-number", "BUILD_NUMBER"),
EnvArg("--sha-short", "SHA_SHORT"),
EnvArg("--timestamp", "TIMESTAMP"),
EnvArg("--date-ymd", "DATE_YMD"),
EnvArg("--image-tags", "IMAGE_TAGS"),
EnvArg("--image-digest", "IMAGE_DIGEST"),
EnvArg("--registry", "REGISTRY"),
)
def release_metadata_step() -> None:
import os
require_env(RELEASE_METADATA_REQUIRED_ENV)
metadata = build_release_metadata(
version_input=os.environ.get("VERSION_INPUT", ""),
channel=os.environ["CHANNEL"],
source_ref=os.environ["SOURCE_REF"],
env=os.environ,
)
write_release_metadata(metadata)
def create_release_summary_step(*, title: str, image_name_env: str) -> Callable[[], None]:
def summary_step() -> None:
import os
require_env([*RELEASE_SUMMARY_REQUIRED_ENV, image_name_env])
summary = build_release_summary(
title=title,
channel=os.environ["CHANNEL"],
version=os.environ["VERSION"],
build_number=os.environ["BUILD_NUMBER"],
sha=os.environ["GITHUB_SHA"],
sha_short=os.environ["SHA_SHORT"],
timestamp=os.environ["TIMESTAMP"],
source_ref=os.environ["SOURCE_REF"],
build_result=os.environ["BUILD_RESULT"],
image_tags=os.environ.get("IMAGE_TAGS", ""),
image_digest=os.environ.get("IMAGE_DIGEST", ""),
registry=os.environ["REGISTRY"],
image_name=os.environ[image_name_env],
date_ymd=os.environ["DATE_YMD"],
)
write_release_summary(summary, build_result=os.environ["BUILD_RESULT"])
return summary_step
def build_release_steps(
*,
title: str,
image_name_env: str,
extra_steps: Mapping[str, Callable[[], None]] | None = None,
) -> dict[str, Callable[[], None]]:
steps: dict[str, Callable[[], None]] = {"metadata": release_metadata_step}
if extra_steps:
steps.update(extra_steps)
steps["summary"] = create_release_summary_step(title=title, image_name_env=image_name_env)
return steps
def build_release_env_args(
*,
image_name_arg: str,
image_name_env: str,
extra_env_args: Sequence[EnvArg] = (),
) -> list[EnvArg]:
return [
*BASE_RELEASE_ENV_ARGS,
*extra_env_args,
EnvArg(image_name_arg, image_name_env),
]
def run_release_workflow(
*,
title: str,
image_name_arg: str,
image_name_env: str,
extra_steps: Mapping[str, Callable[[], None]] | None = None,
extra_env_args: Sequence[EnvArg] = (),
) -> int:
args = parse_step_env_args(
build_release_env_args(
image_name_arg=image_name_arg,
image_name_env=image_name_env,
extra_env_args=extra_env_args,
)
)
run_step(
build_release_steps(
title=title,
image_name_env=image_name_env,
extra_steps=extra_steps,
),
args.step,
)
return 0

69
scripts/ci/uv.lock generated Normal file
View File

@@ -0,0 +1,69 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fluxer-ci"
version = "0.0.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = "==8.3.4" }]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pytest"
version = "8.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" },
]

View File

View File

@@ -0,0 +1,547 @@
#!/usr/bin/env python3
import json
import pathlib
import sys
from datetime import datetime, timezone
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_steps import INSTALL_RCLONE_SCRIPT, rclone_config_script
from ci_workflow import EnvArg, parse_step_env_args
from ci_utils import pwsh_step, require_env, run_step, write_github_output
PLATFORMS = [
{"platform": "windows", "arch": "x64", "os": "windows-latest", "electron_arch": "x64"},
{"platform": "windows", "arch": "arm64", "os": "windows-11-arm", "electron_arch": "arm64"},
{"platform": "macos", "arch": "x64", "os": "macos-15-intel", "electron_arch": "x64"},
{"platform": "macos", "arch": "arm64", "os": "macos-15", "electron_arch": "arm64"},
{"platform": "linux", "arch": "x64", "os": "ubuntu-24.04", "electron_arch": "x64"},
{"platform": "linux", "arch": "arm64", "os": "ubuntu-24.04-arm", "electron_arch": "arm64"},
]
def parse_bool(value: str) -> bool:
return value.lower() in {"1", "true", "yes", "on"}
def set_metadata_step(channel: str, ref: str) -> None:
require_env(["GITHUB_RUN_NUMBER"])
import os
run_number = os.environ.get("GITHUB_RUN_NUMBER", "")
version = f"0.0.{run_number}"
pub_date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
build_channel = "canary" if channel == "canary" else "stable"
source_ref = ref or ("canary" if channel == "canary" else "main")
write_github_output(
{
"version": version,
"pub_date": pub_date,
"channel": channel,
"build_channel": build_channel,
"source_ref": source_ref,
}
)
def set_matrix_step(flags: dict[str, bool]) -> None:
filtered: list[dict[str, str]] = []
for platform in PLATFORMS:
plat = platform["platform"]
arch = platform["arch"]
skip = False
if plat == "windows":
skip = flags["skip_windows"] or (
(arch == "x64" and flags["skip_windows_x64"])
or (arch == "arm64" and flags["skip_windows_arm64"])
)
elif plat == "macos":
skip = flags["skip_macos"] or (
(arch == "x64" and flags["skip_macos_x64"])
or (arch == "arm64" and flags["skip_macos_arm64"])
)
elif plat == "linux":
skip = flags["skip_linux"] or (
(arch == "x64" and flags["skip_linux_x64"])
or (arch == "arm64" and flags["skip_linux_arm64"])
)
if not skip:
filtered.append(platform)
matrix = {"include": filtered}
write_github_output({"matrix": json.dumps(matrix, separators=(",", ":"))})
STEPS = {
"windows_paths": pwsh_step(
r"""
subst W: "$env:GITHUB_WORKSPACE"
"WORKDIR=W:" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
New-Item -ItemType Directory -Force "C:\t" | Out-Null
New-Item -ItemType Directory -Force "C:\sq" | Out-Null
New-Item -ItemType Directory -Force "C:\ebcache" | Out-Null
"TEMP=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"TMP=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"SQUIRREL_TEMP=C:\sq" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"ELECTRON_BUILDER_CACHE=C:\ebcache" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
New-Item -ItemType Directory -Force "C:\pnpm-store" | Out-Null
"NPM_CONFIG_STORE_DIR=C:\pnpm-store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"npm_config_store_dir=C:\pnpm-store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"store-dir=C:\pnpm-store" | Set-Content -Path "W:\.npmrc" -Encoding ascii
git config --global core.longpaths true
"""
),
"set_workdir_unix": "echo \"WORKDIR=$GITHUB_WORKSPACE\" >> \"$GITHUB_ENV\"\n",
"resolve_pnpm_store_windows": pwsh_step(
r"""
$store = pnpm store path --silent
"PNPM_STORE_PATH=$store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
New-Item -ItemType Directory -Force $store | Out-Null
"""
),
"resolve_pnpm_store_unix": """
set -euo pipefail
store="$(pnpm store path --silent)"
echo "PNPM_STORE_PATH=$store" >> "$GITHUB_ENV"
mkdir -p "$store"
""",
"install_setuptools_windows_arm64": pwsh_step(
r"""
python -m pip install --upgrade pip
python -m pip install "setuptools>=69" wheel
"""
),
"install_setuptools_macos": "brew install python-setuptools\n",
"install_linux_deps": """
set -euo pipefail
sudo apt-get update
sudo apt-get install -y \
libx11-dev libxtst-dev libxt-dev libxinerama-dev libxkbcommon-dev libxrandr-dev \
ruby ruby-dev build-essential rpm \
libpixman-1-dev libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
sudo gem install --no-document fpm
""",
"install_dependencies": "pnpm install --frozen-lockfile\n",
"update_version": "pnpm version \"${VERSION}\" --no-git-tag-version --allow-same-version\n",
"set_build_channel": "pnpm set-channel\n",
"build_electron_main": "pnpm build\n",
"build_app_macos": "pnpm exec electron-builder --config electron-builder.config.cjs --mac --${ELECTRON_ARCH}\n",
"verify_bundle_id": """
set -euo pipefail
DIST="dist-electron"
ZIP="$(ls -1 "$DIST"/*"${ELECTRON_ARCH}"*.zip | head -n1)"
tmp="$(mktemp -d)"
ditto -xk "$ZIP" "$tmp"
APP="$(find "$tmp" -maxdepth 2 -name "*.app" -print -quit)"
BID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")
expected="app.fluxer"
if [[ "${BUILD_CHANNEL:-stable}" == "canary" ]]; then expected="app.fluxer.canary"; fi
echo "Bundle id in zip: $BID (expected: $expected)"
test "$BID" = "$expected"
""",
"build_app_windows": "pnpm exec electron-builder --config electron-builder.config.cjs --win --${ELECTRON_ARCH}\n",
"analyse_squirrel_paths": pwsh_step(
r"""
$primaryDir = if ($env:ARCH -eq "arm64") { "dist-electron/squirrel-windows-arm64" } else { "dist-electron/squirrel-windows" }
$fallbackDir = if ($env:ARCH -eq "arm64") { "dist-electron/squirrel-windows" } else { "dist-electron/squirrel-windows-arm64" }
$dirs = @($primaryDir, $fallbackDir)
$nupkg = $null
foreach ($d in $dirs) {
if (Test-Path $d) {
$nupkg = Get-ChildItem -Path "$d/*.nupkg" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($nupkg) { break }
}
}
if (-not $nupkg) {
throw "No Squirrel nupkg found in: $($dirs -join ', ')"
}
Write-Host "Analyzing Windows installer $($nupkg.FullName)"
$env:NUPKG_PATH = $nupkg.FullName
$lines = @(
'import os'
'import zipfile'
''
'path = os.environ["NUPKG_PATH"]'
'build_ver = os.environ["BUILD_VERSION"]'
'prefix = os.path.join(os.environ["LOCALAPPDATA"], "fluxer_app", f"app-{build_ver}", "resources", "app.asar.unpacked")'
'max_len = int(os.environ.get("MAX_WINDOWS_PATH_LEN", "260"))'
'headroom = int(os.environ.get("PATH_HEADROOM", "10"))'
'limit = max_len - headroom'
''
'with zipfile.ZipFile(path) as archive:'
' entries = []'
' for info in archive.infolist():'
' normalized = info.filename.lstrip("/\\\\")'
' total_len = len(os.path.join(prefix, normalized)) if normalized else len(prefix)'
' entries.append((total_len, info.filename))'
''
'if not entries:'
' raise SystemExit("nupkg archive contains no entries")'
''
'entries.sort(reverse=True)'
'print(f"Assumed install prefix: {prefix} ({len(prefix)} chars). Maximum allowed path length: {limit} (total reserve {max_len}, headroom {headroom}).")'
'print("Top 20 longest archived paths (length includes prefix):")'
'for length, name in entries[:20]:'
' print(f"{length:4d} {name}")'
''
'longest_len, longest_name = entries[0]'
'if longest_len > limit:'
' raise SystemExit(f"Longest path {longest_len} for {longest_name} exceeds limit {limit}")'
'print(f"Longest archived path {longest_len} is within the limit of {limit}.")'
)
$scriptPath = Join-Path $env:TEMP "nupkg-long-path-check.py"
Set-Content -Path $scriptPath -Value $lines -Encoding utf8
python $scriptPath
"""
),
"build_app_linux": "pnpm exec electron-builder --config electron-builder.config.cjs --linux --${ELECTRON_ARCH}\n",
"prepare_artifacts_windows": pwsh_step(
r"""
New-Item -ItemType Directory -Force upload_staging | Out-Null
$dist = Join-Path $env:WORKDIR "fluxer_desktop/dist-electron"
$sqDirName = if ($env:ARCH -eq "arm64") { "squirrel-windows-arm64" } else { "squirrel-windows" }
$sqFallbackName = if ($sqDirName -eq "squirrel-windows") { "squirrel-windows-arm64" } else { "squirrel-windows" }
$sq = Join-Path $dist $sqDirName
$sqFallback = Join-Path $dist $sqFallbackName
$picked = $null
if (Test-Path $sq) { $picked = $sq }
elseif (Test-Path $sqFallback) { $picked = $sqFallback }
if ($picked) {
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.exe" "upload_staging\"
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.exe.blockmap" "upload_staging\"
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\RELEASES*" "upload_staging\"
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.nupkg" "upload_staging\"
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.nupkg.blockmap" "upload_staging\"
}
if (Test-Path $dist) {
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.yml" "upload_staging\"
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.zip" "upload_staging\"
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.zip.blockmap" "upload_staging\"
}
if (-not (Get-ChildItem upload_staging -Filter *.exe -ErrorAction SilentlyContinue)) {
throw "No installer .exe staged. Squirrel outputs were not copied."
}
Get-ChildItem -Force upload_staging | Format-Table -AutoSize
"""
),
"prepare_artifacts_unix": """
set -euo pipefail
mkdir -p upload_staging
DIST="${WORKDIR}/fluxer_desktop/dist-electron"
cp -f "$DIST"/*.dmg upload_staging/ 2>/dev/null || true
cp -f "$DIST"/*.zip upload_staging/ 2>/dev/null || true
cp -f "$DIST"/*.zip.blockmap upload_staging/ 2>/dev/null || true
cp -f "$DIST"/*.yml upload_staging/ 2>/dev/null || true
cp -f "$DIST"/*.AppImage upload_staging/ 2>/dev/null || true
cp -f "$DIST"/*.deb upload_staging/ 2>/dev/null || true
cp -f "$DIST"/*.rpm upload_staging/ 2>/dev/null || true
cp -f "$DIST"/*.tar.gz upload_staging/ 2>/dev/null || true
ls -la upload_staging/
""",
"normalise_updater_yaml": """
set -euo pipefail
cd upload_staging
[[ "${PLATFORM}" == "macos" && -f latest-mac.yml && ! -f latest-mac-arm64.yml ]] && mv latest-mac.yml latest-mac-arm64.yml || true
""",
"generate_checksums_unix": """
set -euo pipefail
cd upload_staging
for file in *.exe *.dmg *.zip *.AppImage *.deb *.rpm *.tar.gz; do
[ -f "$file" ] || continue
sha256sum "$file" | awk '{print $1}' > "${file}.sha256"
echo "Generated checksum for $file"
done
ls -la *.sha256 2>/dev/null || echo "No checksum files generated"
""",
"generate_checksums_windows": pwsh_step(
r"""
cd upload_staging
$extensions = @('.exe', '.nupkg')
Get-ChildItem -File | Where-Object { $extensions -contains $_.Extension } | ForEach-Object {
$hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
Set-Content -Path "$($_.FullName).sha256" -Value $hash -NoNewline
Write-Host "Generated checksum for $($_.Name)"
}
Get-ChildItem -Filter "*.sha256" -ErrorAction SilentlyContinue | Format-Table -AutoSize
"""
),
"install_rclone": INSTALL_RCLONE_SCRIPT,
"configure_rclone": rclone_config_script(
endpoint="https://s3.us-east-va.io.cloud.ovh.us",
acl="private",
),
"build_payload": """
set -euo pipefail
mkdir -p s3_payload
shopt -s nullglob
for dir in artifacts/fluxer-desktop-${CHANNEL}-*; do
[ -d "$dir" ] || continue
base="$(basename "$dir")"
if [[ "$base" =~ ^fluxer-desktop-[a-z]+-([a-z]+)-([a-z0-9]+)$ ]]; then
platform="${BASH_REMATCH[1]}"
arch="${BASH_REMATCH[2]}"
else
echo "Skipping unrecognised artifact dir: $base"
continue
fi
case "$platform" in
windows) plat="win32" ;;
macos) plat="darwin" ;;
linux) plat="linux" ;;
*)
echo "Unknown platform: $platform"
continue
;;
esac
dest="s3_payload/desktop/${CHANNEL}/${plat}/${arch}"
mkdir -p "$dest"
cp -av "$dir"/* "$dest/" || true
if [[ "$plat" == "darwin" ]]; then
zip_file=""
for z in "$dest"/*-"$arch".zip; do
zip_file="$z"
break
done
if [[ -z "$zip_file" ]]; then
for z in "$dest"/*.zip; do
zip_file="$z"
break
done
fi
if [[ -z "$zip_file" ]]; then
echo "No .zip found for macOS $arch in $dest (auto-update requires zip artifacts)."
else
zip_name="$(basename "$zip_file")"
url="${PUBLIC_DL_BASE}/desktop/${CHANNEL}/${plat}/${arch}/${zip_name}"
cat > "$dest/RELEASES.json" <<EOF
{
"currentRelease": "${VERSION}",
"releases": [
{
"version": "${VERSION}",
"updateTo": {
"version": "${VERSION}",
"pub_date": "${PUB_DATE}",
"notes": "",
"name": "${VERSION}",
"url": "${url}"
}
}
]
}
EOF
cp -f "$dest/RELEASES.json" "$dest/releases.json"
fi
fi
setup_file=""
dmg_file=""
zip_file2=""
appimage_file=""
deb_file=""
rpm_file=""
targz_file=""
if [[ "$plat" == "win32" ]]; then
setup_file="$(ls -1 "$dest"/*.exe 2>/dev/null | grep -i 'setup' | head -n1 || true)"
if [[ -z "$setup_file" ]]; then
setup_file="$(ls -1 "$dest"/*.exe 2>/dev/null | head -n1 || true)"
fi
fi
if [[ "$plat" == "darwin" ]]; then
dmg_file="$(ls -1 "$dest"/*-"$arch".dmg 2>/dev/null | head -n1 || true)"
if [[ -z "$dmg_file" ]]; then
dmg_file="$(ls -1 "$dest"/*.dmg 2>/dev/null | head -n1 || true)"
fi
zip_file2="$(ls -1 "$dest"/*-"$arch".zip 2>/dev/null | head -n1 || true)"
if [[ -z "$zip_file2" ]]; then
zip_file2="$(ls -1 "$dest"/*.zip 2>/dev/null | head -n1 || true)"
fi
fi
if [[ "$plat" == "linux" ]]; then
appimage_file="$(ls -1 "$dest"/*.AppImage 2>/dev/null | head -n1 || true)"
deb_file="$(ls -1 "$dest"/*.deb 2>/dev/null | head -n1 || true)"
rpm_file="$(ls -1 "$dest"/*.rpm 2>/dev/null | head -n1 || true)"
targz_file="$(ls -1 "$dest"/*.tar.gz 2>/dev/null | head -n1 || true)"
fi
read_sha256() {
local file="$1"
if [[ -n "$file" && -f "${file}.sha256" ]]; then
awk '{print $1}' "${file}.sha256"
else
echo ""
fi
}
setup_sha256="$(read_sha256 "$setup_file")"
dmg_sha256="$(read_sha256 "$dmg_file")"
zip_sha256="$(read_sha256 "$zip_file2")"
appimage_sha256="$(read_sha256 "$appimage_file")"
deb_sha256="$(read_sha256 "$deb_file")"
rpm_sha256="$(read_sha256 "$rpm_file")"
targz_sha256="$(read_sha256 "$targz_file")"
jq -n \
--arg channel "${CHANNEL}" \
--arg platform "${plat}" \
--arg arch "${arch}" \
--arg version "${VERSION}" \
--arg pub_date "${PUB_DATE}" \
--arg setup "$(basename "${setup_file:-}")" \
--arg setup_sha256 "${setup_sha256}" \
--arg dmg "$(basename "${dmg_file:-}")" \
--arg dmg_sha256 "${dmg_sha256}" \
--arg zip "$(basename "${zip_file2:-}")" \
--arg zip_sha256 "${zip_sha256}" \
--arg appimage "$(basename "${appimage_file:-}")" \
--arg appimage_sha256 "${appimage_sha256}" \
--arg deb "$(basename "${deb_file:-}")" \
--arg deb_sha256 "${deb_sha256}" \
--arg rpm "$(basename "${rpm_file:-}")" \
--arg rpm_sha256 "${rpm_sha256}" \
--arg tar_gz "$(basename "${targz_file:-}")" \
--arg tar_gz_sha256 "${targz_sha256}" \
'{
channel: $channel,
platform: $platform,
arch: $arch,
version: $version,
pub_date: $pub_date,
files: (
{}
| if ($setup | length) > 0 then
. + {setup: (if ($setup_sha256 | length) > 0 then {filename: $setup, sha256: $setup_sha256} else $setup end)}
else . end
| if ($dmg | length) > 0 then
. + {dmg: (if ($dmg_sha256 | length) > 0 then {filename: $dmg, sha256: $dmg_sha256} else $dmg end)}
else . end
| if ($zip | length) > 0 then
. + {zip: (if ($zip_sha256 | length) > 0 then {filename: $zip, sha256: $zip_sha256} else $zip end)}
else . end
| if ($appimage | length) > 0 then
. + {appimage: (if ($appimage_sha256 | length) > 0 then {filename: $appimage, sha256: $appimage_sha256} else $appimage end)}
else . end
| if ($deb | length) > 0 then
. + {deb: (if ($deb_sha256 | length) > 0 then {filename: $deb, sha256: $deb_sha256} else $deb end)}
else . end
| if ($rpm | length) > 0 then
. + {rpm: (if ($rpm_sha256 | length) > 0 then {filename: $rpm, sha256: $rpm_sha256} else $rpm end)}
else . end
| if ($tar_gz | length) > 0 then
. + {tar_gz: (if ($tar_gz_sha256 | length) > 0 then {filename: $tar_gz, sha256: $tar_gz_sha256} else $tar_gz end)}
else . end
)
}' > "$dest/manifest.json"
done
echo "Payload tree:"
find s3_payload -maxdepth 6 -type f | sort
""",
"upload_payload": """
set -euo pipefail
rclone copy s3_payload/desktop "ovh:${S3_BUCKET}/desktop" \
--transfers 32 \
--checkers 16 \
--fast-list \
--s3-upload-concurrency 8 \
--s3-chunk-size 16M \
-v
""",
"build_summary": """
{
echo "## Desktop ${DISPLAY_CHANNEL^} Upload Complete"
echo ""
echo "**Version:** ${VERSION}"
echo ""
echo "**S3 prefix:** desktop/${CHANNEL}/"
echo ""
echo "**Redirect endpoint shape:** /dl/desktop/${CHANNEL}/{plat}/{arch}/{format}"
} >> "$GITHUB_STEP_SUMMARY"
""",
}
SKIP_FLAG_ENV_MAP = {
"skip_windows": "SKIP_WINDOWS",
"skip_windows_x64": "SKIP_WINDOWS_X64",
"skip_windows_arm64": "SKIP_WINDOWS_ARM64",
"skip_macos": "SKIP_MACOS",
"skip_macos_x64": "SKIP_MACOS_X64",
"skip_macos_arm64": "SKIP_MACOS_ARM64",
"skip_linux": "SKIP_LINUX",
"skip_linux_x64": "SKIP_LINUX_X64",
"skip_linux_arm64": "SKIP_LINUX_ARM64",
}
ENV_ARGS = [
EnvArg("--channel", "CHANNEL"),
EnvArg("--ref", "REF"),
EnvArg("--skip-windows", "SKIP_WINDOWS"),
EnvArg("--skip-windows-x64", "SKIP_WINDOWS_X64"),
EnvArg("--skip-windows-arm64", "SKIP_WINDOWS_ARM64"),
EnvArg("--skip-macos", "SKIP_MACOS"),
EnvArg("--skip-macos-x64", "SKIP_MACOS_X64"),
EnvArg("--skip-macos-arm64", "SKIP_MACOS_ARM64"),
EnvArg("--skip-linux", "SKIP_LINUX"),
EnvArg("--skip-linux-x64", "SKIP_LINUX_X64"),
EnvArg("--skip-linux-arm64", "SKIP_LINUX_ARM64"),
]
def main() -> int:
import os
args = parse_step_env_args(ENV_ARGS)
if args.step == "set_metadata":
channel = os.environ.get("CHANNEL", "") or "stable"
set_metadata_step(channel, os.environ.get("REF", ""))
return 0
if args.step == "set_matrix":
flags = {
key: parse_bool(os.environ.get(env_name, "false"))
for key, env_name in SKIP_FLAG_ENV_MAP.items()
}
set_matrix_step(flags)
return 0
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
import os
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_workflow import EnvArg, parse_env_args
from ci_utils import require_env, write_github_output
ENV_ARGS = [
EnvArg("--event-name", "EVENT_NAME"),
EnvArg("--ref-name", "REF_NAME"),
EnvArg("--dispatch-channel", "DISPATCH_CHANNEL"),
]
def determine_channel(
*,
event_name: str,
ref_name: str,
dispatch_channel: str,
) -> str:
if event_name == "push":
return "canary" if ref_name == "canary" else "stable"
return "canary" if dispatch_channel == "canary" else "stable"
def main() -> int:
parse_env_args(ENV_ARGS)
require_env(["EVENT_NAME"])
channel = determine_channel(
event_name=os.environ.get("EVENT_NAME", ""),
ref_name=os.environ.get("REF_NAME", ""),
dispatch_channel=os.environ.get("DISPATCH_CHANNEL", ""),
)
stack_suffix = "-canary" if channel == "canary" else ""
is_canary = "true" if channel == "canary" else "false"
write_github_output(
{
"channel": channel,
"is_canary": is_canary,
"stack_suffix": stack_suffix,
}
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

54
scripts/ci/workflows/ci.py Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"install_dependencies": """
set -euo pipefail
pnpm install --frozen-lockfile
""",
"typecheck": """
set -euo pipefail
pnpm typecheck
""",
"test": """
set -euo pipefail
pnpm test
""",
"gateway_compile": """
set -euo pipefail
cd fluxer_gateway
rebar3 compile
""",
"gateway_dialyzer": """
set -euo pipefail
cd fluxer_gateway
rebar3 dialyzer
""",
"gateway_eunit": """
set -euo pipefail
cd fluxer_gateway
rebar3 eunit
""",
"knip": """
set -euo pipefail
pnpm knip
""",
}
def main() -> int:
args = parse_step_env_args()
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"sync": """
set -euo pipefail
cd scripts/ci
uv sync --dev
""",
"test": """
set -euo pipefail
cd scripts/ci
uv run pytest
""",
}
def main() -> int:
args = parse_step_env_args()
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" \
"IMAGE_TAG=${IMAGE_TAG} STACK=${STACK} CADDY_DOMAIN=${CADDY_DOMAIN} REPLICAS=${REPLICAS} RELEASE_CHANNEL=${RELEASE_CHANNEL} IS_CANARY=${IS_CANARY} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${IS_CANARY}" == "true" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
sudo mkdir -p "/opt/${STACK}"
sudo chown -R "${USER}:${USER}" "/opt/${STACK}"
cd "/opt/${STACK}"
cat > compose.yaml << COMPOSEEOF
x-deploy-base: &deploy_base
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
x-healthcheck: &healthcheck
test: ['CMD', 'curl', '-f', 'http://localhost:8080/']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
services:
app:
image: ${IMAGE_TAG}
environment:
FLUXER_CONFIG: /etc/fluxer/config.json
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
<<: *deploy_base
replicas: ${REPLICAS}
labels:
- "caddy=${CADDY_DOMAIN}"
- 'caddy.reverse_proxy={{upstreams 8080}}'
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
- 'caddy.header.X-Xss-Protection="1; mode=block"'
- 'caddy.header.X-Content-Type-Options=nosniff'
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
- 'caddy.header.X-Frame-Options=DENY'
networks: [fluxer-shared]
healthcheck: *healthcheck
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy \
--with-registry-auth \
--detach=false \
--resolve-image never \
-c compose.yaml \
"${STACK}"
REMOTE_EOF
"""
STEPS = build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
)
def main() -> int:
return run_deploy_workflow(STEPS)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG_APP}" "${SERVER}"
if [[ "${IS_CANARY}" == "true" ]]; then
docker pussh "${IMAGE_TAG_WORKER}" "${SERVER}"
fi
ssh "${SERVER}" \
"IMAGE_TAG_APP=${IMAGE_TAG_APP} IMAGE_TAG_WORKER=${IMAGE_TAG_WORKER} STACK=${STACK} WORKER_STACK=${WORKER_STACK} CANARY_WORKER_REPLICAS=${CANARY_WORKER_REPLICAS} IS_CANARY=${IS_CANARY} CADDY_DOMAIN=${CADDY_DOMAIN} RELEASE_CHANNEL=${RELEASE_CHANNEL} SENTRY_RELEASE=${SENTRY_RELEASE} SENTRY_BUILD_SHA=${SENTRY_BUILD_SHA} SENTRY_BUILD_NUMBER=${SENTRY_BUILD_NUMBER} SENTRY_BUILD_TIMESTAMP=${SENTRY_BUILD_TIMESTAMP} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${IS_CANARY}" == "true" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
CANARY_WORKER_REPLICAS="${CANARY_WORKER_REPLICAS:-3}"
BLUESKY_KEYS_DIR="/etc/fluxer/keys"
sudo mkdir -p "${BLUESKY_KEYS_DIR}"
sudo chown root:65534 "${BLUESKY_KEYS_DIR}"
sudo chmod 0750 "${BLUESKY_KEYS_DIR}"
shopt -s nullglob
KEY_FILES=("${BLUESKY_KEYS_DIR}"/*.pem)
if [[ ${#KEY_FILES[@]} -gt 0 ]]; then
sudo chown root:65534 "${KEY_FILES[@]}"
sudo chmod 0440 "${KEY_FILES[@]}"
fi
shopt -u nullglob
deploy_api_stack() {
sudo mkdir -p "/opt/${STACK}"
sudo chown -R "${USER}:${USER}" "/opt/${STACK}"
cd "/opt/${STACK}"
cat > compose.yaml << COMPOSEEOF
x-deploy-base: &deploy_base
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
x-healthcheck: &healthcheck
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
services:
app:
image: ${IMAGE_TAG_APP}
command: ['npm', 'run', 'start']
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
- ${BLUESKY_KEYS_DIR}:${BLUESKY_KEYS_DIR}:ro
- /opt/geoip/GeoLite2-City.mmdb:/data/GeoLite2-City.mmdb:ro
deploy:
<<: *deploy_base
replicas: 6
labels:
- "caddy=${CADDY_DOMAIN}"
- 'caddy.reverse_proxy={{upstreams 8080}}'
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
- 'caddy.header.X-Xss-Protection="1; mode=block"'
- 'caddy.header.X-Content-Type-Options=nosniff'
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
- 'caddy.header.X-Frame-Options=DENY'
- 'caddy.header.Expect-Ct="max-age=86400, report-uri=\\"https://o4510149383094272.ingest.us.sentry.io/api/4510205804019712/security/?sentry_key=bb16e8b823b82d788db49a666b3b4b90\\""'
networks:
- fluxer-shared
healthcheck: *healthcheck
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy --with-registry-auth --detach=false --resolve-image never -c compose.yaml "${STACK}"
}
deploy_worker_stack() {
sudo mkdir -p "/opt/${WORKER_STACK}"
sudo chown -R "${USER}:${USER}" "/opt/${WORKER_STACK}"
cd "/opt/${WORKER_STACK}"
cat > compose.yaml << COMPOSEEOF
x-deploy-base: &deploy_base
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
services:
worker:
image: ${IMAGE_TAG_WORKER}
command: ['npm', 'run', 'start:worker']
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
- SENTRY_RELEASE=${SENTRY_RELEASE}
- SENTRY_BUILD_SHA=${SENTRY_BUILD_SHA}
- SENTRY_BUILD_NUMBER=${SENTRY_BUILD_NUMBER}
- SENTRY_BUILD_TIMESTAMP=${SENTRY_BUILD_TIMESTAMP}
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
<<: *deploy_base
replicas: ${CANARY_WORKER_REPLICAS}
networks:
- fluxer-shared
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy --with-registry-auth --detach=false --resolve-image never -c compose.yaml "${WORKER_STACK}"
}
deploy_api_stack
if [[ "${IS_CANARY}" == "true" ]]; then
deploy_worker_stack
fi
REMOTE_EOF
"""
STEPS = build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
include_sentry=True,
include_build_timestamp=False,
)
def main() -> int:
return run_deploy_workflow(STEPS)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,248 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_steps import (
ADD_KNOWN_HOSTS_SCRIPT,
INSTALL_DOCKER_PUSSH_SCRIPT,
INSTALL_RCLONE_SCRIPT,
record_deploy_commit_script,
rclone_config_script,
set_build_timestamp_script,
)
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"install_dependencies": """
set -euo pipefail
cd fluxer_app
pnpm install --frozen-lockfile
""",
"run_lingui": """
set -euo pipefail
cd fluxer_app
pnpm lingui:extract
pnpm lingui:compile --strict
""",
"record_deploy_commit": record_deploy_commit_script(
include_env=True,
include_sentry=False,
),
"install_wasm_pack": """
set -euo pipefail
if ! command -v wasm-pack >/dev/null 2>&1; then
cargo install wasm-pack --version 0.13.1
fi
""",
"generate_wasm": """
set -euo pipefail
cd fluxer_app
pnpm wasm:codegen
""",
"add_known_hosts": ADD_KNOWN_HOSTS_SCRIPT,
"fetch_deployment_config": """
set -euo pipefail
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
ssh "${SERVER}" "cat ${CONFIG_PATH}" > fluxer_app/config.json
""",
"build_application": """
set -euo pipefail
cd fluxer_app
pnpm build
node -e "const fs = require('fs'); const {execSync} = require('child_process'); const cfg = JSON.parse(fs.readFileSync(process.env.FLUXER_CONFIG, 'utf8')); const app = cfg.app_public || {}; let sha = app.build_sha || ''; if (!sha) { try { sha = execSync('git rev-parse --short HEAD', {stdio:['ignore','pipe','ignore']}).toString().trim(); } catch {} } const timestamp = Number(app.build_timestamp ?? Math.floor(Date.now() / 1000)); const buildNumber = Number(app.build_number ?? 0); const env = app.project_env ?? cfg.sentry?.release_channel ?? cfg.env ?? ''; const payload = { sha, buildNumber, timestamp, env }; fs.writeFileSync('dist/version.json', JSON.stringify(payload, null, 2));"
""",
"install_rclone": INSTALL_RCLONE_SCRIPT,
"upload_assets": rclone_config_script(
endpoint="https://s3.us-east-va.io.cloud.ovh.us",
acl="public-read",
expand_vars=True,
)
+ """
rclone copy fluxer_app/dist/assets ovh:fluxer-static/assets \
--transfers 32 \
--checkers 16 \
--size-only \
--fast-list \
--s3-upload-concurrency 8 \
--s3-chunk-size 16M \
-v
""",
"set_build_timestamp": set_build_timestamp_script(),
"install_docker_pussh": INSTALL_DOCKER_PUSSH_SCRIPT,
"push_and_deploy": """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" \
"IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK} RELEASE_CHANNEL=${RELEASE_CHANNEL} APP_REPLICAS=${APP_REPLICAS} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
read -r CADDY_APP_DOMAIN SENTRY_CADDY_DOMAIN <<EOF
$(python3 - <<'PY' "${CONFIG_PATH}"
import sys, json
from urllib.parse import urlparse
path = sys.argv[1]
with open(path, 'r') as f:
cfg = json.load(f)
domain = cfg.get('domain', {})
overrides = cfg.get('endpoint_overrides', {})
def build_url(scheme, base_domain, port, path=''):
standard = (scheme == 'http' and port == 80) or (scheme == 'https' and port == 443) or (scheme == 'ws' and port == 80) or (scheme == 'wss' and port == 443)
port_part = f":{port}" if port and not standard else ""
return f"{scheme}://{base_domain}{port_part}{path}"
def derive_domain(key):
if key == 'cdn':
return domain.get('cdn_domain') or domain.get('base_domain')
if key == 'invite':
return domain.get('invite_domain') or domain.get('base_domain')
if key == 'gift':
return domain.get('gift_domain') or domain.get('base_domain')
return domain.get('base_domain')
public_scheme = domain.get('public_scheme', 'https')
public_port = domain.get('public_port', 443 if public_scheme == 'https' else 80)
derived_app = build_url(public_scheme, derive_domain('app'), public_port)
app_url = (overrides.get('app') or derived_app).strip()
parsed_app = urlparse(app_url)
app_host = parsed_app.netloc or parsed_app.path
sentry_host_raw = (cfg.get('services', {}).get('app_proxy', {}).get('sentry_report_host') or '').strip()
if sentry_host_raw and not sentry_host_raw.startswith('http'):
sentry_host_raw = f"https://{sentry_host_raw}"
sentry_host = urlparse(sentry_host_raw).netloc if sentry_host_raw else ''
print(f"{app_host} {sentry_host}")
PY
)
EOF
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
API_TARGET="fluxer-api-canary_app"
else
API_TARGET="fluxer-api_app"
fi
SENTRY_REPORT_HOST="$(
python3 - <<'PY' "${CONFIG_PATH}"
import sys, json
path = sys.argv[1]
with open(path, 'r') as f:
cfg = json.load(f)
app_proxy = cfg.get('services', {}).get('app_proxy', {})
host = (app_proxy.get('sentry_report_host') or '').rstrip('/')
print(host)
PY
)"
sudo mkdir -p "/opt/${SERVICE_NAME}"
sudo chown -R "${USER}:${USER}" "/opt/${SERVICE_NAME}"
cd "/opt/${SERVICE_NAME}"
cat > compose.yaml << COMPOSEEOF
x-deploy-base: &deploy_base
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
x-common-caddy-headers: &common_caddy_headers
caddy.header.Strict-Transport-Security: "max-age=31536000; includeSubDomains; preload"
caddy.header.X-Xss-Protection: "1; mode=block"
caddy.header.X-Content-Type-Options: "nosniff"
caddy.header.Referrer-Policy: "strict-origin-when-cross-origin"
caddy.header.X-Frame-Options: "DENY"
caddy.header.Expect-Ct: "max-age=86400, report-uri=\\"${SENTRY_REPORT_HOST}/api/4510205815291904/security/?sentry_key=59ced0e2666ab83dd1ddb056cdd22d1b\\""
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate"
caddy.header.Pragma: "no-cache"
caddy.header.Expires: "0"
x-env-base: &env_base
FLUXER_CONFIG: /etc/fluxer/config.json
x-healthcheck: &healthcheck
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
services:
app:
image: ${IMAGE_TAG}
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
<<: *deploy_base
replicas: ${APP_REPLICAS}
labels:
<<: *common_caddy_headers
caddy: ${CADDY_APP_DOMAIN}
caddy.redir: "/.well-known/fluxer /api/.well-known/fluxer 301"
caddy.handle_path_0: /api*
caddy.handle_path_0.reverse_proxy: "http://${API_TARGET}:8080"
caddy.reverse_proxy: "{{upstreams 8080}}"
environment:
<<: *env_base
networks: [fluxer-shared]
healthcheck: *healthcheck
sentry:
image: ${IMAGE_TAG}
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
<<: *deploy_base
replicas: 1
labels:
<<: *common_caddy_headers
caddy: ${SENTRY_CADDY_DOMAIN}
caddy.reverse_proxy: "{{upstreams 8080}}"
environment:
<<: *env_base
networks: [fluxer-shared]
healthcheck: *healthcheck
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy \
--with-registry-auth \
--detach=false \
--resolve-image never \
-c compose.yaml \
"${COMPOSE_STACK}"
REMOTE_EOF
""",
}
def main() -> int:
args = parse_step_env_args(include_server_ip=True)
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_steps import ADD_KNOWN_HOSTS_SCRIPT, record_deploy_commit_script
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"compile": """
set -euo pipefail
cd fluxer_gateway
rebar3 as prod compile
""",
"add_known_hosts": ADD_KNOWN_HOSTS_SCRIPT,
"record_deploy_commit": record_deploy_commit_script(
include_env=False,
include_sentry=False,
),
"deploy": """
set -euo pipefail
CONTAINER_ID="$(ssh "${SERVER}" "docker ps -q --filter label=com.docker.swarm.service.name=fluxer-gateway_app | head -1")"
if [ -z "${CONTAINER_ID}" ]; then
echo "::error::No running container found for service fluxer-gateway_app"
ssh "${SERVER}" "docker ps --filter 'name=fluxer-gateway_app' --format '{{.ID}} {{.Names}} {{.Status}}'" || true
exit 1
fi
echo "Container: ${CONTAINER_ID}"
GATEWAY_HTTP_PORT="8080"
echo "Gateway HTTP port: ${GATEWAY_HTTP_PORT}"
if ! ssh "${SERVER}" "docker exec ${CONTAINER_ID} curl -fsS --max-time 3 http://localhost:${GATEWAY_HTTP_PORT}/_health >/dev/null"; then
echo "::error::Gateway HTTP listener is not reachable on port ${GATEWAY_HTTP_PORT}"
exit 1
fi
LOCAL_MD5_LINES="$(
erl -noshell -eval '
Files = filelib:wildcard("fluxer_gateway/_build/prod/lib/fluxer_gateway/ebin/*.beam"),
lists:foreach(
fun(F) ->
{ok, {M, Md5}} = beam_lib:md5(F),
Hex = binary:encode_hex(Md5, lowercase),
io:format("~s ~s ~s~n", [atom_to_list(M), binary_to_list(Hex), F])
end,
Files
),
halt().'
)"
REMOTE_MD5_LINES="$(
ssh "${SERVER}" "docker exec ${CONTAINER_ID} /opt/fluxer_gateway/bin/fluxer_gateway eval '
Mods = hot_reload:get_loaded_modules(),
lists:foreach(
fun(M) ->
case hot_reload:get_module_info(M) of
{ok, Info} ->
V = maps:get(loaded_md5, Info),
S = case V of
null -> \"null\";
B when is_binary(B) -> binary_to_list(B)
end,
io:format(\"~s ~s~n\", [atom_to_list(M), S]);
_ ->
ok
end
end,
Mods
),
ok.
' " | tr -d '\r'
)"
LOCAL_MD5_FILE="$(mktemp)"
REMOTE_MD5_FILE="$(mktemp)"
CHANGED_FILE_LIST="$(mktemp)"
CHANGED_MAIN_LIST="$(mktemp)"
CHANGED_SELF_LIST="$(mktemp)"
RELOAD_RESULT_MAIN="$(mktemp)"
RELOAD_RESULT_SELF="$(mktemp)"
trap 'rm -f "${LOCAL_MD5_FILE}" "${REMOTE_MD5_FILE}" "${CHANGED_FILE_LIST}" "${CHANGED_MAIN_LIST}" "${CHANGED_SELF_LIST}" "${RELOAD_RESULT_MAIN}" "${RELOAD_RESULT_SELF}"' EXIT
printf '%s' "${LOCAL_MD5_LINES}" > "${LOCAL_MD5_FILE}"
printf '%s' "${REMOTE_MD5_LINES}" > "${REMOTE_MD5_FILE}"
python3 scripts/ci/erlang_hot_reload.py diff-md5 \
"${LOCAL_MD5_FILE}" \
"${REMOTE_MD5_FILE}" \
"${CHANGED_FILE_LIST}"
mapfile -t CHANGED_FILES < "${CHANGED_FILE_LIST}"
if [ "${#CHANGED_FILES[@]}" -eq 0 ]; then
echo "No BEAM changes detected, nothing to hot-reload."
exit 0
fi
echo "Changed modules count: ${#CHANGED_FILES[@]}"
while IFS= read -r p; do
[ -n "${p}" ] || continue
m="$(basename "${p}")"
m="${m%.beam}"
if [ "${m}" = "hot_reload" ] || [ "${m}" = "hot_reload_handler" ]; then
printf '%s\n' "${p}" >> "${CHANGED_SELF_LIST}"
else
printf '%s\n' "${p}" >> "${CHANGED_MAIN_LIST}"
fi
done < "${CHANGED_FILE_LIST}"
build_json() {
python3 scripts/ci/erlang_hot_reload.py build-json "$1"
}
strict_verify() {
python3 scripts/ci/erlang_hot_reload.py verify --mode strict
}
self_verify() {
python3 scripts/ci/erlang_hot_reload.py verify --mode self
}
if [ -s "${CHANGED_SELF_LIST}" ]; then
if ! build_json "${CHANGED_SELF_LIST}" | ssh "${SERVER}" "docker exec -i ${CONTAINER_ID} curl -sS -X POST -H 'Authorization: Bearer ${GATEWAY_ADMIN_SECRET}' -H 'Content-Type: application/json' --data @- http://localhost:${GATEWAY_HTTP_PORT}/_admin/reload" | tee "${RELOAD_RESULT_SELF}" | self_verify; then
echo "::group::Hot reload response (self)"
cat "${RELOAD_RESULT_SELF}" || true
echo "::endgroup::"
exit 1
fi
fi
if [ -s "${CHANGED_MAIN_LIST}" ]; then
if ! build_json "${CHANGED_MAIN_LIST}" | ssh "${SERVER}" "docker exec -i ${CONTAINER_ID} curl -sS -X POST -H 'Authorization: Bearer ${GATEWAY_ADMIN_SECRET}' -H 'Content-Type: application/json' --data @- http://localhost:${GATEWAY_HTTP_PORT}/_admin/reload" | tee "${RELOAD_RESULT_MAIN}" | strict_verify; then
echo "::group::Hot reload response (main)"
cat "${RELOAD_RESULT_MAIN}" || true
echo "::endgroup::"
exit 1
fi
fi
""",
}
def main() -> int:
args = parse_step_env_args(include_server_ip=True)
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" "IMAGE_TAG=${IMAGE_TAG} STACK=${STACK} IS_CANARY=${IS_CANARY} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${IS_CANARY}" == "true" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
sudo mkdir -p "/opt/${STACK}"
sudo chown -R "${USER}:${USER}" "/opt/${STACK}"
cd "/opt/${STACK}"
cat > compose.yaml << COMPOSEEOF
x-deploy-base: &deploy_base
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
x-healthcheck: &healthcheck
test: ['CMD', 'curl', '-f', 'http://localhost:6380/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
services:
app:
image: ${IMAGE_TAG}
deploy:
<<: *deploy_base
replicas: 1
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
networks: [fluxer-shared]
healthcheck: *healthcheck
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy \
--with-registry-auth \
--detach=false \
--resolve-image never \
-c compose.yaml \
"${STACK}"
REMOTE_EOF
"""
STEPS = build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
)
def main() -> int:
return run_deploy_workflow(STEPS)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" \
"IMAGE_TAG=${IMAGE_TAG} STACK=${STACK} IS_CANARY=${IS_CANARY} CADDY_DOMAIN=${CADDY_DOMAIN} RELEASE_CHANNEL=${RELEASE_CHANNEL} APP_REPLICAS=${APP_REPLICAS} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${IS_CANARY}" == "true" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
sudo mkdir -p "/opt/${STACK}"
sudo chown -R "${USER}:${USER}" "/opt/${STACK}"
cd "/opt/${STACK}"
cat > compose.yaml << COMPOSEEOF
services:
app:
image: ${IMAGE_TAG}
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
replicas: ${APP_REPLICAS}
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
labels:
caddy: "${CADDY_DOMAIN}"
caddy.reverse_proxy: "{{upstreams 8080}}"
caddy.header.Strict-Transport-Security: "max-age=31536000; includeSubDomains; preload"
caddy.header.X-Xss-Protection: "1; mode=block"
caddy.header.X-Content-Type-Options: "nosniff"
caddy.header.Referrer-Policy: "strict-origin-when-cross-origin"
caddy.header.X-Frame-Options: "DENY"
COMPOSEEOF
if [[ "${IS_CANARY}" == "true" ]]; then
cat >> compose.yaml << 'COMPOSEEOF'
caddy.header.X-Robots-Tag: "noindex, nofollow, nosnippet, noimageindex"
caddy.@channels.path: "/channels /channels/*"
caddy.redir: "@channels https://web.canary.fluxer.app{uri}"
caddy.redir_0: "/.well-known/fluxer https://api.canary.fluxer.app/.well-known/fluxer 301"
COMPOSEEOF
else
cat >> compose.yaml << 'COMPOSEEOF'
caddy.redir_0: "/channels/* https://web.fluxer.app{uri}"
caddy.redir_1: "/channels https://web.fluxer.app{uri}"
caddy.redir_2: "/delete-my-account /help/delete-account 302"
caddy.redir_3: "/delete-my-data /help/data-deletion 302"
caddy.redir_4: "/export-my-data /help/data-export 302"
caddy.redir_5: "/bugs /help/report-bug 302"
caddy_1: "www.fluxer.app"
caddy_1.redir: "https://fluxer.app{uri}"
caddy_3: "fluxer.gg"
caddy_3.@fluxer_gg_root.path: "/"
caddy_3.redir_0: "@fluxer_gg_root https://fluxer.app"
caddy_3.redir_1: "https://web.fluxer.app/invite{uri}"
caddy_4: "fluxer.gift"
caddy_4.@fluxer_gift_root.path: "/"
caddy_4.redir_0: "@fluxer_gift_root https://fluxer.app"
caddy_4.redir_1: "https://web.fluxer.app/gift{uri}"
caddy_5: "fluxerapp.com"
caddy_5.redir: "https://fluxer.app{uri}"
caddy_6: "www.fluxerapp.com"
caddy_6.redir: "https://fluxer.app{uri}"
caddy_7: "fluxer.dev"
caddy_7.redir: "https://docs.fluxer.app{uri}"
caddy_8: "www.fluxer.dev"
caddy_8.redir: "https://docs.fluxer.app{uri}"
caddy.redir_9: "/.well-known/fluxer https://api.fluxer.app/.well-known/fluxer 301"
COMPOSEEOF
fi
cat >> compose.yaml << 'COMPOSEEOF'
networks:
- fluxer-shared
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy \
--with-registry-auth \
--detach=false \
--resolve-image never \
-c compose.yaml \
"${STACK}"
REMOTE_EOF
"""
STEPS = build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
)
def main() -> int:
return run_deploy_workflow(STEPS)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" "IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK} RELEASE_CHANNEL=${RELEASE_CHANNEL} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
sudo mkdir -p "/opt/${SERVICE_NAME}"
sudo chown -R "${USER}:${USER}" "/opt/${SERVICE_NAME}"
cd "/opt/${SERVICE_NAME}"
cat > compose.yaml << COMPOSEEOF
services:
app:
image: ${IMAGE_TAG}
command: ['pnpm', 'start']
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
replicas: 2
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
labels:
- 'caddy=http://fluxerusercontent.com'
- 'caddy.reverse_proxy={{upstreams 8080}}'
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
- 'caddy.header.X-Xss-Protection="1; mode=block"'
- 'caddy.header.X-Content-Type-Options=nosniff'
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
- 'caddy.header.X-Frame-Options=DENY'
- 'caddy.header.Expect-Ct="max-age=86400, report-uri=\"https://o4510149383094272.ingest.us.sentry.io/api/4510205811556352/security/?sentry_key=2670068cd12b6a62f3a30a7f0055f0f1\""'
networks:
- fluxer-shared
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy --with-registry-auth --detach=false --resolve-image never -c compose.yaml "${COMPOSE_STACK}"
REMOTE_EOF
"""
STEPS = build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
)
def main() -> int:
return run_deploy_workflow(STEPS)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" "IMAGE_TAG=${IMAGE_TAG} STACK=${STACK} RELEASE_CHANNEL=${RELEASE_CHANNEL} IS_CANARY=${IS_CANARY} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${IS_CANARY}" == "true" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
sudo mkdir -p "/opt/${STACK}"
sudo chown -R "${USER}:${USER}" "/opt/${STACK}"
cd "/opt/${STACK}"
cat > compose.yaml << COMPOSEEOF
x-deploy-base: &deploy_base
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
x-healthcheck: &healthcheck
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
services:
queue:
image: ${IMAGE_TAG}
deploy:
<<: *deploy_base
replicas: 1
volumes:
- queue_data:/data
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
networks: [fluxer-shared]
healthcheck: *healthcheck
volumes:
queue_data:
driver: local
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy \
--with-registry-auth \
--detach=false \
--resolve-image never \
-c compose.yaml \
"${STACK}"
REMOTE_EOF
"""
STEPS = build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
)
def main() -> int:
return run_deploy_workflow(STEPS)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_steps import ADD_KNOWN_HOSTS_SCRIPT, record_deploy_commit_script
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"compile": """
set -euo pipefail
cd fluxer_relay
rebar3 as prod compile
""",
"add_known_hosts": ADD_KNOWN_HOSTS_SCRIPT,
"record_deploy_commit": record_deploy_commit_script(
include_env=False,
include_sentry=False,
),
"deploy": """
set -euo pipefail
CONTAINER_ID="$(ssh "${SERVER}" "docker ps -q --filter label=com.docker.swarm.service.name=fluxer_relay_app | head -1")"
if [ -z "${CONTAINER_ID}" ]; then
echo "::error::No running container found for service fluxer_relay_app"
ssh "${SERVER}" "docker ps --filter 'name=fluxer_relay_app' --format '{{.ID}} {{.Names}} {{.Status}}'" || true
exit 1
fi
echo "Container: ${CONTAINER_ID}"
LOCAL_MD5_LINES="$(
erl -noshell -eval '
Files = filelib:wildcard("fluxer_relay/_build/prod/lib/fluxer_relay/ebin/*.beam"),
lists:foreach(
fun(F) ->
{ok, {M, Md5}} = beam_lib:md5(F),
Hex = binary:encode_hex(Md5, lowercase),
io:format("~s ~s ~s~n", [atom_to_list(M), binary_to_list(Hex), F])
end,
Files
),
halt().'
)"
REMOTE_MD5_LINES="$(
ssh "${SERVER}" "docker exec ${CONTAINER_ID} /opt/fluxer_relay/bin/fluxer_relay eval '
Mods = hot_reload:get_loaded_modules(),
lists:foreach(
fun(M) ->
case hot_reload:get_module_info(M) of
{ok, Info} ->
V = maps:get(loaded_md5, Info),
S = case V of
null -> \"null\";
B when is_binary(B) -> binary_to_list(B)
end,
io:format(\"~s ~s~n\", [atom_to_list(M), S]);
_ ->
ok
end
end,
Mods
),
ok.
' " | tr -d '\r'
)"
LOCAL_MD5_FILE="$(mktemp)"
REMOTE_MD5_FILE="$(mktemp)"
CHANGED_FILE_LIST="$(mktemp)"
CHANGED_MAIN_LIST="$(mktemp)"
CHANGED_SELF_LIST="$(mktemp)"
RELOAD_RESULT_MAIN="$(mktemp)"
RELOAD_RESULT_SELF="$(mktemp)"
trap 'rm -f "${LOCAL_MD5_FILE}" "${REMOTE_MD5_FILE}" "${CHANGED_FILE_LIST}" "${CHANGED_MAIN_LIST}" "${CHANGED_SELF_LIST}" "${RELOAD_RESULT_MAIN}" "${RELOAD_RESULT_SELF}"' EXIT
printf '%s' "${LOCAL_MD5_LINES}" > "${LOCAL_MD5_FILE}"
printf '%s' "${REMOTE_MD5_LINES}" > "${REMOTE_MD5_FILE}"
python3 scripts/ci/erlang_hot_reload.py diff-md5 \
"${LOCAL_MD5_FILE}" \
"${REMOTE_MD5_FILE}" \
"${CHANGED_FILE_LIST}"
mapfile -t CHANGED_FILES < "${CHANGED_FILE_LIST}"
if [ "${#CHANGED_FILES[@]}" -eq 0 ]; then
echo "No BEAM changes detected, nothing to hot-reload."
exit 0
fi
echo "Changed modules count: ${#CHANGED_FILES[@]}"
while IFS= read -r p; do
[ -n "${p}" ] || continue
m="$(basename "${p}")"
m="${m%.beam}"
if [ "${m}" = "hot_reload" ] || [ "${m}" = "hot_reload_handler" ]; then
printf '%s\n' "${p}" >> "${CHANGED_SELF_LIST}"
else
printf '%s\n' "${p}" >> "${CHANGED_MAIN_LIST}"
fi
done < "${CHANGED_FILE_LIST}"
build_json() {
python3 scripts/ci/erlang_hot_reload.py build-json "$1"
}
strict_verify() {
python3 scripts/ci/erlang_hot_reload.py verify --mode strict
}
self_verify() {
python3 scripts/ci/erlang_hot_reload.py verify --mode self
}
if [ -s "${CHANGED_SELF_LIST}" ]; then
if ! build_json "${CHANGED_SELF_LIST}" | ssh "${SERVER}" "docker exec -i ${CONTAINER_ID} curl -sS -X POST -H 'Authorization: Bearer ${RELAY_ADMIN_SECRET}' -H 'Content-Type: application/json' --data @- http://localhost:8081/_admin/reload" | tee "${RELOAD_RESULT_SELF}" | self_verify; then
echo "::group::Hot reload response (self)"
cat "${RELOAD_RESULT_SELF}" || true
echo "::endgroup::"
exit 1
fi
fi
if [ -s "${CHANGED_MAIN_LIST}" ]; then
if ! build_json "${CHANGED_MAIN_LIST}" | ssh "${SERVER}" "docker exec -i ${CONTAINER_ID} curl -sS -X POST -H 'Authorization: Bearer ${RELAY_ADMIN_SECRET}' -H 'Content-Type: application/json' --data @- http://localhost:8081/_admin/reload" | tee "${RELOAD_RESULT_MAIN}" | strict_verify; then
echo "::group::Hot reload response (main)"
cat "${RELOAD_RESULT_MAIN}" || true
echo "::endgroup::"
exit 1
fi
fi
""",
}
def main() -> int:
args = parse_step_env_args(include_server_ip=True)
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" "IMAGE_TAG=${IMAGE_TAG} STACK=${STACK} IS_CANARY=${IS_CANARY} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${IS_CANARY}" == "true" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
sudo mkdir -p "/opt/${STACK}"
sudo chown -R "${USER}:${USER}" "/opt/${STACK}"
cd "/opt/${STACK}"
cat > compose.yaml << COMPOSEEOF
x-deploy-base: &deploy_base
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
x-healthcheck: &healthcheck
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
services:
app:
image: ${IMAGE_TAG}
deploy:
<<: *deploy_base
replicas: 1
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
networks: [fluxer-shared]
healthcheck: *healthcheck
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy \
--with-registry-auth \
--detach=false \
--resolve-image never \
-c compose.yaml \
"${STACK}"
REMOTE_EOF
"""
STEPS = build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
)
def main() -> int:
return run_deploy_workflow(STEPS)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" "IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK} RELEASE_CHANNEL=${RELEASE_CHANNEL} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
sudo mkdir -p "/opt/${SERVICE_NAME}"
sudo chown -R "${USER}:${USER}" "/opt/${SERVICE_NAME}"
cd "/opt/${SERVICE_NAME}"
cat > compose.yaml << COMPOSEEOF
services:
app:
image: ${IMAGE_TAG}
command: ['pnpm', 'start']
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
- FLUXER_CONFIG__SERVICES__MEDIA_PROXY__STATIC_MODE=true
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
replicas: 2
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
labels:
- 'caddy=http://fluxerstatic.com'
- 'caddy.reverse_proxy={{upstreams 8080}}'
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
- 'caddy.header.X-Xss-Protection="1; mode=block"'
- 'caddy.header.X-Content-Type-Options=nosniff'
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
- 'caddy.header.X-Frame-Options=DENY'
- 'caddy.header.Expect-Ct="max-age=86400, report-uri=\"https://o4510149383094272.ingest.us.sentry.io/api/4510205811556352/security/?sentry_key=2670068cd12b6a62f3a30a7f0055f0f1\""'
networks:
- fluxer-shared
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy --with-registry-auth --detach=false --resolve-image never -c compose.yaml "${COMPOSE_STACK}"
REMOTE_EOF
"""
STEPS = build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
)
def main() -> int:
return run_deploy_workflow(STEPS)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_steps import ADD_KNOWN_HOSTS_SCRIPT
from ci_workflow import EnvArg, parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"install_dependencies": """
set -euo pipefail
cd fluxer_api
pnpm install --frozen-lockfile
""",
"validate_migrations": """
set -euo pipefail
cd fluxer_api
pnpm tsx scripts/CassandraMigrate.tsx check
""",
"add_known_hosts": ADD_KNOWN_HOSTS_SCRIPT,
"setup_tunnel": """
set -euo pipefail
TUNNEL_PID_FILE=/tmp/ssh-tunnel.pid
rm -f "$TUNNEL_PID_FILE"
nohup ssh -N -o ConnectTimeout=30 -o ServerAliveInterval=10 -o ServerAliveCountMax=30 -o ExitOnForwardFailure=yes -L 9042:localhost:9042 ${SERVER_USER}@${SERVER_IP} > /tmp/ssh-tunnel.log 2>&1 &
SSH_TUNNEL_PID=$!
printf '%s\n' "$SSH_TUNNEL_PID" > "$TUNNEL_PID_FILE"
printf 'SSH_TUNNEL_PID=%s\n' "$SSH_TUNNEL_PID" >> "$GITHUB_ENV"
for i in {1..30}; do
if timeout 1 bash -c "echo > /dev/tcp/localhost/9042" 2>/dev/null; then
echo "SSH tunnel established"
break
elif command -v ss >/dev/null 2>&1 && ss -tln | grep -q ":9042 "; then
echo "SSH tunnel established"
break
elif command -v netstat >/dev/null 2>&1 && netstat -tln | grep -q ":9042 "; then
echo "SSH tunnel established"
break
fi
if [ $i -eq 30 ]; then
cat /tmp/ssh-tunnel.log || true
exit 1
fi
sleep 1
done
ps -p "$SSH_TUNNEL_PID" > /dev/null || exit 1
""",
"test_connection": """
set -euo pipefail
cd fluxer_api
pnpm tsx scripts/CassandraMigrate.tsx \
--host localhost \
--port 9042 \
--username "${CASSANDRA_USERNAME}" \
--password "${CASSANDRA_PASSWORD}" \
test
""",
"run_migrations": """
set -euo pipefail
cd fluxer_api
pnpm tsx scripts/CassandraMigrate.tsx \
--host localhost \
--port 9042 \
--username "${CASSANDRA_USERNAME}" \
--password "${CASSANDRA_PASSWORD}" \
up
""",
"close_tunnel": """
set -euo pipefail
TUNNEL_PID_FILE=/tmp/ssh-tunnel.pid
if [ -n "${SSH_TUNNEL_PID:-}" ]; then
kill "$SSH_TUNNEL_PID" 2>/dev/null || true
fi
if [ -f "$TUNNEL_PID_FILE" ]; then
read -r TUNNEL_PID < "$TUNNEL_PID_FILE" || true
if [ -n "${TUNNEL_PID:-}" ]; then
kill "$TUNNEL_PID" 2>/dev/null || true
fi
fi
rm -f "$TUNNEL_PID_FILE" /tmp/ssh-tunnel.log || true
""",
}
def main() -> int:
args = parse_step_env_args(
[
EnvArg("--server-user", "SERVER_USER"),
],
include_server_ip=True,
)
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_workflow import EnvArg, parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"verify": """
set -euo pipefail
src="${SRC}"
dst="${DST}"
git fetch origin "${dst}" "${src}" --prune
# Ensure HEAD is exactly origin/src
git reset --hard "origin/${src}"
# FF-only requirement: dst must be an ancestor of src
if ! git merge-base --is-ancestor "origin/${dst}" "origin/${src}"; then
echo "::error::Cannot fast-forward: origin/${dst} is not an ancestor of origin/${src} (branches diverged)."
exit 1
fi
ahead="$(git rev-list --count "origin/${dst}..origin/${src}")"
echo "ahead=$ahead" >> "$GITHUB_OUTPUT"
{
echo "## Promote \`${src}\` → \`${dst}\` (ff-only)"
echo ""
echo "- \`${dst}\`: \`$(git rev-parse "origin/${dst}")\`"
echo "- \`${src}\`: \`$(git rev-parse "origin/${src}")\`"
echo "- Commits to promote: **${ahead}**"
echo ""
echo "### Commits"
if [ "$ahead" -eq 0 ]; then
echo "_Nothing to promote._"
else
git log --oneline --decorate "origin/${dst}..origin/${src}"
fi
} >> "$GITHUB_STEP_SUMMARY"
""",
"push": """
set -euo pipefail
dst="${DST}"
# Push src HEAD to dst (no merge commit, same SHAs)
git push origin "HEAD:refs/heads/${dst}"
""",
"dry_run": """
echo "No push performed (dry_run=${DRY_RUN}, ahead=${AHEAD})."
""",
}
def main() -> int:
args = parse_step_env_args(
[
EnvArg("--src", "SRC"),
EnvArg("--dst", "DST"),
EnvArg("--dry-run", "DRY_RUN"),
EnvArg("--ahead", "AHEAD"),
]
)
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from cli_release import (
determine_cli_version,
generate_checksums,
prepare_release_assets,
write_cli_version_outputs,
)
from ci_workflow import EnvArg, apply_env_args, build_step_parser
from ci_utils import require_env, run_step
BINARY_PREFIX = "livekitctl"
TAG_PREFIX = "livekitctl-v"
PROJECT_DIR = pathlib.Path("fluxer_devops/livekitctl")
def determine_version_step() -> None:
import os
require_env(["EVENT_NAME"])
info = determine_cli_version(
event_name=os.environ["EVENT_NAME"],
input_version=os.environ.get("INPUT_VERSION", ""),
ref_name=os.environ.get("REF_NAME", ""),
tag_prefix=TAG_PREFIX,
)
write_cli_version_outputs(info)
def build_binary_step() -> None:
from ci_utils import run_bash
run_bash(
f"""
set -euo pipefail
cd {PROJECT_DIR}
go build -ldflags=\"-s -w\" -o {BINARY_PREFIX}-${{GOOS}}-${{GOARCH}} .
"""
)
def prepare_release_assets_step(artifacts_dir: pathlib.Path, release_dir: pathlib.Path) -> None:
prepare_release_assets(
artifacts_dir=artifacts_dir,
release_dir=release_dir,
binary_prefix=BINARY_PREFIX,
)
def generate_checksums_step(release_dir: pathlib.Path) -> None:
files = release_dir.glob(f"{BINARY_PREFIX}-*")
generate_checksums(files, release_dir / "checksums.txt")
def create_tag_step() -> None:
from ci_utils import run_bash
from ci_utils import require_env
require_env(["TAG", "VERSION"])
run_bash(
"""
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "${TAG}" -m "Release livekitctl v${VERSION}"
git push origin "${TAG}"
"""
)
STEPS = {
"determine_version": determine_version_step,
"build_binary": build_binary_step,
"prepare_release_assets": prepare_release_assets_step,
"generate_checksums": generate_checksums_step,
"create_tag": create_tag_step,
}
ENV_ARGS = [
EnvArg("--event-name", "EVENT_NAME"),
EnvArg("--input-version", "INPUT_VERSION"),
EnvArg("--ref-name", "REF_NAME"),
EnvArg("--version", "VERSION"),
EnvArg("--tag", "TAG"),
]
def parse_args():
parser = build_step_parser(ENV_ARGS)
parser.add_argument("--artifacts-dir", default="artifacts")
parser.add_argument("--release-dir", default="release")
return parser.parse_args()
def main() -> int:
args = parse_args()
apply_env_args(args, ENV_ARGS)
if args.step == "prepare_release_assets":
prepare_release_assets_step(
pathlib.Path(args.artifacts_dir),
pathlib.Path(args.release_dir),
)
return 0
if args.step == "generate_checksums":
generate_checksums_step(pathlib.Path(args.release_dir))
return 0
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from release_workflow import build_release_steps, run_release_workflow
STEPS = build_release_steps(
title="Fluxer Relay release",
image_name_env="IMAGE_NAME",
)
def main() -> int:
return run_release_workflow(
title="Fluxer Relay release",
image_name_arg="--image-name",
image_name_env="IMAGE_NAME",
)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from release_workflow import build_release_steps, run_release_workflow
STEPS = build_release_steps(
title="Fluxer Relay Directory release",
image_name_env="IMAGE_NAME",
)
def main() -> int:
return run_release_workflow(
title="Fluxer Relay Directory release",
image_name_arg="--image-name",
image_name_env="IMAGE_NAME",
)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_workflow import EnvArg
from ci_utils import require_env, write_github_output
from release_workflow import build_release_steps, run_release_workflow
def determine_build_targets_step() -> None:
import os
require_env(["EVENT_NAME"])
if os.environ["EVENT_NAME"] == "workflow_dispatch":
write_github_output({"server": os.environ.get("BUILD_SERVER_INPUT", "")})
return
write_github_output({"server": "true"})
EXTRA_ENV_ARGS = [
EnvArg("--event-name", "EVENT_NAME"),
EnvArg("--build-server-input", "BUILD_SERVER_INPUT"),
]
STEPS = build_release_steps(
title="Fluxer Server release",
image_name_env="IMAGE_NAME_SERVER",
extra_steps={"determine_build_targets": determine_build_targets_step},
)
def main() -> int:
return run_release_workflow(
title="Fluxer Server release",
image_name_arg="--image-name-server",
image_name_env="IMAGE_NAME_SERVER",
extra_steps={"determine_build_targets": determine_build_targets_step},
extra_env_args=EXTRA_ENV_ARGS,
)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_workflow import EnvArg
from deploy_workflow import build_standard_deploy_steps, run_deploy_workflow
VALIDATE_CONFIRMATION_STEP = """
set -euo pipefail
if [ "${CONFIRMATION}" != "RESTART" ]; then
echo "::error::Confirmation failed. You must type 'RESTART' to proceed with a full restart."
echo "::error::For regular updates, use deploy-gateway.yaml instead."
exit 1
fi
"""
PUSH_AND_DEPLOY_SCRIPT = """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" "IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK} RELEASE_CHANNEL=${RELEASE_CHANNEL} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
sudo mkdir -p "/opt/${SERVICE_NAME}"
sudo chown -R "${USER}:${USER}" "/opt/${SERVICE_NAME}"
cd "/opt/${SERVICE_NAME}"
cat > compose.yaml << COMPOSEEOF
services:
app:
image: ${IMAGE_TAG}
hostname: "{{.Node.Hostname}}-{{.Task.Slot}}"
environment:
- FLUXER_CONFIG=/etc/fluxer/config.json
- FLUXER_GATEWAY_NODE_FLAG=-sname
- FLUXER_GATEWAY_NODE_NAME=fluxer_gateway_{{.Node.ID}}_{{.Task.Slot}}
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
replicas: 1
endpoint_mode: dnsrr
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
labels:
- 'caddy_gw=gateway.fluxer.app'
- 'caddy_gw.reverse_proxy={{upstreams 8080}}'
networks:
- fluxer-shared
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy --with-registry-auth --detach=false --resolve-image never -c compose.yaml "${COMPOSE_STACK}"
REMOTE_EOF
"""
STEPS = {
"validate_confirmation": VALIDATE_CONFIRMATION_STEP,
**build_standard_deploy_steps(
push_and_deploy_script=PUSH_AND_DEPLOY_SCRIPT,
include_build_timestamp=False,
),
}
def main() -> int:
return run_deploy_workflow(
STEPS,
env_args=[
EnvArg("--confirmation", "CONFIRMATION"),
],
)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_steps import bot_user_id_script
from ci_workflow import EnvArg, parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"get_user_id": bot_user_id_script(),
"determine_branch": """
set -euo pipefail
if [[ -n "${INPUT_BRANCH}" ]]; then
echo "name=${INPUT_BRANCH}" >> "$GITHUB_OUTPUT"
else
echo "name=${REF_NAME}" >> "$GITHUB_OUTPUT"
fi
""",
"clone_target": """
set -euo pipefail
git clone --depth 1 "https://x-access-token:${TOKEN}@github.com/fluxerapp/fluxer_desktop.git" target || {
mkdir target
cd target
git init
git remote add origin "https://x-access-token:${TOKEN}@github.com/fluxerapp/fluxer_desktop.git"
}
""",
"configure_git": """
set -euo pipefail
cd target
git config user.name "${APP_SLUG}[bot]"
git config user.email "${USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
""",
"checkout_or_create_branch": """
set -euo pipefail
cd target
BRANCH="${BRANCH_NAME}"
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
git fetch origin "$BRANCH"
git checkout "$BRANCH"
else
git checkout --orphan "$BRANCH"
git rm -rf . 2>/dev/null || true
fi
""",
"sync_files": """
set -euo pipefail
find target -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
cp -a source/fluxer_desktop/. target/
""",
"commit_and_push": """
set -euo pipefail
cd target
BRANCH="${BRANCH_NAME}"
SOURCE_SHA="$(git -C ../source rev-parse --short HEAD)"
git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Sync from fluxerapp/fluxer @ ${SOURCE_SHA}"
git push origin "HEAD:refs/heads/${BRANCH}"
echo "Synced to fluxerapp/fluxer_desktop:${BRANCH}"
""",
"summary": """
set -euo pipefail
{
echo "## Desktop Sync Complete"
echo ""
echo "- **Source:** \`fluxerapp/fluxer:${BRANCH_NAME}\`"
echo "- **Destination:** \`fluxerapp/fluxer_desktop:${BRANCH_NAME}\`"
echo "- **Commit:** \`$(git -C source rev-parse --short HEAD)\`"
} >> "$GITHUB_STEP_SUMMARY"
""",
}
def main() -> int:
args = parse_step_env_args(
[
EnvArg("--app-slug", "APP_SLUG"),
EnvArg("--token", "TOKEN"),
EnvArg("--user-id", "USER_ID"),
EnvArg("--input-branch", "INPUT_BRANCH"),
EnvArg("--ref-name", "REF_NAME"),
EnvArg("--branch-name", "BRANCH_NAME"),
]
)
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_steps import INSTALL_RCLONE_SCRIPT, rclone_config_script
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"install_rclone": INSTALL_RCLONE_SCRIPT,
"push": rclone_config_script(
endpoint="$RCLONE_ENDPOINT",
acl="private",
expand_vars=True,
)
+ """
mkdir -p "$RCLONE_SOURCE_DIR"
rclone sync "$RCLONE_SOURCE" "$RCLONE_REMOTE:$RCLONE_BUCKET" --create-empty-src-dirs --exclude "assets/**"
""",
}
def main() -> int:
args = parse_step_env_args()
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,281 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS = {
"set_temp_paths": """
set -euo pipefail
: "${RUNNER_TEMP:?RUNNER_TEMP is not set}"
echo "WORKDIR=$RUNNER_TEMP/cassandra-restore-test" >> "$GITHUB_ENV"
""",
"pre_clean": """
set -euo pipefail
docker rm -f "${CASS_CONTAINER}" "${UTIL_CONTAINER}" 2>/dev/null || true
docker volume rm "${CASS_VOLUME}" 2>/dev/null || true
docker volume rm "${BACKUP_VOLUME}" 2>/dev/null || true
rm -rf "${WORKDIR}" 2>/dev/null || true
""",
"install_tools": """
set -euo pipefail
sudo apt-get update -y
sudo apt-get install -y --no-install-recommends rclone age ca-certificates
""",
"fetch_backup": """
set -euo pipefail
rm -rf "$WORKDIR"
mkdir -p "$WORKDIR"
export RCLONE_CONFIG_B2S3_TYPE=s3
export RCLONE_CONFIG_B2S3_PROVIDER=Other
export RCLONE_CONFIG_B2S3_ACCESS_KEY_ID="${B2_KEY_ID}"
export RCLONE_CONFIG_B2S3_SECRET_ACCESS_KEY="${B2_APPLICATION_KEY}"
export RCLONE_CONFIG_B2S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com"
export RCLONE_CONFIG_B2S3_REGION="eu-central-003"
export RCLONE_CONFIG_B2S3_FORCE_PATH_STYLE=true
LATEST_BACKUP="$(
rclone lsf "B2S3:fluxer" --recursive --files-only --fast-list \
| grep -E '(^|/)cassandra-backup-[0-9]{8}-[0-9]{6}\.tar\.age$' \
| sort -r \
| head -n 1
)"
if [ -z "${LATEST_BACKUP}" ]; then
echo "Error: No backup found in bucket"
exit 1
fi
echo "LATEST_BACKUP=${LATEST_BACKUP}" >> "$GITHUB_ENV"
base="$(basename "${LATEST_BACKUP}")"
ts="${base#cassandra-backup-}"
ts="${ts%.tar.age}"
if ! [[ "$ts" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then
echo "Error: Could not extract timestamp from backup filename: ${base}"
exit 1
fi
BACKUP_EPOCH="$(date -u -d "${ts:0:8} ${ts:9:2}:${ts:11:2}:${ts:13:2}" +%s)"
CURRENT_EPOCH="$(date -u +%s)"
AGE_HOURS=$(( (CURRENT_EPOCH - BACKUP_EPOCH) / 3600 ))
echo "Backup age: ${AGE_HOURS} hours"
if [ "${AGE_HOURS}" -ge 3 ]; then
echo "Error: Latest backup is ${AGE_HOURS} hours old (threshold: 3 hours)"
exit 1
fi
rclone copyto "B2S3:fluxer/${LATEST_BACKUP}" "${WORKDIR}/backup.tar.age" --fast-list
umask 077
printf '%s' "${AGE_PRIVATE_KEY}" > "${WORKDIR}/age.key"
docker volume create "${BACKUP_VOLUME}"
age -d -i "${WORKDIR}/age.key" "${WORKDIR}/backup.tar.age" \
| docker run --rm -i \
-v "${BACKUP_VOLUME}:/backup" \
--entrypoint bash \
"${CASSANDRA_IMAGE}" -lc '
set -euo pipefail
rm -rf /backup/*
mkdir -p /backup/_tmp
tar -C /backup/_tmp -xf -
top="$(find /backup/_tmp -maxdepth 1 -mindepth 1 -type d -name "cassandra-backup-*" | head -n 1 || true)"
if [ -n "$top" ] && [ -f "$top/schema.cql" ]; then
cp -a "$top"/. /backup/
elif [ -f /backup/_tmp/schema.cql ]; then
cp -a /backup/_tmp/. /backup/
else
echo "Error: schema.cql not found after extraction"
find /backup/_tmp -maxdepth 3 -type f -print | sed -n "1,80p" || true
exit 1
fi
rm -rf /backup/_tmp
'
docker run --rm \
-v "${BACKUP_VOLUME}:/backup:ro" \
--entrypoint bash \
"${CASSANDRA_IMAGE}" -lc '
set -euo pipefail
test -f /backup/schema.cql
echo "Extracted backup layout (top 3 levels):"
find /backup -maxdepth 3 -type d -print | sed -n "1,200p" || true
echo "Sample SSTables (*Data.db):"
find /backup -type f -name "*Data.db" | sed -n "1,30p" || true
'
""",
"create_data_volume": """
set -euo pipefail
docker volume create "${CASS_VOLUME}"
""",
"restore_keyspaces": """
set -euo pipefail
docker run --rm \
--name "${UTIL_CONTAINER}" \
-v "${CASS_VOLUME}:/var/lib/cassandra" \
-v "${BACKUP_VOLUME}:/backup:ro" \
--entrypoint bash \
"${CASSANDRA_IMAGE}" -lc '
set -euo pipefail
shopt -s nullglob
BASE=/var/lib/cassandra
DATA_DIR="$BASE/data"
mkdir -p "$DATA_DIR" "$BASE/commitlog" "$BASE/hints" "$BASE/saved_caches"
ROOT=/backup
if [ -d "$ROOT/cassandra_data" ]; then ROOT="$ROOT/cassandra_data"; fi
if [ -d "$ROOT/data" ]; then ROOT="$ROOT/data"; fi
echo "Using backup ROOT=$ROOT"
echo "Restoring into DATA_DIR=$DATA_DIR"
restored=0
for keyspace_dir in "$ROOT"/*/; do
[ -d "$keyspace_dir" ] || continue
ks="$(basename "$keyspace_dir")"
if [ "$ks" = "system_schema" ] || ! [[ "$ks" =~ ^system ]]; then
echo "Restoring keyspace: $ks"
rm -rf "$DATA_DIR/$ks"
cp -a "$keyspace_dir" "$DATA_DIR/"
restored=$((restored + 1))
fi
done
if [ "$restored" -le 0 ]; then
echo "Error: No keyspaces restored from backup root: $ROOT"
echo "Debug: listing $ROOT:"
ls -la "$ROOT" || true
find "$ROOT" -maxdepth 2 -type d -print | sed -n "1,100p" || true
exit 1
fi
promoted=0
for ks_dir in "$DATA_DIR"/*/; do
[ -d "$ks_dir" ] || continue
ks="$(basename "$ks_dir")"
if [ "$ks" != "system_schema" ] && [[ "$ks" =~ ^system ]]; then
continue
fi
for table_dir in "$ks_dir"*/; do
[ -d "$table_dir" ] || continue
snap_root="$table_dir/snapshots"
[ -d "$snap_root" ] || continue
latest_snap="$(ls -1d "$snap_root"/*/ 2>/dev/null | sort -r | head -n 1 || true)"
[ -n "$latest_snap" ] || continue
files=( "$latest_snap"* )
if [ "${#files[@]}" -gt 0 ]; then
cp -av "${files[@]}" "$table_dir"
promoted=$((promoted + $(ls -1 "$latest_snap"/*Data.db 2>/dev/null | wc -l || true)))
fi
done
done
chown -R cassandra:cassandra "$BASE"
echo "Promoted Data.db files: $promoted"
if [ "$promoted" -le 0 ]; then
echo "Error: No *Data.db files were promoted out of snapshots"
echo "Debug: first snapshot dirs found:"
find "$DATA_DIR" -type d -path "*/snapshots/*" | sed -n "1,50p" || true
exit 1
fi
'
""",
"start_cassandra": """
set -euo pipefail
docker run -d \
--name "${CASS_CONTAINER}" \
-v "${CASS_VOLUME}:/var/lib/cassandra" \
-e MAX_HEAP_SIZE="${MAX_HEAP_SIZE}" \
-e HEAP_NEWSIZE="${HEAP_NEWSIZE}" \
-e JVM_OPTS="-Dcassandra.disable_mlock=true" \
"${CASSANDRA_IMAGE}"
for i in $(seq 1 150); do
status="$(docker inspect -f '{{.State.Status}}' "${CASS_CONTAINER}" 2>/dev/null || true)"
if [ "${status}" != "running" ]; then
docker inspect "${CASS_CONTAINER}" --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}}' || true
docker logs --tail 300 "${CASS_CONTAINER}" || true
exit 1
fi
if docker exec "${CASS_CONTAINER}" cqlsh -e "SELECT now() FROM system.local;" >/dev/null 2>&1; then
break
fi
sleep 2
done
docker exec "${CASS_CONTAINER}" cqlsh -e "SELECT now() FROM system.local;" >/dev/null 2>&1
""",
"verify_data": """
set -euo pipefail
USER_COUNT=""
for i in $(seq 1 20); do
USER_COUNT="$(
docker exec "${CASS_CONTAINER}" cqlsh -e "SELECT COUNT(*) FROM fluxer.users;" 2>/dev/null \
| awk "/^[[:space:]]*[0-9]+[[:space:]]*$/ {print \$1; exit}" || true
)"
if [ -n "${USER_COUNT}" ]; then
break
fi
sleep 2
done
if [ -n "${USER_COUNT}" ] && [ "${USER_COUNT}" -gt 0 ] 2>/dev/null; then
echo "Backup restore verification passed"
else
echo "Backup restore verification failed"
docker logs --tail 300 "${CASS_CONTAINER}" || true
exit 1
fi
""",
"cleanup": """
set -euo pipefail
docker rm -f "${CASS_CONTAINER}" 2>/dev/null || true
docker volume rm "${CASS_VOLUME}" 2>/dev/null || true
docker volume rm "${BACKUP_VOLUME}" 2>/dev/null || true
rm -rf "${WORKDIR}" 2>/dev/null || true
""",
"report_status": """
set -euo pipefail
LATEST_BACKUP_NAME="${LATEST_BACKUP:-unknown}"
if [ "${JOB_STATUS}" = "success" ]; then
echo "Backup ${LATEST_BACKUP_NAME} is valid and restorable"
else
echo "Backup ${LATEST_BACKUP_NAME} test failed"
fi
""",
}
def main() -> int:
args = parse_step_env_args()
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"download": """
set -euo pipefail
curl -fsSL https://raw.githubusercontent.com/tailscale/tailscale/refs/heads/main/words/scales.txt -o /tmp/scales.txt
curl -fsSL https://raw.githubusercontent.com/tailscale/tailscale/refs/heads/main/words/tails.txt -o /tmp/tails.txt
""",
"check_changes": """
set -euo pipefail
if ! diff -q /tmp/scales.txt fluxer_api/src/words/scales.txt > /dev/null 2>&1 || \
! diff -q /tmp/tails.txt fluxer_api/src/words/tails.txt > /dev/null 2>&1; then
printf 'changes_detected=true\n' >> "$GITHUB_OUTPUT"
echo "Changes detected in word lists"
else
printf 'changes_detected=false\n' >> "$GITHUB_OUTPUT"
echo "No changes detected in word lists"
fi
""",
"update": """
set -euo pipefail
cp /tmp/scales.txt fluxer_api/src/words/scales.txt
cp /tmp/tails.txt fluxer_api/src/words/tails.txt
""",
"no_changes": """
echo "Word lists are already up to date."
""",
}
def main() -> int:
args = parse_step_env_args()
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())

511
scripts/dev_bootstrap.sh Executable file
View File

@@ -0,0 +1,511 @@
#!/usr/bin/env sh
# Copyright (C) 2026 Fluxer Contributors
#
# This file is part of Fluxer.
#
# Fluxer is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Fluxer is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
info() { printf "%b\n" "${GREEN}[INFO]${NC} $1"; }
warn() { printf "%b\n" "${YELLOW}[WARN]${NC} $1"; }
error() { printf "%b\n" "${RED}[ERROR]${NC} $1"; }
prepare_log_dir() {
info "Ensuring dev log directory exists..."
mkdir -p "$REPO_ROOT/dev/logs"
}
check_config() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
template_path="$REPO_ROOT/config/config.dev.template.json"
if [ ! -f "$config_path" ]; then
if [ -f "$template_path" ]; then
info "No config found, creating from development template..."
cp "$template_path" "$config_path"
else
error "Configuration file not found: $config_path"
error "Template file also missing: $template_path"
exit 1
fi
fi
}
random_hex() {
byte_count="$1"
node - "$byte_count" <<'NODE'
const {randomBytes} = require('node:crypto');
const byteCount = Number(process.argv[2]);
if (!Number.isInteger(byteCount) || byteCount <= 0) {
process.exit(1);
}
process.stdout.write(randomBytes(byteCount).toString('hex'));
NODE
}
is_empty_or_placeholder() {
value="$1"
shift
if [ -z "$value" ]; then
return 0
fi
for placeholder in "$@"; do
if [ "$value" = "$placeholder" ]; then
return 0
fi
done
return 1
}
seed_hex_secret() {
current_value="$1"
byte_count="$2"
shift 2
if is_empty_or_placeholder "$current_value" "$@"; then
random_hex "$byte_count"
else
printf '%s' "$current_value"
fi
}
ensure_core_secrets() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
info "Checking development secret configuration..."
if [ ! -f "$config_path" ]; then
warn "Config file not found, skipping secret generation"
return 0
fi
current_s3_access_key_id=$(jq -r '.s3.access_key_id // empty' "$config_path" 2>/dev/null || true)
current_s3_secret_access_key=$(jq -r '.s3.secret_access_key // empty' "$config_path" 2>/dev/null || true)
current_media_proxy_secret_key=$(jq -r '.services.media_proxy.secret_key // empty' "$config_path" 2>/dev/null || true)
current_admin_secret_key_base=$(jq -r '.services.admin.secret_key_base // empty' "$config_path" 2>/dev/null || true)
current_admin_oauth_client_secret=$(jq -r '.services.admin.oauth_client_secret // empty' "$config_path" 2>/dev/null || true)
current_marketing_secret_key_base=$(jq -r '.services.marketing.secret_key_base // empty' "$config_path" 2>/dev/null || true)
current_gateway_admin_reload_secret=$(jq -r '.services.gateway.admin_reload_secret // empty' "$config_path" 2>/dev/null || true)
current_queue_secret=$(jq -r '.services.queue.secret // empty' "$config_path" 2>/dev/null || true)
current_meilisearch_api_key=$(jq -r '.integrations.search.api_key // empty' "$config_path" 2>/dev/null || true)
current_rpc_secret=$(jq -r '.gateway.rpc_secret // empty' "$config_path" 2>/dev/null || true)
current_sudo_mode_secret=$(jq -r '.auth.sudo_mode_secret // empty' "$config_path" 2>/dev/null || true)
current_connection_initiation_secret=$(jq -r '.auth.connection_initiation_secret // empty' "$config_path" 2>/dev/null || true)
current_smtp_password=$(jq -r '.integrations.email.smtp.password // empty' "$config_path" 2>/dev/null || true)
current_voice_api_key=$(jq -r '.integrations.voice.api_key // empty' "$config_path" 2>/dev/null || true)
current_voice_api_secret=$(jq -r '.integrations.voice.api_secret // empty' "$config_path" 2>/dev/null || true)
has_smtp=$(jq -r '.integrations.email.smtp != null' "$config_path" 2>/dev/null || echo "false")
has_marketing=$(jq -r '.services.marketing != null' "$config_path" 2>/dev/null || echo "false")
has_queue=$(jq -r '.services.queue != null' "$config_path" 2>/dev/null || echo "false")
has_search=$(jq -r '.integrations.search != null' "$config_path" 2>/dev/null || echo "false")
has_voice=$(jq -r '.integrations.voice != null' "$config_path" 2>/dev/null || echo "false")
seeded_s3_access_key_id=$(seed_hex_secret "$current_s3_access_key_id" 16 "dev-access-key" "fluxer-dev-access-key")
seeded_s3_secret_access_key=$(seed_hex_secret "$current_s3_secret_access_key" 32 "dev-secret-key" "fluxer-dev-secret-key")
seeded_media_proxy_secret_key=$(seed_hex_secret "$current_media_proxy_secret_key" 32 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
seeded_admin_secret_key_base=$(seed_hex_secret "$current_admin_secret_key_base" 32 "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
seeded_admin_oauth_client_secret=$(seed_hex_secret "$current_admin_oauth_client_secret" 32 "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210")
seeded_marketing_secret_key_base="$current_marketing_secret_key_base"
if [ "$has_marketing" = "true" ]; then
seeded_marketing_secret_key_base=$(seed_hex_secret "$current_marketing_secret_key_base" 32 "marketing0123456789abcdef0123456789abcdef0123456789abcdef01234567")
fi
seeded_gateway_admin_reload_secret=$(seed_hex_secret "$current_gateway_admin_reload_secret" 32 "deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567")
seeded_queue_secret="$current_queue_secret"
if [ "$has_queue" = "true" ]; then
seeded_queue_secret=$(seed_hex_secret "$current_queue_secret" 32 "queue00123456789abcdef0123456789abcdef0123456789abcdef0123456789")
fi
seeded_meilisearch_api_key="$current_meilisearch_api_key"
if [ "$has_search" = "true" ]; then
seeded_meilisearch_api_key=$(seed_hex_secret "$current_meilisearch_api_key" 32 "meilisearch0123456789abcdef0123456789abcdef0123456789abcdef012345")
fi
seeded_rpc_secret=$(seed_hex_secret "$current_rpc_secret" 32 "cafebabe0123456789abcdef0123456789abcdef0123456789abcdef01234567")
seeded_sudo_mode_secret=$(seed_hex_secret "$current_sudo_mode_secret" 32 "c0ffee000123456789abcdef0123456789abcdef0123456789abcdef01234567")
seeded_connection_initiation_secret=$(seed_hex_secret "$current_connection_initiation_secret" 32 "d0d0ca000123456789abcdef0123456789abcdef0123456789abcdef01234567")
seeded_smtp_password="$current_smtp_password"
if [ "$has_smtp" = "true" ]; then
seeded_smtp_password=$(seed_hex_secret "$current_smtp_password" 16 "dev")
fi
seeded_voice_api_key="$current_voice_api_key"
seeded_voice_api_secret="$current_voice_api_secret"
if [ "$has_voice" = "true" ]; then
seeded_voice_api_key=$(seed_hex_secret "$current_voice_api_key" 32 "5VCKLGhj3Yz0q2GIBnuumpOP1GlSTSw5mLPZDvZNIvQpiocQXDQIwTS5CRrnOhe7" "devkey")
seeded_voice_api_secret=$(seed_hex_secret "$current_voice_api_secret" 32 "devsecret")
fi
has_changes=false
if [ "$seeded_s3_access_key_id" != "$current_s3_access_key_id" ]; then has_changes=true; fi
if [ "$seeded_s3_secret_access_key" != "$current_s3_secret_access_key" ]; then has_changes=true; fi
if [ "$seeded_media_proxy_secret_key" != "$current_media_proxy_secret_key" ]; then has_changes=true; fi
if [ "$seeded_admin_secret_key_base" != "$current_admin_secret_key_base" ]; then has_changes=true; fi
if [ "$seeded_admin_oauth_client_secret" != "$current_admin_oauth_client_secret" ]; then has_changes=true; fi
if [ "$has_marketing" = "true" ] && [ "$seeded_marketing_secret_key_base" != "$current_marketing_secret_key_base" ]; then has_changes=true; fi
if [ "$seeded_gateway_admin_reload_secret" != "$current_gateway_admin_reload_secret" ]; then has_changes=true; fi
if [ "$has_queue" = "true" ] && [ "$seeded_queue_secret" != "$current_queue_secret" ]; then has_changes=true; fi
if [ "$has_search" = "true" ] && [ "$seeded_meilisearch_api_key" != "$current_meilisearch_api_key" ]; then has_changes=true; fi
if [ "$seeded_rpc_secret" != "$current_rpc_secret" ]; then has_changes=true; fi
if [ "$seeded_sudo_mode_secret" != "$current_sudo_mode_secret" ]; then has_changes=true; fi
if [ "$seeded_connection_initiation_secret" != "$current_connection_initiation_secret" ]; then has_changes=true; fi
if [ "$has_smtp" = "true" ] && [ "$seeded_smtp_password" != "$current_smtp_password" ]; then has_changes=true; fi
if [ "$has_voice" = "true" ] && [ "$seeded_voice_api_key" != "$current_voice_api_key" ]; then has_changes=true; fi
if [ "$has_voice" = "true" ] && [ "$seeded_voice_api_secret" != "$current_voice_api_secret" ]; then has_changes=true; fi
if [ "$has_changes" = false ]; then
info "Development secrets already configured"
return 0
fi
# Development secrets are generated locally during bootstrap to avoid
# committing placeholder values that look like real credentials.
info "Generating local development secrets..."
temp_config="$config_path.tmp"
jq \
--arg s3_access_key_id "$seeded_s3_access_key_id" \
--arg s3_secret_access_key "$seeded_s3_secret_access_key" \
--arg media_proxy_secret_key "$seeded_media_proxy_secret_key" \
--arg admin_secret_key_base "$seeded_admin_secret_key_base" \
--arg admin_oauth_client_secret "$seeded_admin_oauth_client_secret" \
--arg marketing_secret_key_base "$seeded_marketing_secret_key_base" \
--arg gateway_admin_reload_secret "$seeded_gateway_admin_reload_secret" \
--arg queue_secret "$seeded_queue_secret" \
--arg meilisearch_api_key "$seeded_meilisearch_api_key" \
--arg rpc_secret "$seeded_rpc_secret" \
--arg sudo_mode_secret "$seeded_sudo_mode_secret" \
--arg connection_initiation_secret "$seeded_connection_initiation_secret" \
--arg smtp_password "$seeded_smtp_password" \
--arg voice_api_key "$seeded_voice_api_key" \
--arg voice_api_secret "$seeded_voice_api_secret" \
'.s3.access_key_id = $s3_access_key_id |
.s3.secret_access_key = $s3_secret_access_key |
.services.media_proxy.secret_key = $media_proxy_secret_key |
.services.admin.secret_key_base = $admin_secret_key_base |
.services.admin.oauth_client_secret = $admin_oauth_client_secret |
(if .services.marketing != null then .services.marketing.secret_key_base = $marketing_secret_key_base else . end) |
.services.gateway.admin_reload_secret = $gateway_admin_reload_secret |
(if .services.queue != null then .services.queue.secret = $queue_secret else . end) |
(if .integrations.search != null then .integrations.search.api_key = $meilisearch_api_key else . end) |
.gateway.rpc_secret = $rpc_secret |
.auth.sudo_mode_secret = $sudo_mode_secret |
.auth.connection_initiation_secret = $connection_initiation_secret |
(if .integrations.email.smtp != null then .integrations.email.smtp.password = $smtp_password else . end) |
(if .integrations.voice != null then .integrations.voice.api_key = $voice_api_key | .integrations.voice.api_secret = $voice_api_secret else . end)' \
"$config_path" > "$temp_config"
if [ $? -eq 0 ]; then
mv "$temp_config" "$config_path"
info "Development secrets configured"
if [ "$has_search" = "true" ]; then
# Keep a local copy for the devenv Meilisearch process to read.
meilisearch_key_path="$REPO_ROOT/dev/meilisearch_master_key"
meilisearch_key_file_value="$(cat "$meilisearch_key_path" 2>/dev/null || true)"
if is_empty_or_placeholder "$meilisearch_key_file_value" ""; then
printf '%s' "$seeded_meilisearch_api_key" > "$meilisearch_key_path"
chmod 600 "$meilisearch_key_path" 2>/dev/null || true
fi
mkdir -p "$REPO_ROOT/dev/data/meilisearch"
fi
else
error "Failed to update config.json with development secrets"
rm -f "$temp_config"
return 1
fi
}
validate_vapid_keys() {
public_key="$1"
private_key="$2"
node - "$public_key" "$private_key" >/dev/null 2>&1 <<'NODE'
const [publicKey, privateKey] = process.argv.slice(2);
try {
if (!publicKey || !privateKey) {
process.exit(1);
}
const publicRaw = Buffer.from(publicKey, 'base64url');
const privateRaw = Buffer.from(privateKey, 'base64url');
if (publicRaw.length !== 65 || publicRaw[0] !== 0x04 || privateRaw.length !== 32) {
process.exit(1);
}
process.exit(0);
} catch (_error) {
process.exit(1);
}
NODE
}
generate_vapid_keypair() {
node - <<'NODE'
const {generateKeyPairSync} = require('node:crypto');
const {privateKey, publicKey} = generateKeyPairSync('ec', {namedCurve: 'prime256v1'});
const publicJwk = publicKey.export({format: 'jwk'});
const privateJwk = privateKey.export({format: 'jwk'});
const publicRaw = Buffer.concat([
Buffer.from([0x04]),
Buffer.from(publicJwk.x, 'base64url'),
Buffer.from(publicJwk.y, 'base64url'),
]);
process.stdout.write(
JSON.stringify({
public_key: publicRaw.toString('base64url'),
private_key: privateJwk.d,
})
);
NODE
}
ensure_vapid_keys() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
info "Checking VAPID configuration..."
if [ ! -f "$config_path" ]; then
warn "Config file not found, skipping VAPID key generation"
return 0
fi
vapid_public_key=$(jq -r '.auth.vapid.public_key // empty' "$config_path" 2>/dev/null || true)
vapid_private_key=$(jq -r '.auth.vapid.private_key // empty' "$config_path" 2>/dev/null || true)
if validate_vapid_keys "$vapid_public_key" "$vapid_private_key"; then
info "VAPID keys already configured"
return 0
fi
# Development VAPID keys are generated locally by bootstrap, not issued by
# an external provider. There is no external renewal process if keys are
# missing or invalid we generate a fresh pair here.
info "Generating development-only VAPID keypair..."
vapid_keys_json=$(generate_vapid_keypair)
generated_public_key=$(printf '%s' "$vapid_keys_json" | jq -r '.public_key // empty')
generated_private_key=$(printf '%s' "$vapid_keys_json" | jq -r '.private_key // empty')
if ! validate_vapid_keys "$generated_public_key" "$generated_private_key"; then
error "Failed to generate valid VAPID keys"
return 1
fi
temp_config="$config_path.tmp"
jq --arg vapid_public_key "$generated_public_key" \
--arg vapid_private_key "$generated_private_key" \
'.auth.vapid.public_key = $vapid_public_key |
.auth.vapid.private_key = $vapid_private_key' "$config_path" > "$temp_config"
if [ $? -eq 0 ]; then
mv "$temp_config" "$config_path"
info "VAPID keys configured for development"
else
error "Failed to update config.json with VAPID keys"
rm -f "$temp_config"
return 1
fi
}
generate_bluesky_oauth_keys() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
key_path="$REPO_ROOT/dev/bluesky_oauth_key.pem"
info "Checking Bluesky OAuth configuration..."
if [ ! -f "$config_path" ]; then
warn "Config file not found, skipping Bluesky OAuth key generation"
return 0
fi
keys_length=$(jq -r '.auth.bluesky.keys | length' "$config_path" 2>/dev/null || echo "0")
if [ "$keys_length" != "0" ]; then
all_keys_exist=true
for key_file in $(jq -r '.auth.bluesky.keys[].private_key_path // empty' "$config_path" 2>/dev/null); do
if [ ! -f "$key_file" ]; then
warn "Configured key file missing: $key_file"
all_keys_exist=false
continue
fi
if ! openssl pkey -in "$key_file" -text -noout 2>/dev/null | grep -Eq "prime256v1|secp256r1"; then
warn "Configured key file is not an ES256 (P-256) key: $key_file"
all_keys_exist=false
elif ! openssl pkcs8 -topk8 -nocrypt -in "$key_file" -out /dev/null >/dev/null 2>&1; then
warn "Configured key file is not PKCS#8 encoded: $key_file"
all_keys_exist=false
fi
done
if [ "$all_keys_exist" = true ]; then
info "Bluesky OAuth keys already configured"
return 0
fi
info "Regenerating Bluesky OAuth key files..."
fi
info "Generating Bluesky OAuth ES256 (P-256) keypair..."
mkdir -p "$REPO_ROOT/dev"
if ! openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out "$key_path" >/dev/null 2>&1; then
error "Failed to generate ES256 key for Bluesky OAuth"
return 1
fi
info "Generated ES256 key at: $key_path"
info "Updating config.json with Bluesky OAuth key..."
temp_config="$config_path.tmp"
jq --arg kid "dev-key-1" \
--arg key_path "$key_path" \
'.auth.bluesky.enabled = true |
.auth.bluesky.logo_uri = "https://fluxerstatic.com/web/apple-touch-icon.png" |
.auth.bluesky.tos_uri = "https://fluxer.app/terms" |
.auth.bluesky.policy_uri = "https://fluxer.app/privacy" |
.auth.bluesky.token_endpoint_auth_signing_alg = "ES256" |
.auth.bluesky.keys = [{
"kid": $kid,
"private_key_path": $key_path
}]' "$config_path" > "$temp_config"
if [ $? -eq 0 ]; then
mv "$temp_config" "$config_path"
info "Bluesky OAuth configured with dev key (enabled: true)"
else
error "Failed to update config.json with Bluesky OAuth key"
rm -f "$temp_config"
return 1
fi
}
generate_livekit_config() {
config_path="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
livekit_config="$REPO_ROOT/dev/livekit.yaml"
template="$REPO_ROOT/dev/livekit.template.yaml"
info "Generating LiveKit configuration..."
api_key=
api_secret=
webhook_url=
base_domain=
api_key=$(jq -r '.integrations.voice.api_key // empty' "$config_path" 2>/dev/null || true)
api_secret=$(jq -r '.integrations.voice.api_secret // empty' "$config_path" 2>/dev/null || true)
webhook_url=$(jq -r '.integrations.voice.webhook_url // empty' "$config_path" 2>/dev/null || true)
base_domain=$(jq -r '.domain.base_domain // empty' "$config_path" 2>/dev/null || true)
api_key="${api_key:-devkey}"
api_secret="${api_secret:-devsecret}"
webhook_url="${webhook_url:-http://localhost:49319/api/webhooks/livekit}"
base_domain="${base_domain:-localhost}"
if [ "$base_domain" = "localhost" ] || [ "$base_domain" = "127.0.0.1" ]; then
node_ip="127.0.0.1"
turn_domain="localhost"
else
turn_domain="$base_domain"
node_ip=$(curl -4 -sf --max-time 5 https://ifconfig.me 2>/dev/null || true)
if [ -z "$node_ip" ]; then
node_ip=$(curl -4 -sf --max-time 5 https://api.ipify.org 2>/dev/null || true)
fi
if [ -z "$node_ip" ]; then
warn "Could not resolve public IP for LiveKit. Voice may not work for remote clients."
warn "Set rtc.node_ip manually in dev/livekit.yaml to your server's public IP."
node_ip="127.0.0.1"
else
info "Resolved public IP for LiveKit: $node_ip"
fi
fi
sed -e "s|{{API_KEY}}|$api_key|g" \
-e "s|{{API_SECRET}}|$api_secret|g" \
-e "s|{{WEBHOOK_URL}}|$webhook_url|g" \
-e "s|{{NODE_IP}}|$node_ip|g" \
-e "s|{{TURN_DOMAIN}}|$turn_domain|g" \
"$template" > "$livekit_config"
info "LiveKit config generated at: $livekit_config (domain: $base_domain, node_ip: $node_ip)"
}
setup_model_symlink() {
source="$REPO_ROOT/fluxer_media_proxy/data/model.onnx"
target_dir="$REPO_ROOT/fluxer_server/data"
target="$target_dir/model.onnx"
info "Setting up ONNX model symlink..."
if [ ! -f "$source" ]; then
warn "Source model not found: $source"
warn "NSFW detection will not work until model.onnx is provided"
return 0
fi
mkdir -p "$target_dir"
if ls -ld "$target" 2>/dev/null | grep -q '^l'; then
info "Model symlink already exists"
elif [ -f "$target" ]; then
source_size=$(stat -f%z "$source" 2>/dev/null || stat -c%s "$source" 2>/dev/null)
target_size=$(stat -f%z "$target" 2>/dev/null || stat -c%s "$target" 2>/dev/null)
if [ "$target_size" -lt 1000 ]; then
info "Replacing empty/corrupt model file with symlink"
rm -f "$target"
ln -s "$source" "$target"
else
info "Model file already exists (not a symlink)"
fi
else
ln -s "$source" "$target"
info "Created model symlink: $target -> $source"
fi
}
main() {
echo ""
info "Fluxer Development Bootstrap"
echo ""
prepare_log_dir
check_config
ensure_core_secrets
ensure_vapid_keys
generate_bluesky_oauth_keys
generate_livekit_config
setup_model_symlink
echo ""
info "Bootstrap complete"
echo ""
}
main "$@"

59
scripts/dev_css_watch.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Copyright (C) 2026 Fluxer Contributors
#
# This file is part of Fluxer.
#
# Fluxer is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Fluxer is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
admin_dir="$repo_root/packages/admin"
marketing_dir="$repo_root/packages/marketing"
shutting_down=0
shutdown() {
shutting_down=1
kill -TERM "$P1" "$P2" 2>/dev/null || true
wait "$P1" "$P2" 2>/dev/null || true
exit 0
}
(
cd "$admin_dir"
./node_modules/.bin/tailwindcss -i ./src/styles/app.css -o ./public/static/app.css --watch=always
) &
P1=$!
(
cd "$marketing_dir"
./node_modules/.bin/tailwindcss -i ./src/styles/app.css -o ./public/static/app.css --watch=always
) &
P2=$!
trap shutdown INT TERM
wait -n "$P1" "$P2"
status=$?
if [ "$shutting_down" -eq 1 ]; then
exit 0
fi
shutdown
exit "$status"

36
scripts/dev_fluxer_app.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
app_dir="$repo_root/fluxer_app"
shutting_down=0
child_pid=""
shutdown() {
shutting_down=1
if [ -n "$child_pid" ] && kill -0 "$child_pid" 2>/dev/null; then
kill -TERM "$child_pid" 2>/dev/null || true
wait "$child_pid" 2>/dev/null || true
fi
exit 0
}
trap shutdown INT TERM
(
cd "$app_dir"
./node_modules/.bin/tsx scripts/DevServer.tsx
) &
child_pid=$!
wait "$child_pid"
status=$?
if [ "$shutting_down" -eq 1 ]; then
exit 0
fi
exit "$status"

49
scripts/dev_gateway.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env sh
# Copyright (C) 2026 Fluxer Contributors
#
# This file is part of Fluxer.
#
# Fluxer is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Fluxer is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
export FLUXER_CONFIG="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
cd fluxer_gateway
# Ensure asdf shims are available for Erlang/OTP selection in non-interactive shells.
ASDF_SHIMS_PATH="${ASDF_DATA_DIR:-$HOME/.asdf}/shims"
if [ -d "$ASDF_SHIMS_PATH" ]; then
export PATH="$ASDF_SHIMS_PATH:$PATH"
fi
# Render config templates.
export FLUXER_GATEWAY_NODE_FLAG="${FLUXER_GATEWAY_NODE_FLAG:--name}"
export FLUXER_GATEWAY_NODE_NAME="${FLUXER_GATEWAY_NODE_NAME:-fluxer_gateway@127.0.0.1}"
export LOGGER_LEVEL="${LOGGER_LEVEL:-debug}"
envsubst < config/sys.config.template > config/sys.config
echo "Building release (node=${FLUXER_GATEWAY_NODE_NAME})..."
./scripts/rebar3_wrapper.sh as dev release
echo "Starting gateway release..."
if [ "${FLUXER_GATEWAY_NO_SHELL:-0}" = "1" ]; then
export ERL_FLAGS="${ERL_FLAGS:-} -noshell -noinput"
fi
exec _build/dev/rel/fluxer_gateway/bin/fluxer_gateway foreground

50
scripts/dev_process_entry.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -lt 2 ]; then
echo "Usage: $0 <service_name> <command...>" >&2
exit 1
fi
service_name="$1"
shift
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
runtime_dir="${DEVENV_RUNTIME:-${XDG_RUNTIME_DIR:-/tmp}}"
lock_dir="${runtime_dir}/fluxer_dev_bootstrap.lock"
stamp_file="${runtime_dir}/fluxer_dev_bootstrap.done"
mkdir -p "$repo_root/dev/logs"
if [ "${FLUXER_SKIP_BOOTSTRAP:-0}" != "1" ]; then
if [ "${FLUXER_FORCE_BOOTSTRAP:-0}" = "1" ] || [ ! -f "$stamp_file" ]; then
if mkdir "$lock_dir" 2>/dev/null; then
trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT
# Keep bootstrap output out of per-service logs.
rm -f "$stamp_file" 2>/dev/null || true
"$repo_root/scripts/dev_bootstrap.sh" >"$repo_root/dev/logs/bootstrap.log" 2>"$repo_root/dev/logs/bootstrap.err.log"
date -Is >"$stamp_file"
rmdir "$lock_dir" 2>/dev/null || true
trap - EXIT
else
# Another process is bootstrapping wait for completion.
wait_started_at="$(date +%s)"
while [ ! -f "$stamp_file" ]; do
if [ "$(( $(date +%s) - wait_started_at ))" -gt 120 ]; then
echo "Timed out waiting for bootstrap to complete (service=$service_name)." >&2
echo "If this persists, stop the stack and remove ${lock_dir} and ${stamp_file}." >&2
exit 1
fi
sleep 0.2
done
fi
fi
fi
cd "$repo_root"
exec "$@"

View File

@@ -1,51 +0,0 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
const outputIdx = args.indexOf('--output');
const outputArg = outputIdx >= 0 && args[outputIdx + 1] ? args[outputIdx + 1] : 'dev/livekit.yaml';
const voiceEnabled = (process.env.VOICE_ENABLED || '').trim().toLowerCase() === 'true';
if (!voiceEnabled) {
process.exit(0);
}
const apiKey = (process.env.LIVEKIT_API_KEY || '').trim();
const apiSecret = (process.env.LIVEKIT_API_SECRET || '').trim();
const webhookUrl = (process.env.LIVEKIT_WEBHOOK_URL || '').trim();
if (!apiKey || !apiSecret || !webhookUrl) {
process.exit(0);
}
const redisUrl = (process.env.REDIS_URL || '').trim();
const redisAddr = redisUrl.replace(/^redis:\/\//, '') || 'redis:6379';
const yaml = `port: 7880
redis:
address: "${redisAddr}"
db: 0
keys:
"${apiKey}": "${apiSecret}"
rtc:
tcp_port: 7881
webhook:
api_key: "${apiKey}"
urls:
- "${webhookUrl}"
room:
auto_create: true
max_participants: 100
empty_timeout: 300
development: true
`;
const outputPath = path.resolve(outputArg);
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
fs.writeFileSync(outputPath, yaml, {encoding: 'utf-8', mode: 0o600});

View File

@@ -1,381 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "cc"
version = "1.2.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "find-msvc-tools"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "js-sys"
version = "0.3.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "license-enforcer"
version = "0.1.0"
dependencies = [
"chrono",
"regex",
"walkdir",
]
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

View File

@@ -1,15 +0,0 @@
[package]
name = "license-enforcer"
version = "0.1.0"
edition = "2024"
[dependencies]
walkdir = "2.4"
regex = "1.10"
chrono = "0.4"
[profile.release]
lto = true
panic = "abort"
codegen-units = 1
opt-level = "z"

View File

@@ -1,426 +0,0 @@
#![allow(clippy::four_forward_slashes)]
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
use regex::Regex;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
const TS_LICENSE_HEADER: &str = r"/*
* Copyright (C) {year} Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/";
const ERLANG_LICENSE_HEADER: &str = r"%% Copyright (C) {year} Fluxer Contributors
%%
%% This file is part of Fluxer.
%%
%% Fluxer is free software: you can redistribute it and/or modify
%% it under the terms of the GNU Affero General Public License as published by
%% the Free Software Foundation, either version 3 of the License, or
%% (at your option) any later version.
%%
%% Fluxer is distributed in the hope that it will be useful,
%% but WITHOUT ANY WARRANTY; without even the implied warranty of
%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
%% GNU Affero General Public License for more details.
%%
%% You should have received a copy of the GNU Affero General Public License
%% along with Fluxer. If not, see <https://www.gnu.org/licenses/>.";
const GLEAM_LICENSE_HEADER: &str = r"//// Copyright (C) {year} Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// You should have received a copy of the GNU Affero General Public License
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.";
const SHELL_LICENSE_HEADER: &str = r"# Copyright (C) {year} Fluxer Contributors
#
# This file is part of Fluxer.
#
# Fluxer is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Fluxer is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.";
const BLOCK_COMMENT_EXTS: &[&str] = &[
"ts", "tsx", "js", "jsx", "mjs", "cjs", "css", "go", "rs", "c", "cc", "cpp", "cxx", "h", "hh",
"hpp", "hxx", "mm", "m", "java", "kt", "kts", "swift", "scala", "dart", "cs", "fs",
];
const HASH_LINE_EXTS: &[&str] = &[
"sh", "bash", "zsh", "py", "rb", "ps1", "psm1", "psd1", "ksh", "fish",
];
#[derive(Clone, Copy)]
enum HeaderStyle {
Block,
Line(&'static str),
}
#[derive(Clone, Copy)]
struct FileTemplate {
header: &'static str,
style: HeaderStyle,
}
impl FileTemplate {
const fn new(header: &'static str, style: HeaderStyle) -> Self {
Self { header, style }
}
}
struct Processor {
current_year: i32,
updated: usize,
ignore_patterns: Vec<String>,
}
impl Processor {
fn new() -> Self {
let current_year = chrono::Datelike::year(&chrono::Utc::now());
let mut processor = Processor {
current_year,
updated: 0,
ignore_patterns: Vec::new(),
};
processor.load_gitignore();
processor
}
fn load_gitignore(&mut self) {
if let Ok(file) = fs::File::open(".gitignore") {
let reader = BufReader::new(file);
for line in reader.lines().map_while(Result::ok) {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') {
self.ignore_patterns.push(trimmed.to_string());
}
}
}
}
fn should_ignore(&self, path: &str) -> bool {
if path.contains("fluxer_static") {
return true;
}
for pattern in &self.ignore_patterns {
if self.match_pattern(pattern, path) {
return true;
}
}
false
}
fn match_pattern(&self, pattern: &str, path: &str) -> bool {
if let Some(sub_pattern) = pattern.strip_prefix("**/") {
if sub_pattern.ends_with('/') {
let dir_name = sub_pattern.trim_end_matches('/');
return path
.split(std::path::MAIN_SEPARATOR)
.any(|part| part == dir_name);
}
return path
.split(std::path::MAIN_SEPARATOR)
.any(|part| part == sub_pattern);
}
if pattern.ends_with('/') {
let dir_pattern = pattern.trim_end_matches('/');
return path
.split(std::path::MAIN_SEPARATOR)
.any(|part| part == dir_pattern)
|| path.starts_with(&format!("{dir_pattern}/"));
}
if let Some(p) = pattern.strip_prefix('/') {
return path == p;
}
path.split(std::path::MAIN_SEPARATOR)
.any(|part| part == pattern)
|| Path::new(path).file_name().and_then(|f| f.to_str()) == Some(pattern)
}
fn is_target_file(&self, path: &Path) -> bool {
self.get_template(path).is_some()
}
fn get_template(&self, path: &Path) -> Option<FileTemplate> {
path.extension()
.and_then(|ext| ext.to_str())
.and_then(Self::template_for_extension)
}
fn template_for_extension(ext: &str) -> Option<FileTemplate> {
let normalized = ext.to_ascii_lowercase();
if BLOCK_COMMENT_EXTS.contains(&normalized.as_str()) {
Some(FileTemplate::new(TS_LICENSE_HEADER, HeaderStyle::Block))
} else if HASH_LINE_EXTS.contains(&normalized.as_str()) {
Some(FileTemplate::new(
SHELL_LICENSE_HEADER,
HeaderStyle::Line("#"),
))
} else {
match normalized.as_str() {
"gleam" => Some(FileTemplate::new(
GLEAM_LICENSE_HEADER,
HeaderStyle::Line("////"),
)),
"erl" | "hrl" => Some(FileTemplate::new(
ERLANG_LICENSE_HEADER,
HeaderStyle::Line("%%"),
)),
_ => None,
}
}
}
fn detect_license(&self, content: &str) -> (bool, Option<i32>) {
let lines: Vec<&str> = content.lines().take(25).collect();
let mut has_agpl = false;
let mut has_fluxer = false;
let mut detected_year = None;
let year_regex = Regex::new(r"\b(20\d{2})\b").unwrap();
for line in lines {
let lower = line.to_lowercase();
if lower.contains("gnu affero general public license") || lower.contains("agpl") {
has_agpl = true;
}
if lower.contains("fluxer") {
has_fluxer = true;
}
if lower.contains("copyright")
&& lower.contains("fluxer")
&& detected_year.is_none()
&& let Some(cap) = year_regex.captures(line)
&& let Ok(year) = cap[1].parse::<i32>()
&& (1900..3000).contains(&year)
{
detected_year = Some(year);
}
}
(has_agpl && has_fluxer, detected_year)
}
fn update_year(&self, content: &str, old_year: i32) -> String {
content.replacen(&old_year.to_string(), &self.current_year.to_string(), 1)
}
fn strip_license_header(&self, content: &str, style: HeaderStyle) -> (String, bool) {
let lines: Vec<&str> = content.split('\n').collect();
if lines.is_empty() {
return (content.to_string(), false);
}
let mut prefix_end = 0;
if let Some(first) = lines.get(0) {
if first.starts_with("#!") {
prefix_end = 1;
}
}
let mut header_start = prefix_end;
while header_start < lines.len() && lines[header_start].trim().is_empty() {
header_start += 1;
}
if header_start >= lines.len() {
return (content.to_string(), false);
}
let original_ending = content.ends_with('\n');
let after_idx = match style {
HeaderStyle::Block => {
let first = lines[header_start].trim_start();
if !first.starts_with("/*") {
return (content.to_string(), false);
}
let mut header_end = header_start;
let mut found_end = false;
for i in header_start..lines.len() {
if lines[i].contains("*/") {
header_end = i;
found_end = true;
break;
}
}
if !found_end {
return (content.to_string(), false);
}
let mut after = header_end + 1;
while after < lines.len() && lines[after].trim().is_empty() {
after += 1;
}
after
}
HeaderStyle::Line(prefix) => {
let first = lines[header_start].trim_start();
if !first.starts_with(prefix) {
return (content.to_string(), false);
}
let mut header_end = header_start;
while header_end < lines.len() {
let trimmed = lines[header_end].trim_start();
if trimmed.is_empty() {
break;
}
if trimmed.starts_with(prefix) {
header_end += 1;
continue;
}
break;
}
let mut after = header_end;
while after < lines.len() && lines[after].trim().is_empty() {
after += 1;
}
after
}
};
let mut new_lines = Vec::new();
new_lines.extend_from_slice(&lines[..prefix_end]);
new_lines.extend_from_slice(&lines[after_idx..]);
let mut result = new_lines.join("\n");
if original_ending && !result.ends_with('\n') {
result.push('\n');
}
(result, true)
}
fn add_header(&self, content: &str, template: FileTemplate) -> String {
let header = template
.header
.replace("{year}", &self.current_year.to_string());
if let Some(first_line) = content.lines().next()
&& first_line.starts_with("#!")
{
let rest = content.lines().skip(1).collect::<Vec<_>>().join("\n");
return format!("{first_line}\n\n{header}\n\n{rest}");
}
format!("{header}\n\n{content}")
}
fn process_file(&mut self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let content = fs::read_to_string(path)?;
let template = self.get_template(path).ok_or("Unknown file type")?;
let (has_header, detected_year) = self.detect_license(&content);
let (new_content, action) = if !has_header {
(self.add_header(&content, template), "Added header")
} else {
let (stripped, stripped_ok) = self.strip_license_header(&content, template.style);
if stripped_ok {
(self.add_header(&stripped, template), "Normalized header")
} else if let Some(old_year) = detected_year {
if old_year == self.current_year {
return Ok(());
}
(
self.update_year(&content, old_year),
&format!("Updated year {}{}", old_year, self.current_year) as &str,
)
} else {
return Ok(());
}
};
fs::write(path, new_content)?;
self.updated += 1;
println!("{}: {}", action, path.display());
Ok(())
}
fn walk(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let paths: Vec<PathBuf> = WalkDir::new(".")
.into_iter()
.filter_map(std::result::Result::ok)
.filter(|e| {
let path = e.path();
let path_str = path.to_string_lossy();
!self.should_ignore(&path_str)
&& e.file_type().is_file()
&& self.is_target_file(path)
})
.map(|e| e.path().to_path_buf())
.collect();
for path in paths {
if let Err(e) = self.process_file(&path) {
eprintln!("Error processing {path:?}: {e}");
}
}
Ok(())
}
}
fn main() {
let mut processor = Processor::new();
if let Err(e) = processor.walk() {
eprintln!("Error: {e}");
std::process::exit(1);
}
println!("Updated {} files", processor.updated);
}

22
scripts/run_dev.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env sh
# Copyright (C) 2026 Fluxer Contributors
#
# This file is part of Fluxer.
#
# Fluxer is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Fluxer is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
set -eu
exec devenv up

View File

@@ -1,251 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "clap"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "once_cell_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [
"proc-macro2",
]
[[package]]
name = "snowflake-generator"
version = "0.1.0"
dependencies = [
"clap",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"

View File

@@ -1,13 +0,0 @@
[package]
name = "snowflake-generator"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.4", features = ["derive"] }
[profile.release]
lto = true
panic = "abort"
codegen-units = 1
opt-level = "z"

View File

@@ -1,72 +0,0 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
use clap::Parser;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const FLUXER_EPOCH: i64 = 1420070400000;
#[derive(Parser)]
#[command(name = "snowflake-generator")]
#[command(about = "Generates unique Fluxer snowflake IDs", long_about = None)]
struct Cli {
/// Number of snowflakes to generate
#[arg(long, short, default_value = "1")]
count: usize,
/// Optional Unix timestamp in milliseconds (defaults to current time)
#[arg(long, short)]
timestamp: Option<i64>,
}
fn generate_snowflake(timestamp: Option<i64>) -> i64 {
let ts = match timestamp {
Some(t) => t,
None => SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64,
};
(ts - FLUXER_EPOCH) << 22
}
fn main() {
let cli = Cli::parse();
if cli.count < 1 {
eprintln!("Error: count must be at least 1");
std::process::exit(1);
}
if cli.count == 1 {
let snowflake = generate_snowflake(cli.timestamp);
println!("{snowflake}");
} else {
for _ in 0..cli.count {
let snowflake = generate_snowflake(cli.timestamp);
println!("{snowflake}");
if cli.timestamp.is_none() {
thread::sleep(Duration::from_millis(1));
}
}
}
}

72
scripts/watch_css.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env sh
# Copyright (C) 2026 Fluxer Contributors
#
# This file is part of Fluxer.
#
# Fluxer is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Fluxer is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
set -eu
if [ "$#" -eq 0 ]; then
targets="admin marketing"
else
targets="$*"
fi
pids=""
start_watch() {
target="$1"
case "$target" in
admin)
if [ ! -f "packages/admin/src/styles/app.css" ]; then
echo "Admin css input missing, skipping: packages/admin/src/styles/app.css" >&2
return
fi
pnpm --filter fluxer_admin build:css:watch &
;;
marketing)
if [ ! -f "packages/marketing/src/styles/app.css" ]; then
echo "Marketing css input missing, skipping: packages/marketing/src/styles/app.css" >&2
return
fi
pnpm --filter @fluxer/marketing build:css:watch &
;;
*)
echo "Unknown css watch target: $target" >&2
exit 1
;;
esac
pids="${pids} $!"
}
for target in $targets; do
start_watch "$target"
done
if [ -z "$pids" ]; then
echo "No css watchers started." >&2
exit 1
fi
cleanup() {
for pid in $pids; do
kill "$pid" 2>/dev/null || true
done
}
trap cleanup EXIT INT TERM
wait