iru-dotnet-bump-version
Set a .NET project's version to an exact value in every place it must agree — a .NET library, service, or Windows desktop app (WPF, WinUI 3, Windows Forms) alike. Rewrites `<Version>` (or `<VersionPre
SKILL.md.claude/skills/iru-dotnet-bump-version/SKILL.md
GitHub.NET Bump Version
Set a .NET project's version to an exact value across every file that must agree with it — the MSBuild version
property and, for a packaged Windows desktop app, the MSIX identity version — and, optionally, README/Antora version
mentions. This skill never computes a version itself: it applies exactly the string it's given, validated for shape.
The "which version comes next" logic belongs to the caller (typically iru-release) and is defined in Step 5, the
same contract iru-java-bump-version, iru-typescript-bump-version, iru-php-bump-version and the other
iru-<key>-bump-version skills define for their ecosystems. It never commits, tags, builds, or packages anything.
Step 0 — Resolve inputs
- The positional argument is
<new-version>(e.g.1.4.0,1.5.0-dev). Missing → ask for it before doing anything else; never guess or compute one here. args(key: valuelines), if present:sync-files: yes|no— also update README/Antora mentions (Step 7). Omitted → ask once withAskUserQuestionafter Step 3 shows such mentions exist; don't ask when none do.next-version: <upcoming-version>— the upcoming development version (e.g.1.5.0-dev) that "Current development version" markers should show after a release, while "Latest release" markers show<new-version>. Only meaningful withsync-files: yes; validated like<new-version>.dry-run: yes|no— defaultno.yesruns everything through showing the diff, then stops: nothing is written; say so plainly in the report.
Reject before touching anything a value that is obviously not a version (empty, or containing whitespace).
Step 1 — Validate the version string
MSBuild's <Version> is free-form, but NuGet (SemVer 2.0) and the MSIX identity are not, so use this catalog's
convention — X.Y.Z or X.Y.Z-dev:
python3 -c 'import re,sys; sys.exit(0 if re.fullmatch(r"\d+\.\d+\.\d+(-dev)?", sys.argv[1]) else 1)' "<new-version>"
No match → if it is still SemVer (X.Y.Z-<pre-release>, e.g. -rc.1, -preview.2), warn that it doesn't follow
the -dev convention and ask whether to proceed (NuGet orders any SemVer pre-release correctly). Anything that isn't
X.Y.Z[-suffix] — 1.0, 1.2.3.4, v1.2.3 — is refused: the MSIX identity needs exactly three numeric parts plus
the revision this skill appends, each part 0–65535. Anything with a - suffix counts as a pre-release.
Step 2 — Write the helper and survey the version locations
| Location | Rewritten to | When |
|---|---|---|
root Directory.Build.props <Version> |
<new-version> |
it declares <Version> or <VersionPrefix> (this catalog's scaffolds: iru-setup-windows-app) |
root Directory.Build.props <VersionPrefix> / <VersionSuffix> |
X.Y.Z / the suffix (dev), or an empty <VersionSuffix></VersionSuffix> for a release |
the project splits the version that way |
every non-test *.csproj declaring <Version>/<VersionPrefix> |
as above | only when Directory.Build.props declares none |
every git-tracked Package.appxmanifest and AppxManifest.xml (e.g. packaging/AppxManifest.xml) <Identity … Version="…"> |
X.Y.Z.0 — always numeric, suffix stripped, revision 0 |
the file exists outside bin//obj//AppPackages/ |
Test projects (IsTestProject, or referencing MSTest/xUnit/NUnit/Microsoft.NET.Test.Sdk, or named *.Tests/
*.UITests) are never touched. Explicit <AssemblyVersion>/<FileVersion>/<InformationalVersion> are reported
and left alone — the SDK derives all three from <Version> unless a project overrides them, and an override is a
deliberate choice this skill doesn't second-guess.
mkdir -p "$TMPDIR/iru-dotnet-bump-version"
cat > "$TMPDIR/iru-dotnet-bump-version/dotnet_version.py" <<'PY'
import difflib, pathlib, re, subprocess, sys
args = sys.argv[1:]
dry, force = "--dry-run" in args, "--force" in args
args = [a for a in args if not a.startswith("--")]
new = args[0] if args else None # None => --list
root = pathlib.Path(".")
SKIP = {"bin", "obj", "node_modules", ".git", ".vs", "artifacts", "AppPackages", "BundleArtifacts", "publish"}
def files(pattern):
return [p for p in sorted(root.rglob(pattern)) if not any(part in SKIP for part in p.parts)]
def read(p):
return p.read_text(encoding="utf-8-sig")
def tracked(p):
try:
r = subprocess.run(["git", "ls-files", "--error-unmatch", str(p)], capture_output=True)
return r.returncode == 0 if r.returncode in (0, 1) else True
except FileNotFoundError:
return True # no git: treat an existing file as tracked
def is_test(p):
t = read(p)
return (re.search(r"<IsTestProject>\s*true\s*</IsTestProject>", t, re.I) is not None
or re.search(r'Include="(MSTest[^"]*|Microsoft\.NET\.Test\.Sdk|xunit[^"]*|NUnit)"', t) is not None
or re.search(r"\.(Tests?|UITests|IntegrationTests)\.csproj$", p.name) is not None)
def split(v):
m = re.fullmatch(r"(\d+\.\d+\.\d+)(?:-(.+))?", v)
return (m.group(1), m.group(2) or "") if m else (v, "")
def msix(v):
parts = split(v)[0].split(".")
if len(parts) != 3 or not all(x.isdigit() and int(x) <= 65535 for x in parts):
sys.exit(f"Cannot derive a four-part MSIX version from {v!r} (X.Y.Z, each part 0-65535)")
return ".".join(parts) + ".0"
def value_for(kind, v):
if kind == "version":
return v
if kind == "prefix":
return split(v)[0]
if kind == "suffix":
return split(v)[1]
return msix(v)
TAG = lambda name: re.compile(rf"(<{name}>\s*)([^<]*?)(\s*</{name}>)")
IDENTITY = re.compile(r'(<Identity\b[^>]*?\bVersion\s*=\s*")([^"]*)(")', re.S)
targets = [] # (path, kind, regex, label)
props = root / "Directory.Build.props"
prop_files = [props] if props.exists() and re.search(r"<Version(Prefix)?>", read(props)) else []
if not prop_files:
prop_files = [p for p in files("*.csproj") if not is_test(p) and re.search(r"<Version(Prefix)?>", read(p))]
for p in prop_files:
t = read(p)
if "<VersionPrefix>" in t:
targets.append((p, "prefix", TAG("VersionPrefix"), "VersionPrefix"))
if "<VersionSuffix>" in t:
targets.append((p, "suffix", TAG("VersionSuffix"), "VersionSuffix"))
if "<Version>" in t:
targets.append((p, "version", TAG("Version"), "Version"))
for p in files("Package.appxmanifest") + files("AppxManifest.xml"):
if tracked(p) and IDENTITY.search(read(p)):
targets.append((p, "msix", IDENTITY, "MSIX Identity Version"))
for p in files("*.csproj") + ([props] if props.exists() else []):
for tag in ("AssemblyVersion", "FileVersion", "InformationalVersion"):
m = TAG(tag).search(read(p))
if m:
print(f"note: {p.as_posix()} sets <{tag}>{m.group(2)}</{tag}> explicitly — left untouched")
if not any(k in ("version", "prefix") for _, k, _, _ in targets):
sys.exit("No <Version>/<VersionPrefix> found in Directory.Build.props or any non-test *.csproj")
found, edits = [], {}
for path, kind, rx, label in targets:
text = edits.get(path, read(path))
m = rx.search(text)
if not m:
continue
found.append((path.as_posix(), text.count("\n", 0, m.start()) + 1, m.group(2), label, kind))
if new is not None:
edits[path] = text[:m.start()] + m.group(1) + value_for(kind, new) + m.group(3) + text[m.end():]
for path, line, value, label, _ in found:
print(f"{path}:{line}: {value} [{label}]")
suf = {f[0]: f[2] for f in found if f[4] == "suffix"}
current = {f[2] if f[4] == "version" else f[2] + ("-" + suf[f[0]] if suf.get(f[0]) else "")
for f in found if f[4] in ("version", "prefix")}
if len(current) > 1:
print(f"WARNING: <Version> locations disagree: {sorted(current)}", file=sys.stderr)
if new is not None and not force:
sys.exit("Refusing to write: confirm with the user, then re-run with --force to align every location")
if len(current) == 1:
cur = next(iter(current))
print(f"current: {cur}")
ms = sorted({f[2] for f in found if f[4] == "msix"})
if ms and re.fullmatch(r"\d+\.\d+\.\d+(-.+)?", cur) and ms != [msix(cur)]:
print(f"note: MSIX Identity Version {ms} does not match {msix(cur)} derived from <Version> — realigned on write")
if new is None:
sys.exit(0)
for path, text in edits.items():
old = read(path)
if text == old:
continue
if dry:
sys.stdout.writelines(difflib.unified_diff(old.splitlines(True), text.splitlines(True), str(path), str(path)))
continue
raw = path.read_bytes()
out = text.replace("\r\n", "\n")
if b"\r\n" in raw:
out = out.replace("\n", "\r\n") # keep CRLF files CRLF (Visual Studio writes manifests that way)
path.write_bytes((b"\xef\xbb\xbf" if raw.startswith(b"\xef\xbb\xbf") else b"") + out.encode("utf-8"))
print(f"updated {path.as_posix()}")
PY
python3 "$TMPDIR/iru-dotnet-bump-version/dotnet_version.py" --list
Run it from the repository root (the directory holding the solution and Directory.Build.props). Output: note:
lines, one <file>:<line>: <value> [<location>] line per location, and a current: line. Then:
- No
<Version>/<VersionPrefix>anywhere (non-zero exit) — the project relies on the SDK default1.0.0or on a tool such as MinVer/Nerdbank.GitVersioning/GitVersion (look forMinVer,Nerdbank.GitVersioning/version.json,GitVersion.yml). With such a tool the git tag (orversion.json) is the version: report that, change nothing, and letiru-releasetreat the tag as the version (as for a Swift library). Otherwise ask whether to add<Version><new-version></Version>toDirectory.Build.props(creating it with a<Project><PropertyGroup>shell if absent) and re-run--list— never invent it silently. - Locations disagree (
WARNING: <Version> locations disagree) — e.g. two app projects carrying their own<Version>. Show the list and ask whether to align them all to<new-version>(Step 4 then passes--force) or which to leave (edit the others by hand and say so in the report). - MSIX identity out of step (
note: MSIX Identity Version … does not match) — informational: the identity is derived, so the write realigns it. Mention it in the report (someone hand-edited a manifest). - The current version for the report, and for
iru-release(which reads it this way), is thecurrent:line.
Step 3 — Look for mirrored mentions
grep -n -i "latest release\|current development version\|dotnet add package\|PackageReference Include\|winget install\|Version=" README.md 2>/dev/null
grep -rln -i "latest release\|dotnet add package\|winget install" docs/modules/ROOT/pages 2>/dev/null; grep -n '^version:' docs/antora.yml 2>/dev/null
Only when something turns up is sync-files worth asking about (Step 0). An .appinstaller file (App Installer
feed) carries the MSIX version too, but in this catalog's workflows it is generated at release time by
release.yml, not committed — if a committed one turns up, report it and leave it for the release workflow rather
than rewriting it here.
Step 4 — Preview (dry-run: yes) or apply
python3 "$TMPDIR/iru-dotnet-bump-version/dotnet_version.py" "<new-version>" --dry-run # unified diff only
python3 "$TMPDIR/iru-dotnet-bump-version/dotnet_version.py" "<new-version>" # write
Add --force only after the user confirmed aligning disagreeing locations (Step 2). The script rewrites exactly the
first match of each location --list printed — other <Version> elements (e.g. inside an unrelated ItemGroup
metadata), test projects, PackageVersions in Directory.Packages.props and every other file stay untouched. A
non-zero exit at any point → stop, show the error, and treat nothing as updated; never fall back to hand edits.
Step 5 — The .NET-catalog pre-release convention (for iru-release and other callers)
The .NET equivalent of iru-java-bump-version's -SNAPSHOT and iru-php-bump-version's -dev — the contract
iru-release calls this skill against (two calls per release, each passing a computed <new-version>), and the one
the sync.yml written by iru-setup-windows-github-workflows and the iru-setup-windows-app scaffold use:
- Current development version:
x.y.z-dev(e.g.1.4.0-dev) in<Version>on the gitflow integration branch. NuGet/SemVer order1.4.0-devbefore1.4.0and after1.3.0;AssemblyInformationalVersioncarries it as is. A main-only repository has no development marker:<Version>is always the last or next releasex.y.z. - MSIX identity: always
x.y.z.0, derived — so a1.4.0-devbuild is packaged as1.4.0.0, the same identity version the1.4.0release will get. Windows refuses to install a package over an installed one with the same identity version but different content, so a sideloaded dev build must be uninstalled before the release MSIX is installed (or CI may stamp a build-specific revision at packaging time — never in the committed manifest; the Microsoft Store requires revision0for submitted packages). - Cutting a release: strip the suffix —
x.y.z-dev→x.y.z. Call this skill with<new-version>: x.y.z. - Opening the next development cycle: bump the minor (this catalog's default, matching
iru-release's recommended "minor bump" and thesync.ymlworkflow) and re-append-dev—x.y.z→x.(y+1).0-dev; a patch or major bump is the caller's choice. Call this skill again (or letsync.ymldo it after the release is published) with that exact value — this skill never decides which component to bump. - Version-from-git projects (MinVer, Nerdbank.GitVersioning, GitVersion — Step 2): no in-tree marker; the release
is the git tag.
iru-releaseskips its "must currently be a pre-release" check and its next-version question for them, exactly as for a Swift library.
Step 6 — Sanity check
Skip with dry-run: yes. Otherwise:
python3 "$TMPDIR/iru-dotnet-bump-version/dotnet_version.py" --list # every location shows the new value, no warning
dotnet msbuild <one non-test project> -getProperty:Version # what MSBuild actually evaluates
-getProperty:Version must print <new-version> (it needs the .NET SDK; on a non-Windows host a WPF/WinForms project
evaluates fine because -getProperty doesn't resolve the Windows Desktop targeting pack — pick the Core/library
project anyway). Without an SDK, say the MSBuild check wasn't run rather than assume it passed.
Step 7 — Optionally sync other files that mirror the version
Ask (or honor sync-files) whether to update the mentions Step 3 found, mapping each by what it names — the same rule
as iru-java-bump-version's Step 7:
- "Latest release" markers (and a
Latest releaserow in the README's status table),dotnet add package <Id> --version x.y.z/<PackageReference Include="<Id>" Version="x.y.z" />snippets for this project's own package, andwinget install <Id> --version x.y.zlines →<new-version>only when it is a release. - "Current development version" markers →
next-versionwhen given; otherwise<new-version>only if it is a-devvalue, and untouched (warn in Step 8 that the caller should passnext-version) when it's a release — never regress a development marker to a release value. docs/antora.ymlversion:→<new-version>only for a release (Antora's component version names released versions; skip-dev), if the site tracks the release version at all.- Antora pages under
docs/modules/ROOT/pages/with the same markers/snippets → as for the README.
Targeted, scoped substitutions only — never a repository-wide replace of the old string. A README badge pulling the version live (NuGet, GitHub release, WinGet shields) must not be rewritten.
Step 8 — Report
- Every location from
--listwith its old value, the new<Version>(or prefix/suffix) and the derived MSIXx.y.z.0, and any explicitAssemblyVersion/FileVersion/InformationalVersionleft untouched. - Every file changed (by the script and by Step 7), or that nothing changed because the version comes from git tags.
- Any disagreement aligned with
--forceafter the user confirmed, and any MSIX identity that was out of step. - Whether this was a
dry-run, and the Step 6 result (--listagreement,-getProperty:Versionvalue or "not run"). - Warn explicitly: review the diff before committing (this skill commits nothing); a
-devbuild and the following release share the MSIX identity version (Step 5); a committed.appinstalleris not managed here.
Verification notes
Verified in $TMPDIR/iru-verify/windows/iru-dotnet-bump-version/ (macOS, .NET SDK 10.0.402, host python3 3.9) by
running the exact helper above against a copy of a scaffold-shaped solution (Directory.Build.props <Version>,
WPF app, src/<App>/Package.appxmanifest with CRLF line endings and a multi-line <Identity>, and
packaging/AppxManifest.xml) committed to a throwaway git repository:
--listreported<Version>1.2.0-dev, both identity versions and thecurrent:line, plus the out-of-step note for a manifest left at1.1.0.0.1.2.0rewrote<Version>and realigned the stale manifest to1.2.0.0;--dry-runprinted the diff and leftgit statusclean;1.3.0-devwrote<Version>1.3.0-dev</Version>andVersion="1.3.0.0"in both manifests; CRLF endings were preserved (filestill reports CRLF).- A library with
<VersionPrefix>2.0.0</VersionPrefix><VersionSuffix>dev</VersionSuffix>, an explicit<FileVersion>and a test project carrying its own<Version>9.9.9: the test project was ignored,FileVersionreported and untouched,2.0.0emptied the suffix anddotnet msbuild -getProperty:Versionthen printed2.0.0;2.1.0-devprinted2.1.0-dev.1.0was refused (no MSIX derivation possible). dotnet msbuild -getProperty:Versionon the Core project printed the new value;AssemblyVersion/FileVersioncome back empty from-getProperty(they are computed in targets, not at evaluation) — don't use them as a check.
Not verified: MinVer/Nerdbank.GitVersioning repositories (the "version from git" branch follows their docs), and real MSIX packaging/installation of the rewritten manifests — unverified locally (needs Windows).