Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion skills/oneup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
22 changes: 10 additions & 12 deletions src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
_ => {}
}
Expand Down
1 change: 1 addition & 0 deletions src/npmrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
let mut entries = HashMap::new();
Expand Down
120 changes: 93 additions & 27 deletions src/target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: version_code can fail after earlier targets were written, leaving non-Gradle files updated while this Gradle file and later targets remain unchanged.

Assessment: 🟠 Major · 🔁 Occurrence: Rarely · 🏷️ Incomplete implementation

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/target.rs
**Line:** 183:183
**Comment:**
	*Incomplete Implementation: `version_code` can fail after earlier targets were written, leaving non-Gradle files updated while this Gradle file and later targets remain unchanged.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

output = code_re
.replace(&output, |c: &regex::Captures| format!("{}{}", &c[1], code))
.into_owned();
Expand Down Expand Up @@ -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<u64> = 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<u64> {
let nums = version
.split('.')
.map(str::parse::<u64>)
.collect::<std::result::Result<Vec<_>, _>>()
.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}");
Comment on lines +247 to +248

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: A valid YY.MICRO format pads its output to three parts, placing MICRO in b; values above 99 are then rejected as if they were months.

Assessment: 🟠 Major · 🔁 Occurrence: Sometimes · 🏷️ Api mismatch

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/target.rs
**Line:** 247:248
**Comment:**
	*Api Mismatch: A valid `YY.MICRO` format pads its output to three parts, placing MICRO in `b`; values above 99 are then rejected as if they were months.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}
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`,
Expand Down Expand Up @@ -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) ---
Expand Down Expand Up @@ -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]
Expand All @@ -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"));
}

Expand Down Expand Up @@ -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\""));
}

Expand Down
25 changes: 12 additions & 13 deletions src/version.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,26 +248,25 @@ 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() {
// With MICRO: find highest micro for today's prefix, increment
let mut max_micro: Option<u64> = 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)));
}
}

Expand Down
61 changes: 61 additions & 0 deletions tests/android_version_code.rs
Original file line number Diff line number Diff line change
@@ -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", "[email protected]")
.env("GIT_COMMITTER_NAME", "Example User")
.env("GIT_COMMITTER_EMAIL", "[email protected]")
.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 { "" }
)));
}
}
}
Loading