diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d54bc32..6c02a3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: - 'capsule-core/**' - 'capsule-media/**' - 'capsule-sdk/**' + - 'xtask/**' - '.github/workflows/ci.yml' web: - 'capsule-web/**' diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 26cec77..a48545e 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -22,6 +22,7 @@ "**/dist/**", "**/.astro/**", "**/Derived/**", - "CLAUDE.md" // symlink to AGENTS.md + "CLAUDE.md", // symlink to AGENTS.md + "CHANGELOG.md" // generated by convco; not hand-formatted ] } diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..dacda02 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +This file is generated from [Conventional Commits](https://www.conventionalcommits.org) +by [convco](https://convco.github.io) — run `just changelog` (or let the release +workflow do it). Section grouping is configured in `.versionrc`. Hand-edits made when +cutting a release are reviewed in the release PR before the tag is pushed. + +## Unreleased diff --git a/Cargo.lock b/Cargo.lock index ef31257..3f60327 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4548,7 +4548,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -6998,6 +6998,12 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -7016,6 +7022,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.12+spec-1.1.0" @@ -7037,6 +7055,12 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -8750,6 +8774,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "636f85e5ca6488e96401b61eb7de54f4e44755c988af0f52cf90230c312a1a89" +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "anyhow", + "regex", + "semver", + "toml_edit 0.22.27", +] + [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/Cargo.toml b/Cargo.toml index fe27c26..d051d0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "capsule-core-ffi", "capsule-media", "capsule-sdk", + "xtask", ] # capsule-sdk requires a pre-generated openapi.json (run generate_openapi.sh first) default-members = [ diff --git a/capsule-android/build.gradle.kts b/capsule-android/build.gradle.kts index 317169c..b527178 100644 --- a/capsule-android/build.gradle.kts +++ b/capsule-android/build.gradle.kts @@ -48,8 +48,10 @@ android { applicationId = "com.justin13888.capsule" minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + // Version is the repo-wide source of truth in gradle.properties, kept in sync + // across every package by `just set-version` (xtask). + versionCode = providers.gradleProperty("capsule.versionCode").get().toInt() + versionName = providers.gradleProperty("capsule.versionName").get() } packaging { resources { diff --git a/capsule-docs/package.json b/capsule-docs/package.json index 437caae..d6d8073 100644 --- a/capsule-docs/package.json +++ b/capsule-docs/package.json @@ -1,7 +1,7 @@ { "name": "capsule-docs", "type": "module", - "version": "0.0.1", + "version": "0.1.0", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/capsule-media/Cargo.toml b/capsule-media/Cargo.toml index 650c69c..cb54fbf 100644 --- a/capsule-media/Cargo.toml +++ b/capsule-media/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capsule-media" -version = "0.1.0" +version.workspace = true edition = "2024" [dependencies] diff --git a/capsule-swift/Project.swift b/capsule-swift/Project.swift index 720025a..22e3297 100644 --- a/capsule-swift/Project.swift +++ b/capsule-swift/Project.swift @@ -6,10 +6,14 @@ private let bundlePrefix = "com.justin13888.capsule" private let appDestinations: Destinations = [.iPhone, .iPad] private let appDeploymentTargets: DeploymentTargets = .iOS("18.0") -/// The Swift-6 language settings shared by every Capsule target. +/// The Swift-6 language settings shared by every Capsule target. MARKETING_VERSION is +/// the iOS app's version source of truth, kept in sync across every package by +/// `just set-version` (xtask). private let baseSettings: SettingsDictionary = [ "SWIFT_VERSION": "6.0", "SWIFT_STRICT_CONCURRENCY": "complete", + "MARKETING_VERSION": "0.1.0", + "CURRENT_PROJECT_VERSION": "1", ] /// Framework settings: a Release build marks the framework mergeable so the diff --git a/gradle.properties b/gradle.properties index 50c0048..cdda9ca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,3 +30,9 @@ kotlin.incremental.js=true kotlin.incremental.multiplatform=true # Disable compiler cache for Linux (issue on Kotlin 2.1.20) kotlin.native.cacheKind.linuxX64=none + +# Capsule +# Repo-wide version source of truth for the Android app. Kept in sync across every +# package by `just set-version` (xtask), which also bumps versionCode on each release. +capsule.versionName=0.1.0 +capsule.versionCode=1 diff --git a/justfile b/justfile index 6ef15a1..9c08d9b 100644 --- a/justfile +++ b/justfile @@ -421,6 +421,18 @@ commit-check msg_file: check-commits base="origin/master": convco check --first-parent --ignore-reverts {{ base }}..HEAD +# Write one repo-wide version into every package's source of truth (Rust workspace, +# web/docs package.json, vision pyproject, Android gradle.properties, iOS Project.swift) +# and bump the Android versionCode. See xtask/src/main.rs for the per-format editors. +[group('release')] +set-version version: + cargo run -q -p xtask -- set-version {{ version }} + +# Regenerate CHANGELOG.md from Conventional Commits. Hand-edits land in the release PR. +[group('release')] +changelog: + convco changelog > CHANGELOG.md + # ── Setup ──────────────────────────────────────────────────────────────────── [group('setup')] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..efce390 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "xtask" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +anyhow = "1" +regex = "1" +semver = "1" +toml_edit = "0.22" diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..6fc7aea --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,228 @@ +//! Repo-maintenance tasks for the Capsule workspace. +//! +//! Currently one command, `set-version `, which writes a single repo-wide +//! version string into every package's source of truth so a release bump stays in +//! sync across Rust, web, docs, Python, Android, and iOS. Each per-format editor is a +//! pure `&str -> Result` function so it can be unit-tested without disk I/O. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use regex::{Captures, Regex}; +use semver::Version; +use toml_edit::Item; + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + Some("set-version") => { + let raw = args.next().context("usage: xtask set-version ")?; + let version = Version::parse(&raw) + .with_context(|| format!("`{raw}` is not a valid semantic version"))?; + set_version(&repo_root(), &version.to_string()) + } + Some(other) => bail!("unknown command `{other}`; usage: xtask set-version "), + None => bail!("usage: xtask set-version "), + } +} + +/// The workspace root — `xtask` lives at `/xtask`. +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf) +} + +/// A per-format editor: rewrites file `contents` with the new `version`. +type Editor = fn(&str, &str) -> Result; + +/// Write `version` into every package's version source of truth. +fn set_version(root: &Path, version: &str) -> Result<()> { + let edits: &[(&str, Editor)] = &[ + ("Cargo.toml", set_cargo_workspace_version), + ("capsule-vision/pyproject.toml", set_pyproject_version), + ("capsule-web/package.json", set_package_json_version), + ("capsule-docs/package.json", set_package_json_version), + ("gradle.properties", set_gradle_properties_version), + ("capsule-swift/Project.swift", set_marketing_version), + ]; + for (rel, edit) in edits { + let path = root.join(rel); + let input = + fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let output = edit(&input, version).with_context(|| format!("updating {rel}"))?; + if output == input { + println!("unchanged {rel}"); + } else { + fs::write(&path, output).with_context(|| format!("writing {}", path.display()))?; + println!("updated {rel} -> {version}"); + } + } + Ok(()) +} + +/// Root `Cargo.toml`'s `[workspace.package] version` — the SSoT for every Rust crate. +fn set_cargo_workspace_version(input: &str, version: &str) -> Result { + let mut doc = input + .parse::() + .context("parsing Cargo.toml")?; + // toml_edit's chained `get_mut`/indexing auto-vivifies, so check existence first. + let exists = doc + .get("workspace") + .and_then(Item::as_table) + .and_then(|w| w.get("package")) + .and_then(Item::as_table) + .is_some_and(|p| p.contains_key("version")); + if !exists { + bail!("[workspace.package] version not found"); + } + doc["workspace"]["package"]["version"] = toml_edit::value(version); + Ok(doc.to_string()) +} + +/// `capsule-vision/pyproject.toml`'s `[project] version`. +fn set_pyproject_version(input: &str, version: &str) -> Result { + let mut doc = input + .parse::() + .context("parsing pyproject.toml")?; + let exists = doc + .get("project") + .and_then(Item::as_table) + .is_some_and(|p| p.contains_key("version")); + if !exists { + bail!("[project] version not found"); + } + doc["project"]["version"] = toml_edit::value(version); + Ok(doc.to_string()) +} + +/// The top-level `"version"` field of a `package.json` (formatting preserved). +fn set_package_json_version(input: &str, version: &str) -> Result { + replace_value( + input, + r#"(?m)^(?P
\s*"version"\s*:\s*")[^"]*(?P")"#,
+        version,
+        "\"version\" field",
+    )
+}
+
+/// Tuist's `MARKETING_VERSION` build setting in `Project.swift` — the iOS app version.
+fn set_marketing_version(input: &str, version: &str) -> Result {
+    replace_value(
+        input,
+        r#"(?m)^(?P
\s*"MARKETING_VERSION"\s*:\s*")[^"]*(?P")"#,
+        version,
+        "MARKETING_VERSION setting",
+    )
+}
+
+/// `capsule.versionName` (set to `version`) and `capsule.versionCode` (incremented —
+/// it's a monotonic Android build number) in `gradle.properties`.
+fn set_gradle_properties_version(input: &str, version: &str) -> Result {
+    let name_re = Regex::new(r"(?m)^capsule\.versionName=.*$").expect("static regex is valid");
+    if !name_re.is_match(input) {
+        bail!("capsule.versionName not found");
+    }
+    let with_name = name_re
+        .replace(input, |_: &Captures| {
+            format!("capsule.versionName={version}")
+        })
+        .into_owned();
+
+    let code_re = Regex::new(r"(?m)^capsule\.versionCode=(?P\d+)[ \t]*$")
+        .expect("static regex is valid");
+    let next = code_re
+        .captures(&with_name)
+        .context("capsule.versionCode not found")?
+        .name("code")
+        .map(|m| m.as_str())
+        .unwrap_or_default()
+        .parse::()
+        .context("capsule.versionCode is not an integer")?
+        + 1;
+    Ok(code_re
+        .replace(&with_name, |_: &Captures| {
+            format!("capsule.versionCode={next}")
+        })
+        .into_owned())
+}
+
+/// Replace the value captured between named groups `pre` and `post` of the first match
+/// of `pattern` with `version`, erroring (via `what`) when nothing matches.
+fn replace_value(input: &str, pattern: &str, version: &str, what: &str) -> Result {
+    let re = Regex::new(pattern).expect("static regex is valid");
+    if !re.is_match(input) {
+        bail!("{what} not found");
+    }
+    Ok(re
+        .replace(input, |caps: &Captures| {
+            format!("{}{version}{}", &caps["pre"], &caps["post"])
+        })
+        .into_owned())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn cargo_workspace_version_updates_and_preserves_rest() {
+        let input = "[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n";
+        let out = set_cargo_workspace_version(input, "0.2.0").unwrap();
+        assert!(out.contains("version = \"0.2.0\""));
+        assert!(
+            out.contains("edition = \"2024\""),
+            "unrelated keys preserved"
+        );
+    }
+
+    #[test]
+    fn cargo_missing_version_errors() {
+        assert!(set_cargo_workspace_version("[workspace]\nmembers = []\n", "0.2.0").is_err());
+    }
+
+    #[test]
+    fn pyproject_version_updates() {
+        let input = "[project]\nname = \"capsule-vision\"\nversion = \"0.1.0\"\n";
+        let out = set_pyproject_version(input, "1.2.3").unwrap();
+        assert!(out.contains("version = \"1.2.3\""));
+        assert!(out.contains("name = \"capsule-vision\""));
+    }
+
+    #[test]
+    fn package_json_version_updates_field_and_keeps_formatting() {
+        let input =
+            "{\n  \"name\": \"capsule-web\",\n  \"version\": \"0.1.0\",\n  \"private\": true\n}\n";
+        let out = set_package_json_version(input, "0.2.0").unwrap();
+        assert!(out.contains("\"version\": \"0.2.0\""));
+        assert!(out.contains("\"name\": \"capsule-web\""));
+        assert!(out.contains("\"private\": true"));
+    }
+
+    #[test]
+    fn gradle_bumps_name_and_increments_code() {
+        let input = "capsule.versionName=0.1.0\ncapsule.versionCode=7\n";
+        let out = set_gradle_properties_version(input, "0.2.0").unwrap();
+        assert!(out.contains("capsule.versionName=0.2.0"));
+        assert!(
+            out.contains("capsule.versionCode=8"),
+            "versionCode is monotonic"
+        );
+    }
+
+    #[test]
+    fn marketing_version_updates() {
+        let input = "        \"MARKETING_VERSION\": \"0.1.0\",\n";
+        let out = set_marketing_version(input, "3.4.5").unwrap();
+        assert_eq!(out, "        \"MARKETING_VERSION\": \"3.4.5\",\n");
+    }
+
+    #[test]
+    fn missing_targets_error() {
+        assert!(set_gradle_properties_version("nope=1\n", "0.2.0").is_err());
+        assert!(set_marketing_version("no setting here\n", "0.2.0").is_err());
+        assert!(set_package_json_version("{}\n", "0.2.0").is_err());
+        assert!(set_pyproject_version("[tool]\nx = 1\n", "0.2.0").is_err());
+    }
+}