mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Signed release pipeline (macOS/Windows/Linux/Web); README community note
Ports photocraft's release workflow + packaging (signing, notarization, MSI/DMG/deb/rpm/AppImage/web), adds `cargo xtask version`. File-type associations omitted for now (route B); app keeps its own .desktop. Fresh WiX UpgradeCode. README 'about' note updated. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
7b37dd1da5
commit
da8a14dd04
24 files changed
+1672
-3
No files matched your search
@@ -8,6 +8,7 @@ mod bundle;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -39,6 +40,7 @@ fn main() -> ExitCode {
|
||||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||
Some("bundle") => bundle::run(&root()),
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! `cargo xtask version [set X.Y.Z[-pre]]`: the workspace version, the single source of truth.
|
||||
//!
|
||||
//! The version lives in `[workspace.package] version` in the root `Cargo.toml`; every crate
|
||||
//! inherits it with `version.workspace = true`, and the packaging scripts read it from here.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The `version = "…"` value inside `[workspace.package]`.
|
||||
pub fn read(manifest: &str) -> Result<String, String> {
|
||||
let (_, line) = find_line(manifest)?;
|
||||
let v = line
|
||||
.split_once('=')
|
||||
.map(|(_, v)| v.trim().trim_matches('"').to_string())
|
||||
.unwrap_or_default();
|
||||
if v.is_empty() {
|
||||
return Err("empty [workspace.package] version".into());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged.
|
||||
pub fn replace(manifest: &str, new: &str) -> Result<String, String> {
|
||||
validate(new)?;
|
||||
let (idx, _) = find_line(manifest)?;
|
||||
let mut out = String::with_capacity(manifest.len() + 8);
|
||||
for (i, line) in manifest.split_inclusive('\n').enumerate() {
|
||||
if i == idx {
|
||||
let eol = if line.ends_with("\r\n") {
|
||||
"\r\n"
|
||||
} else if line.ends_with('\n') {
|
||||
"\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
out.push_str(&format!("version = \"{new}\"{eol}"));
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Semver without build metadata: `MAJOR.MINOR.PATCH` plus an optional `-pre.release` tag.
|
||||
pub fn validate(v: &str) -> Result<(), String> {
|
||||
let bad = || Err(format!("`{v}` is not a version like 1.2.3 or 1.2.3-rc.1"));
|
||||
let (core, pre) = match v.split_once('-') {
|
||||
Some((c, p)) => (c, Some(p)),
|
||||
None => (v, None),
|
||||
};
|
||||
let nums: Vec<&str> = core.split('.').collect();
|
||||
if nums.len() != 3
|
||||
|| nums.iter().any(|n| {
|
||||
n.is_empty()
|
||||
|| !n.bytes().all(|b| b.is_ascii_digit())
|
||||
|| (n.len() > 1 && n.starts_with('0'))
|
||||
})
|
||||
{
|
||||
return bad();
|
||||
}
|
||||
if let Some(p) = pre
|
||||
&& (p.is_empty()
|
||||
|| p.split('.').any(|id| {
|
||||
id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
}))
|
||||
{
|
||||
return bad();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Index and text of the `version` line in `[workspace.package]`.
|
||||
fn find_line(manifest: &str) -> Result<(usize, &str), String> {
|
||||
let mut in_section = false;
|
||||
for (i, line) in manifest.lines().enumerate() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('[') {
|
||||
in_section = t == "[workspace.package]";
|
||||
continue;
|
||||
}
|
||||
if in_section
|
||||
&& let Some(rest) = t.strip_prefix("version")
|
||||
&& rest.trim_start().starts_with('=')
|
||||
{
|
||||
return Ok((i, line));
|
||||
}
|
||||
}
|
||||
Err("no `version = \"…\"` in [workspace.package] of the root Cargo.toml".into())
|
||||
}
|
||||
|
||||
pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let path = root.join("Cargo.toml");
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
match args {
|
||||
[] => {
|
||||
println!("{}", read(&text)?);
|
||||
Ok(())
|
||||
}
|
||||
["set", new] => {
|
||||
let old = read(&text)?;
|
||||
let updated = replace(&text, new)?;
|
||||
// Write atomically: a half-written root manifest breaks every build in the workspace.
|
||||
let tmp = root.join("target").join("Cargo.toml.xtask-version");
|
||||
std::fs::create_dir_all(root.join("target")).map_err(|e| format!("create target/: {e}"))?;
|
||||
std::fs::write(&tmp, updated).map_err(|e| format!("write {}: {e}", tmp.display()))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| format!("replace {}: {e}", path.display()))?;
|
||||
// Refresh the workspace members' entries in Cargo.lock (no dependency upgrades).
|
||||
let mut c = crate::cargo();
|
||||
c.args(["update", "--workspace", "--offline"]);
|
||||
if crate::run(c, "cargo update --workspace --offline").is_err() {
|
||||
let mut c = crate::cargo();
|
||||
c.args(["update", "--workspace"]);
|
||||
crate::run(c, "cargo update --workspace")?;
|
||||
}
|
||||
println!("version: {old} -> {new}");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("usage: cargo xtask version [set X.Y.Z[-pre]]".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MANIFEST: &str = "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[workspace.dependencies]\nfoo = { version = \"1\" }\n";
|
||||
|
||||
#[test]
|
||||
fn reads_the_workspace_version() {
|
||||
assert_eq!(read(MANIFEST).unwrap(), "0.1.0");
|
||||
assert!(read("[package]\nversion = \"1.0.0\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_only_the_workspace_version() {
|
||||
let out = replace(MANIFEST, "1.2.3-rc.1").unwrap();
|
||||
assert_eq!(read(&out).unwrap(), "1.2.3-rc.1");
|
||||
assert_eq!(out, MANIFEST.replace("version = \"0.1.0\"", "version = \"1.2.3-rc.1\""));
|
||||
assert!(out.contains("foo = { version = \"1\" }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_crlf_line_endings() {
|
||||
let crlf = MANIFEST.replace('\n', "\r\n");
|
||||
let out = replace(&crlf, "2.0.0").unwrap();
|
||||
assert_eq!(out, crlf.replace("\"0.1.0\"", "\"2.0.0\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_versions() {
|
||||
for ok in ["0.1.0", "1.20.300", "1.0.0-rc.1", "1.0.0-alpha", "1.0.0-x-y.2"] {
|
||||
assert!(validate(ok).is_ok(), "{ok}");
|
||||
}
|
||||
for bad in ["1.0", "1.0.0.0", "v1.0.0", "01.0.0", "1.0.0-", "1.0.0-a..b", "1.0.0+meta", "1.a.0", ""] {
|
||||
assert!(validate(bad).is_err(), "{bad}");
|
||||
}
|
||||
assert!(replace(MANIFEST, "nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_real_manifest_has_a_valid_version() {
|
||||
let text = std::fs::read_to_string(crate::root().join("Cargo.toml")).unwrap();
|
||||
validate(&read(&text).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user