use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use portaki_sdk::manifest::ModuleManifest;
use crate::manifest::generator::{MANIFEST_VERSION, SDUI_SCHEMA_VERSION};
use crate::ui;
use crate::update::outdated;
const MODULE_MANIFEST: &str = "portaki.module.json";
const MODULES_DIR: &str = "modules";
const SHARED_PATHS: [&str; 5] = [
"Cargo.toml",
"Cargo.lock",
".cargo/",
"rust-toolchain",
"rust-toolchain.toml",
];
#[derive(Debug, Parser)]
pub struct CiArgs {
#[command(subcommand)]
pub command: CiCommand,
}
#[derive(Debug, Subcommand)]
pub enum CiCommand {
Modules(ModulesArgs),
SdkVersion(SdkVersionArgs),
Check(CheckArgs),
Info(InfoArgs),
Report(ReportArgs),
}
pub async fn run(args: CiArgs) -> Result<()> {
match args.command {
CiCommand::Modules(args) => modules(args),
CiCommand::SdkVersion(args) => sdk_version(args),
CiCommand::Check(args) => check(args).await,
CiCommand::Info(args) => info(args),
CiCommand::Report(args) => report(args).await,
}
}
#[derive(Debug, Parser)]
pub struct ReportArgs {
#[arg(long)]
pub outcome: String,
#[arg(long)]
pub run_url: Option<String>,
#[arg(long)]
pub root: Option<PathBuf>,
#[arg(long)]
pub url: Option<String>,
}
async fn report(args: ReportArgs) -> Result<()> {
let root = args
.root
.clone()
.map(Ok)
.unwrap_or_else(std::env::current_dir)
.context("resolve the module root")?;
let module_id = read_module_id(&root)?;
let base = crate::auth::api_base_url(args.url.as_deref());
let run_url = args.run_url.clone().or_else(github_run_url);
match deliver(&base, &module_id, &args.outcome, run_url.as_deref()).await {
Ok(()) => ui::success(format!("reported {} for {module_id}", args.outcome)),
Err(failure) => {
ui::skipped(format!("could not report the run: {failure}"));
ui::advice("the publication itself is unaffected — only the alert was not updated");
}
}
if !ui::plain() {
ui::blank();
}
Ok(())
}
async fn deliver(base: &str, module_id: &str, outcome: &str, run_url: Option<&str>) -> Result<()> {
if !crate::oidc::available() {
anyhow::bail!("no OIDC token available — add `permissions: id-token: write` to the job");
}
let token = crate::oidc::request_token(&crate::oidc::audience(base)).await?;
let response = reqwest::Client::new()
.post(format!("{}/registry/v1/runs", base.trim_end_matches('/')))
.bearer_auth(token)
.json(&serde_json::json!({
"moduleId": module_id,
"outcome": outcome,
"runUrl": run_url.unwrap_or_default(),
}))
.send()
.await?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("the registry answered {status}");
}
Ok(())
}
fn github_run_url() -> Option<String> {
let server = std::env::var("GITHUB_SERVER_URL").ok()?;
let repository = std::env::var("GITHUB_REPOSITORY").ok()?;
let run = std::env::var("GITHUB_RUN_ID").ok()?;
Some(format!("{server}/{repository}/actions/runs/{run}"))
}
fn read_module_id(root: &Path) -> Result<String> {
let manifest = root.join(MODULE_MANIFEST);
let raw = std::fs::read_to_string(&manifest)
.with_context(|| format!("read {} — run from the module root", manifest.display()))?;
let parsed: serde_json::Value = serde_json::from_str(&raw)?;
parsed
.get("id")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.with_context(|| format!("{MODULE_MANIFEST} carries no id"))
}
#[derive(Debug, Parser)]
pub struct InfoArgs {
#[arg(long)]
pub root: Option<PathBuf>,
}
fn info(args: InfoArgs) -> Result<()> {
let root = args
.root
.clone()
.map(Ok)
.unwrap_or_else(std::env::current_dir)
.context("resolve the module root")?;
let manifest = root.join(MODULE_MANIFEST);
let raw = std::fs::read_to_string(&manifest)
.with_context(|| format!("read {} — run from the module root", manifest.display()))?;
let parsed: serde_json::Value =
serde_json::from_str(&raw).with_context(|| format!("parse {}", manifest.display()))?;
let field = |key: &str| {
parsed
.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string()
};
let (id, version) = (field("id"), field("version"));
if id.is_empty() || version.is_empty() {
anyhow::bail!("{MODULE_MANIFEST} carries no id or no version");
}
emit_outputs(&[("id", &id), ("version", &version)])?;
if ui::plain() {
println!("{id}");
println!("{version}");
return Ok(());
}
ui::header("portaki ci info", "What this module calls itself.");
ui::field("id", &id);
ui::field("version", &version);
ui::blank();
Ok(())
}
#[derive(Debug, Parser)]
pub struct ModulesArgs {
#[arg(long)]
pub root: Option<PathBuf>,
#[arg(long)]
pub changed_since: Option<String>,
#[arg(long, value_delimiter = ',')]
pub only: Vec<String>,
}
struct Selection {
modules: Vec<String>,
reason: &'static str,
}
fn modules(args: ModulesArgs) -> Result<()> {
let root = args
.root
.clone()
.map(Ok)
.unwrap_or_else(std::env::current_dir)
.context("resolve the repository root")?;
let known = discover(&root)?;
if known.is_empty() {
anyhow::bail!(
"no module found — expected {MODULE_MANIFEST} here, or one under {MODULES_DIR}/*/"
);
}
let selection = select(&root, &known, &args)?;
let json = serde_json::to_string(&selection.modules)?;
emit_outputs(&[
("modules", &json),
("any", &(!selection.modules.is_empty()).to_string()),
("reason", selection.reason),
])?;
if ui::plain() {
println!("{json}");
return Ok(());
}
ui::header(
"portaki ci modules",
"The modules this run should build, and why that is the list.",
);
if selection.modules.is_empty() {
ui::skipped(format!("nothing to build ({})", selection.reason));
} else {
ui::detail(selection.reason);
let versions: Vec<String> = selection
.modules
.iter()
.map(|name| declared_version(&root, name).unwrap_or_else(|| "?".to_string()))
.collect();
let rows: Vec<(&str, &str)> = selection
.modules
.iter()
.zip(&versions)
.map(|(name, version)| (name.as_str(), version.as_str()))
.collect();
ui::list("modules", &rows);
}
ui::blank();
Ok(())
}
fn declared_version(root: &Path, name: &str) -> Option<String> {
for candidate in [root.join(MODULES_DIR).join(name), root.to_path_buf()] {
let manifest = candidate.join(MODULE_MANIFEST);
if let Ok(raw) = std::fs::read_to_string(&manifest) {
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
return parsed
.get("version")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
}
}
None
}
fn discover(root: &Path) -> Result<Vec<String>> {
let nested = root.join(MODULES_DIR);
if nested.is_dir() {
let mut found = Vec::new();
for entry in std::fs::read_dir(&nested)
.with_context(|| format!("read {}", nested.display()))?
.flatten()
{
if entry.path().join(MODULE_MANIFEST).is_file() {
found.push(entry.file_name().to_string_lossy().to_string());
}
}
found.sort();
if !found.is_empty() {
return Ok(found);
}
}
if root.join(MODULE_MANIFEST).is_file() {
let name = root
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
return Ok(vec![name]);
}
Ok(Vec::new())
}
fn select(root: &Path, known: &[String], args: &ModulesArgs) -> Result<Selection> {
if !args.only.is_empty() {
let mut chosen = Vec::new();
for name in &args.only {
let name = name.trim();
if name.is_empty() {
continue;
}
if !known.iter().any(|candidate| candidate == name) {
anyhow::bail!("unknown module: {name}");
}
chosen.push(name.to_string());
}
return Ok(Selection {
modules: chosen,
reason: "asked for",
});
}
let Some(base) = args.changed_since.as_deref() else {
return Ok(Selection {
modules: known.to_vec(),
reason: "no base to compare against",
});
};
if base.trim().is_empty() || base.chars().all(|character| character == '0') {
return Ok(Selection {
modules: known.to_vec(),
reason: "no usable base",
});
}
let Some(changed) = changed_paths(root, base) else {
return Ok(Selection {
modules: known.to_vec(),
reason: "base not found",
});
};
if changed.is_empty() {
return Ok(Selection {
modules: Vec::new(),
reason: "nothing changed",
});
}
if changed.iter().any(|path| touches_shared(path)) {
return Ok(Selection {
modules: known.to_vec(),
reason: "the shared workspace changed",
});
}
let mut touched: Vec<String> = known
.iter()
.filter(|name| {
let prefix = format!("{MODULES_DIR}/{name}/");
changed.iter().any(|path| path.starts_with(&prefix))
})
.cloned()
.collect();
touched.sort();
touched.dedup();
if touched.is_empty() {
return Ok(Selection {
modules: Vec::new(),
reason: "no module was touched",
});
}
Ok(Selection {
modules: touched,
reason: "changed",
})
}
fn changed_paths(root: &Path, base: &str) -> Option<Vec<String>> {
let output = std::process::Command::new("git")
.current_dir(root)
.args(["diff", "--name-only", &format!("{base}...HEAD")])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::to_string)
.filter(|path| !path.is_empty())
.collect(),
)
}
fn touches_shared(path: &str) -> bool {
SHARED_PATHS.iter().any(|shared| {
if let Some(directory) = shared.strip_suffix('/') {
path.starts_with(&format!("{directory}/"))
} else {
path == *shared
}
})
}
#[derive(Debug, Parser)]
pub struct SdkVersionArgs {
#[arg(long)]
pub root: Option<PathBuf>,
}
fn sdk_version(args: SdkVersionArgs) -> Result<()> {
let root = args
.root
.clone()
.map(Ok)
.unwrap_or_else(std::env::current_dir)
.context("resolve the checkout root")?;
let lock = find_lockfile(&root).context(
"no Cargo.lock found here or above — the SDK a build resolves to is written there",
)?;
let text =
std::fs::read_to_string(&lock).with_context(|| format!("read {}", lock.display()))?;
let resolved = read_locked_sdk(&text).context("Cargo.lock carries no portaki-sdk")?;
emit_outputs(&[
("version", &resolved.version),
("rev", resolved.rev.as_deref().unwrap_or("")),
("key", resolved.cache_key()),
])?;
if ui::plain() {
println!("{}", resolved.cache_key());
return Ok(());
}
ui::header(
"portaki ci sdk-version",
"The Portaki SDK this checkout resolves to — the key a CLI cache should use.",
);
ui::field("version", &resolved.version);
if let Some(rev) = &resolved.rev {
ui::field("rev", rev);
}
ui::field("install", format!("portaki-cli@{}", resolved.cache_key()));
if resolved.rev.is_some() {
ui::advice("this checkout follows a git branch — the published CLI may lag behind it");
}
ui::blank();
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
struct LockedSdk {
version: String,
rev: Option<String>,
}
impl LockedSdk {
fn cache_key(&self) -> &str {
&self.version
}
}
fn find_lockfile(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.map(|directory| directory.join("Cargo.lock"))
.find(|candidate| candidate.is_file())
}
fn read_locked_sdk(lock: &str) -> Option<LockedSdk> {
let mut in_sdk = false;
let mut version = None;
let mut source = None;
for line in lock.lines() {
let line = line.trim();
if line == "[[package]]" {
if in_sdk {
break;
}
version = None;
source = None;
continue;
}
if let Some(value) = quoted(line, "name") {
in_sdk = value == "portaki-sdk";
continue;
}
if let Some(value) = quoted(line, "version") {
version = Some(value.to_string());
}
if let Some(value) = quoted(line, "source") {
source = Some(value.to_string());
}
if in_sdk && version.is_some() && line.starts_with("dependencies") {
break;
}
}
let version = version?;
if !in_sdk {
return None;
}
let rev = source
.and_then(|source| source.split_once('#').map(|(_, rev)| rev.to_string()))
.filter(|rev| !rev.is_empty());
Some(LockedSdk { version, rev })
}
fn quoted<'a>(line: &'a str, key: &str) -> Option<&'a str> {
let rest = line.strip_prefix(key)?.trim_start();
let rest = rest.strip_prefix('=')?.trim();
rest.strip_prefix('"')?.strip_suffix('"')
}
#[derive(Debug, Parser)]
pub struct CheckArgs {
#[arg(long)]
pub root: Option<PathBuf>,
#[arg(long)]
pub offline: bool,
}
async fn check(args: CheckArgs) -> Result<()> {
let root = args
.root
.clone()
.map(Ok)
.unwrap_or_else(std::env::current_dir)
.context("resolve the module root")?;
if !ui::plain() {
ui::header(
"portaki ci check",
"What still builds today and will not tomorrow.",
);
}
let mut found = 0;
let (manifest, _) = crate::manifest::load_manifest(&root, None)?;
if manifest.manifest_version != MANIFEST_VERSION {
found += 1;
annotate(
Some(MODULE_MANIFEST),
format!(
"manifest_version {} — this SDK writes {MANIFEST_VERSION}; rebuild with a current \
portaki build",
manifest.manifest_version
),
);
}
for (shell, declared) in [
("host", &manifest.ui_schema.host),
("guest", &manifest.ui_schema.guest),
] {
if declared != SDUI_SCHEMA_VERSION {
found += 1;
annotate(
Some(MODULE_MANIFEST),
format!(
"ui_schema {shell}={declared} — the shell now renders {SDUI_SCHEMA_VERSION}; \
newer primitives will not be available"
),
);
}
}
if args.offline {
ui::skipped("skipped the crates.io lookup (--offline)");
} else if let Some(lock) = find_lockfile(&root) {
let text = std::fs::read_to_string(&lock)?;
if let Some(resolved) = read_locked_sdk(&text) {
match latest_sdk().await {
Ok(latest) if outdated(&resolved.version, &latest) => {
found += 1;
annotate(
Some("Cargo.toml"),
format!(
"portaki-sdk {} — {latest} is published; \
https://github.com/PortakiApp/portaki-sdk/releases",
resolved.version
),
);
}
Ok(_) => {}
Err(failure) => ui::skipped(format!("could not reach crates.io: {failure}")),
}
match withdrawn(&resolved.version).await {
Ok(catalogue) => found += warn_about_withdrawn(&manifest, &catalogue),
Err(failure) => ui::skipped(format!("could not read the deprecations: {failure}")),
}
}
}
if found == 0 {
ui::success(format!("{} has nothing ageing", manifest.id));
}
if !ui::plain() {
ui::blank();
}
Ok(())
}
fn annotate(file: Option<&str>, message: impl std::fmt::Display) {
if std::env::var("GITHUB_ACTIONS").is_ok_and(|value| value == "true") {
match file {
Some(file) => println!("::warning file={file}::{message}"),
None => println!("::warning::{message}"),
}
return;
}
ui::warn(message);
}
fn warn_about_withdrawn(manifest: &ModuleManifest, catalogue: &[Withdrawn]) -> usize {
let declared = declared_ids(manifest);
let mut found = 0;
for entry in catalogue {
if !declared.iter().any(|name| name == &entry.id) {
continue;
}
found += 1;
let replacement = match &entry.replacement {
Some(replacement) => format!(" — use {replacement}"),
None => String::new(),
};
annotate(
Some(MODULE_MANIFEST),
format!(
"{} `{}` is deprecated since {}{replacement}: {}",
entry.subject, entry.id, entry.since, entry.note
),
);
}
found
}
fn declared_ids(manifest: &ModuleManifest) -> Vec<String> {
manifest
.capabilities
.required
.iter()
.chain(manifest.capabilities.provided.iter())
.map(|capability| capability.as_str().to_string())
.chain(
manifest
.capabilities
.optional
.iter()
.map(|capability| capability.id.as_str().to_string()),
)
.chain(manifest.connectors.builtin.iter().cloned())
.collect()
}
#[derive(Debug, serde::Deserialize)]
struct Withdrawn {
id: String,
subject: String,
since: String,
#[serde(default)]
replacement: Option<String>,
#[serde(default)]
note: String,
}
async fn withdrawn(sdk_version: &str) -> Result<Vec<Withdrawn>> {
let base = crate::auth::api_base_url(None);
let response = reqwest::Client::new()
.get(format!(
"{base}/registry/v1/sdk-releases/{sdk_version}/deprecations"
))
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(Vec::new());
}
let body: serde_json::Value = response.json().await?;
let entries = body
.get("deprecations")
.cloned()
.unwrap_or(serde_json::Value::Null);
Ok(serde_json::from_value(entries).unwrap_or_default())
}
async fn latest_sdk() -> Result<String> {
let response = reqwest::Client::new()
.get("https://crates.io/api/v1/crates/portaki-sdk")
.header(
"User-Agent",
concat!("portaki-cli/", env!("CARGO_PKG_VERSION")),
)
.send()
.await?;
let body: serde_json::Value = response.json().await?;
body.pointer("/crate/max_stable_version")
.or_else(|| body.pointer("/crate/newest_version"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.context("crates.io did not say which version is newest")
}
fn emit_outputs(pairs: &[(&str, &str)]) -> Result<()> {
let Ok(path) = std::env::var("GITHUB_OUTPUT") else {
return Ok(());
};
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&path)
.with_context(|| format!("open {path}"))?;
for (key, value) in pairs {
writeln!(file, "{key}={value}").with_context(|| format!("write {key} to {path}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const GIT_LOCK: &str = r#"
[[package]]
name = "serde"
version = "1.0.0"
[[package]]
name = "portaki-sdk"
version = "2.2.0"
source = "git+https://github.com/PortakiApp/portaki-sdk.git?branch=main#28d522da69b70627f78123d9c42475cb7c595c46"
dependencies = [
"base64",
]
"#;
const REGISTRY_LOCK: &str = r#"
[[package]]
name = "portaki-sdk"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
#[test]
fn a_git_dependency_still_yields_a_published_version() {
let resolved = read_locked_sdk(GIT_LOCK).expect("portaki-sdk dans le lock");
assert_eq!(resolved.version, "2.2.0");
assert_eq!(
resolved.rev.as_deref(),
Some("28d522da69b70627f78123d9c42475cb7c595c46")
);
assert_eq!(resolved.cache_key(), "2.2.0");
}
#[test]
fn a_registry_dependency_has_no_revision() {
let resolved = read_locked_sdk(REGISTRY_LOCK).expect("portaki-sdk dans le lock");
assert_eq!(resolved.cache_key(), "2.3.0");
assert!(resolved.rev.is_none());
}
#[test]
fn a_lock_without_the_sdk_says_so() {
assert!(read_locked_sdk("[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\n").is_none());
}
#[test]
fn only_the_shared_workspace_fans_out() {
assert!(touches_shared("Cargo.lock"));
assert!(touches_shared("Cargo.toml"));
assert!(touches_shared(".cargo/config.toml"));
assert!(touches_shared("rust-toolchain.toml"));
assert!(!touches_shared(".github/workflows/ci.yml"));
assert!(!touches_shared("modules/weather/Cargo.toml"));
assert!(!touches_shared("README.md"));
}
#[test]
fn both_repository_layouts_are_recognised() {
let single = tempfile::tempdir().unwrap();
std::fs::write(single.path().join(MODULE_MANIFEST), r#"{"id":"weather"}"#).unwrap();
assert_eq!(discover(single.path()).unwrap().len(), 1);
let many = tempfile::tempdir().unwrap();
for name in ["weather", "nuki"] {
let directory = many.path().join(MODULES_DIR).join(name);
std::fs::create_dir_all(&directory).unwrap();
std::fs::write(directory.join(MODULE_MANIFEST), r#"{"id":"x"}"#).unwrap();
}
assert_eq!(discover(many.path()).unwrap(), vec!["nuki", "weather"]);
}
#[test]
fn the_registry_document_reads_as_the_cli_expects() {
let document = serde_json::json!([
{
"id": "core.storage",
"subject": "capability",
"since": "2.4.0",
"replacement": "core.kv",
"note": "typed repositories replace raw storage"
},
{ "id": "kv.list", "subject": "hostOp", "since": "2.4.0", "note": "never scaled" }
]);
let parsed: Vec<Withdrawn> = serde_json::from_value(document).unwrap();
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].replacement.as_deref(), Some("core.kv"));
assert!(parsed[1].replacement.is_none());
assert_eq!(parsed[1].since, "2.4.0");
}
#[test]
fn every_kind_of_declaration_is_looked_at() {
let raw = serde_json::json!({
"manifestVersion": "1",
"id": "weather",
"version": "1.0.0",
"displayName": "k",
"description": "k",
"author": { "name": "x" },
"uiSchema": { "host": "1", "guest": "1" },
"capabilities": {
"required": ["core.storage"],
"optional": [
{ "id": "core.guests.notifications", "purpose_key": "k", "fallback_key": "k" }
],
"provided": ["access.smart_lock"]
},
"connectors": { "builtin": ["open-weather"], "custom": [] },
"entities": [],
"surfaces": {},
"queries": [],
"commands": [],
"events": {},
"i18n": { "default": "fr-FR", "supported": ["fr-FR"] }
});
let manifest: ModuleManifest = serde_json::from_value(raw).expect("manifeste lisible");
let declared = declared_ids(&manifest);
for expected in [
"core.storage",
"core.guests.notifications",
"access.smart_lock",
"open-weather",
] {
assert!(
declared.iter().any(|id| id == expected),
"manque {expected}"
);
}
}
#[test]
fn the_module_id_comes_from_the_manifest_not_the_directory() {
let root = tempfile::tempdir().unwrap();
std::fs::write(
root.path().join(MODULE_MANIFEST),
r#"{"id":"access-guide","version":"0.3.2"}"#,
)
.unwrap();
assert_eq!(read_module_id(root.path()).unwrap(), "access-guide");
}
#[test]
fn a_manifest_without_an_id_says_so() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join(MODULE_MANIFEST), r#"{"version":"1.0.0"}"#).unwrap();
assert!(read_module_id(root.path()).is_err());
}
#[test]
fn a_directory_without_a_manifest_is_not_a_module() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join(MODULES_DIR).join("target")).unwrap();
assert!(discover(root.path()).unwrap().is_empty());
}
}