diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0ff56ca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - run: cargo fmt --check + - run: cargo clippy --all-targets -- -D warnings + - run: cargo test + - run: cargo build diff --git a/README.md b/README.md index 8027fc8..a32502c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,29 @@ sequence. For example, `v26.8.2-internal` makes the next `YY.MM.MICRO` version suffix describes the distribution channel and is never written to a target version field. +## Android versionCode + +The default `YY.MM.MICRO` format maps to `YYMMXXXX`: `YY*1_000_000 + MM*10_000 + MICRO`. For example, `26.10.100` produces `26100100`, and `26.10.9999` produces `26109999`, below `26.11.0` (`26110000`). MICRO supports 0–9999; 10000 is rejected before the Gradle file is changed. + +Four-component versions retain `a*1_000_000 + b*10_000 + c*100 + d`, with each component after the first limited to 0–99. Codes must be in Google Play's supported range, 1–2100000000. Invalid values fail instead of silently overlapping another component. Keep the same component count within a version sequence; the encodings are not ordered across formats. + +### Migrating existing Android apps + +Older oneup releases multiplied the third component by 100 even for three-component versions. This correction can lower the code in an existing month: old `26.10.3` was `26100300`, while new `26.10.4` is `26100004`. Before distributing with the corrected mapping, compare the generated code with the highest code already distributed for that application ID (including store test tracks and direct installs). The new code must be strictly greater. Advance the release sequence through the normal version source, or wait until a later month produces a greater code; do not rewrite existing tags. oneup does not query app stores or installed devices to discover that floor. + +## Development + +Rust's standard formatter and Clippy linter match the existing Cargo toolchain and require no extra dependencies. Run the same gate locally and in pull requests: + +```bash +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test +cargo build +``` + +Run the built CLI with `target/debug/oneup version --source git --target path/to/build.gradle`. Releases are dispatched separately through `.github/workflows/release.yml`. + ## Skills | Skill | Description | diff --git a/skills/oneup/SKILL.md b/skills/oneup/SKILL.md index af2a4a2..2281234 100644 --- a/skills/oneup/SKILL.md +++ b/skills/oneup/SKILL.md @@ -45,7 +45,7 @@ oneup version [OPTIONS] | `build.gradle` / `build.gradle.kts` | `.gradle` / `.gradle.kts` | `versionName` (also bumps `versionCode`) | git tags | | Go source (e.g. `version.go`) | `.go` | `Version = "..."` const/var | git tags | -**gradle:** oneup surgically rewrites only the `versionName` string (preserving quote style and formatting). It also updates `versionCode` (if present) to a monotonic integer derived from the CalVer version — numeric dot components `[a,b,c,d]` become `a*1_000_000 + b*10_000 + c*100 + d` (e.g. `26.7.0` → `26070000`). The `applicationId` is read as the package name (not otherwise used by the git source). +**gradle:** oneup surgically rewrites only the `versionName` string (preserving quote style and formatting). It also updates `versionCode` (if present) using `YY*1_000_000 + MM*10_000 + MICRO` for the default three-component format (`YYMMXXXX`, e.g. `26.7.100` → `26070100`). MICRO ranges from 0 to 9999; larger values fail instead of overlapping the next month. Four-component formats retain `a*1_000_000 + b*10_000 + c*100 + d`, with `b`, `c`, and `d` limited to 0–99. Invalid components and codes outside 1–2100000000 are rejected before writing the Gradle file. Do not switch component counts within an existing version sequence. When migrating from the old three-component mapping (`MICRO*100`), ensure the next generated code exceeds every previously distributed code; see the README migration note. The `applicationId` is read as the package name (not otherwise used by the git source). **Go:** oneup rewrites only the quoted string of a `Version = "..."` const/var. The module path is read from a sibling/ancestor `go.mod` for reference. `go.mod` itself is never touched (it has no module-version field). diff --git a/src/format.rs b/src/format.rs index 2f36838..80299e4 100644 --- a/src/format.rs +++ b/src/format.rs @@ -54,13 +54,13 @@ impl VersionFormat { } // MICRO, if present, must be the last component - if let Some(idx) = micro_index { - if idx != components.len() - 1 { - bail!( - "invalid format '{}': MICRO must be the last component", - format - ); - } + if let Some(idx) = micro_index + && idx != components.len() - 1 + { + bail!( + "invalid format '{}': MICRO must be the last component", + format + ); } // Must have at least one date component @@ -165,14 +165,12 @@ impl VersionFormat { if self.micro_index != Some(i) { match self.components[i] { Component::Mm => { - if val < 1 || val > 12 { + if !(1..=12).contains(&val) { return None; } } - Component::Dd => { - if val < 1 || val > 31 { - return None; - } + Component::Dd if (!(1..=31).contains(&val)) => { + return None; } _ => {} } diff --git a/src/npmrc.rs b/src/npmrc.rs index 0c29d0f..742590f 100644 --- a/src/npmrc.rs +++ b/src/npmrc.rs @@ -11,6 +11,7 @@ impl NpmrcConfig { /// Load .npmrc files following npm's resolution order: /// 1. Project-level .npmrc (directory of target file) /// 2. User-level ~/.npmrc + /// /// Environment variables (NPM_CONFIG_*) override file values. pub fn load(project_dir: &Path) -> Result { let mut entries = HashMap::new(); diff --git a/src/target.rs b/src/target.rs index 8bd436c..5cf9375 100644 --- a/src/target.rs +++ b/src/target.rs @@ -180,7 +180,7 @@ impl TargetFile { // but only if a versionCode line exists. let code_re = Regex::new(r#"(versionCode\s*=?\s*)(\d+)"#).unwrap(); if code_re.is_match(&output) { - let code = version_code(new_version); + let code = version_code(new_version)?; output = code_re .replace(&output, |c: ®ex::Captures| format!("{}{}", &c[1], code)) .into_owned(); @@ -218,17 +218,40 @@ impl TargetFile { } } -/// Compute an Android versionCode from a CalVer version string. -/// Parses the numeric dot components [a, b, c, d] and computes -/// `a*1_000_000 + b*10_000 + c*100 + d` (missing components = 0). -/// Monotonic as CalVer increases, e.g. `26.7.0` → 26_070_000. -fn version_code(version: &str) -> u64 { - let nums: Vec = version.split('.').map(|p| p.parse().unwrap_or(0)).collect(); - let a = nums.first().copied().unwrap_or(0); +/// Pack three-component versions as YYMMXXXX, leaving four digits for MICRO. +/// Four-component formats retain two digits per trailing component. +fn version_code(version: &str) -> Result { + let nums = version + .split('.') + .map(str::parse::) + .collect::, _>>() + .with_context(|| format!("invalid Android version: {version}"))?; + if nums.is_empty() || nums.len() > 4 { + bail!("Android versions require one to four numeric components: {version}"); + } + let a = nums[0]; let b = nums.get(1).copied().unwrap_or(0); let c = nums.get(2).copied().unwrap_or(0); - let d = nums.get(3).copied().unwrap_or(0); - a * 1_000_000 + b * 10_000 + c * 100 + d + let tail = if nums.len() == 4 { + let d = nums[3]; + if c > 99 || d > 99 { + bail!("four-component Android versions require trailing components <= 99: {version}"); + } + c * 100 + d + } else { + if c > 9999 { + bail!("Android MICRO must be <= 9999: {version}"); + } + c + }; + if a > 2100 || b > 99 { + bail!("Android version components exceed versionCode capacity: {version}"); + } + let code = a * 1_000_000 + b * 10_000 + tail; + if code == 0 || code > 2_100_000_000 { + bail!("Android versionCode must be between 1 and 2100000000: {version}"); + } + Ok(code) } /// Walk up from a Go source file's directory looking for a `go.mod`, @@ -424,18 +447,61 @@ edition = "2024" #[test] fn version_code_derivation() { - assert_eq!(version_code("26.7.0"), 26_070_000); - assert_eq!(version_code("26.7.3"), 26_070_300); - assert_eq!(version_code("26.12.5.9"), 26_120_509); - assert_eq!(version_code("26.7"), 26_070_000); // missing components = 0 - assert_eq!(version_code("1"), 1_000_000); + assert_eq!(version_code("26.7.0").unwrap(), 26_070_000); + assert_eq!(version_code("26.7.3").unwrap(), 26_070_003); + assert_eq!(version_code("26.10.100").unwrap(), 26_100_100); + assert_eq!(version_code("26.10.9999").unwrap(), 26_109_999); + assert_eq!(version_code("26.12.5.9").unwrap(), 26_120_509); + assert_eq!(version_code("26.7").unwrap(), 26_070_000); + assert_eq!(version_code("1").unwrap(), 1_000_000); + assert_eq!(version_code("2100.0.0").unwrap(), 2_100_000_000); + assert_eq!(version_code("2026.10.15.0").unwrap(), 2_026_101_500); } #[test] fn version_code_is_monotonic() { - assert!(version_code("26.7.1") > version_code("26.7.0")); - assert!(version_code("26.8.0") > version_code("26.7.99")); - assert!(version_code("27.1.0") > version_code("26.12.99")); + let versions = [ + "26.10.0", + "26.10.1", + "26.10.99", + "26.10.100", + "26.10.9999", + "26.11.0", + "26.12.9999", + "27.1.0", + ]; + for pair in versions.windows(2) { + assert!(version_code(pair[0]).unwrap() < version_code(pair[1]).unwrap()); + } + assert!(version_code("26.7.31.99").unwrap() < version_code("26.8.1.0").unwrap()); + } + + #[test] + fn version_code_rejects_overflow_and_invalid_components() { + for version in [ + "26.10.10000", + "26.100.0", + "26.12.100.0", + "26.12.1.100", + "2100.0.1", + "18446744073709551615.1.0", + "18446744073709551616.1.0", + "0.0.0", + "26.10.bad", + "26..0", + "26.10.0.0.1", + "", + ] { + assert!(version_code(version).is_err(), "accepted {version}"); + } + } + + #[test] + fn invalid_version_code_does_not_modify_gradle() { + let f = temp_with_suffix(".gradle", GROOVY_GRADLE); + let target = TargetFile::read(f.path()).unwrap(); + assert!(target.write(f.path(), "26.10.10000").is_err()); + assert_eq!(std::fs::read_to_string(f.path()).unwrap(), GROOVY_GRADLE); } // --- Gradle (Groovy) --- @@ -463,17 +529,17 @@ edition = "2024" fn write_gradle_groovy_updates_version_and_code() { let f = temp_with_suffix(".gradle", GROOVY_GRADLE); let target = TargetFile::read(f.path()).unwrap(); - target.write(f.path(), "26.7.0").unwrap(); + target.write(f.path(), "26.7.100").unwrap(); let content = std::fs::read_to_string(f.path()).unwrap(); - assert!(content.contains("versionName '26.7.0'")); - assert!(content.contains("versionCode 26070000")); + assert!(content.contains("versionName '26.7.100'")); + assert!(content.contains("versionCode 26070100")); // Preserve the rest of the file. assert!(content.contains("applicationId 'com.example.app'")); assert!(content.contains("android {")); let updated = TargetFile::read(f.path()).unwrap(); - assert_eq!(updated.version, "26.7.0"); + assert_eq!(updated.version, "26.7.100"); } #[test] @@ -484,10 +550,10 @@ edition = "2024" ); let target = TargetFile::read(f.path()).unwrap(); assert_eq!(target.package_name, "app"); // no applicationId → placeholder - target.write(f.path(), "26.7.0").unwrap(); + target.write(f.path(), "26.7.100").unwrap(); let content = std::fs::read_to_string(f.path()).unwrap(); - assert!(content.contains("versionName '26.7.0'")); + assert!(content.contains("versionName '26.7.100'")); assert!(!content.contains("versionCode")); } @@ -515,11 +581,11 @@ edition = "2024" fn write_gradle_kts_updates_version_and_code() { let f = temp_with_suffix(".gradle.kts", KTS_GRADLE); let target = TargetFile::read(f.path()).unwrap(); - target.write(f.path(), "26.7.0").unwrap(); + target.write(f.path(), "26.7.100").unwrap(); let content = std::fs::read_to_string(f.path()).unwrap(); - assert!(content.contains("versionName = \"26.7.0\"")); - assert!(content.contains("versionCode = 26070000")); + assert!(content.contains("versionName = \"26.7.100\"")); + assert!(content.contains("versionCode = 26070100")); assert!(content.contains("applicationId = \"com.example.app\"")); } diff --git a/src/version.rs b/src/version.rs index edc50d6..6be93fe 100644 --- a/src/version.rs +++ b/src/version.rs @@ -248,13 +248,13 @@ fn determine_version( } PackageInfo::Found { versions, latest } => { // Warn if registry latest is ahead of today - if let Some(latest_values) = fmt.extract_values(&latest) { - if fmt.ahead_of_today(&latest_values) { - eprintln!( - "warning: registry latest {} is ahead of current date prefix", - latest - ); - } + if let Some(latest_values) = fmt.extract_values(&latest) + && fmt.ahead_of_today(&latest_values) + { + eprintln!( + "warning: registry latest {} is ahead of current date prefix", + latest + ); } if fmt.has_micro() { @@ -262,12 +262,11 @@ fn determine_version( let mut max_micro: Option = None; for v in &versions { - if let Some(values) = fmt.extract_values(v) { - if fmt.matches_today(&values) { - if let Some(micro) = fmt.micro_value(&values) { - max_micro = Some(max_micro.map_or(micro, |m: u64| m.max(micro))); - } - } + if let Some(values) = fmt.extract_values(v) + && fmt.matches_today(&values) + && let Some(micro) = fmt.micro_value(&values) + { + max_micro = Some(max_micro.map_or(micro, |m: u64| m.max(micro))); } } diff --git a/tests/android_version_code.rs b/tests/android_version_code.rs new file mode 100644 index 0000000..d09100f --- /dev/null +++ b/tests/android_version_code.rs @@ -0,0 +1,61 @@ +use chrono::{Datelike, Local}; +use std::process::Command; + +#[test] +fn gradle_cli_uses_four_digit_micro_and_rejects_exhaustion() { + let now = Local::now(); + let yy = now.year() % 100; + let mm = now.month(); + for (last_micro, extension, declaration) in [ + (99, "gradle", "versionName '0.0.0'\nversionCode 1\n"), + ( + 9998, + "gradle.kts", + "versionName = \"0.0.0\"\nversionCode = 1\n", + ), + (9999, "gradle", "versionName '0.0.0'\nversionCode 1\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join(format!("build.{extension}")); + std::fs::write(&target, declaration).unwrap(); + for args in [ + vec!["init", "--quiet"], + vec!["add", "."], + vec!["commit", "--quiet", "-m", "fixture"], + vec!["tag", &format!("v{yy}.{mm}.{last_micro}")], + ] { + let result = Command::new("git") + .args(args) + .current_dir(dir.path()) + .env("GIT_AUTHOR_NAME", "Example User") + .env("GIT_AUTHOR_EMAIL", "alice@example.com") + .env("GIT_COMMITTER_NAME", "Example User") + .env("GIT_COMMITTER_EMAIL", "alice@example.com") + .output() + .unwrap(); + assert!(result.status.success(), "{:?}", result); + } + let result = Command::new(env!("CARGO_BIN_EXE_oneup")) + .args(["version", "--source", "git", "--target"]) + .arg(&target) + .current_dir(dir.path()) + .output() + .unwrap(); + let content = std::fs::read_to_string(&target).unwrap(); + if last_micro == 9999 { + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("MICRO must be <= 9999")); + assert_eq!(content, declaration); + } else { + assert!(result.status.success(), "{:?}", result); + let version = format!("{yy}.{mm}.{}", last_micro + 1); + assert_eq!(String::from_utf8_lossy(&result.stdout).trim(), version); + assert!(content.contains(&version)); + let code = yy as u32 * 1_000_000 + mm * 10_000 + last_micro + 1; + assert!(content.contains(&format!( + "versionCode {}{code}", + if extension == "gradle.kts" { "= " } else { "" } + ))); + } + } +}