.kilo/skills/release-version/SKILL.md
End-to-end checklist for releasing a new VNote version. Replace X.Y.Z with the
target version (e.g. 4.3.0) throughout.
master (releases are published from master), working tree clean
except for the release changes.X.Y.Z. Confirm the previous tag with git tag | Sort-Object -Descending.AGENTS.md
§ Submodule Push Discipline — push submodules BEFORE the parent).python scripts/update_version.py X.Y.Z
scripts/update_version.py is the single source of truth. It updates:
CMakeLists.txt — project(... VERSION X.Y.Z ...).github/workflows/ci-win.yml, ci-linux.yml, ci-macos.yml, ci-linux-tsan.yml
— VNOTE_VER: X.Y.Zsrc/data/core/Info.plist — short (X.Y) and full (X.Y.Z, X.Y.Z.1) stringssrc/core/configmgr2.cpp — ConfigMgr2::c_version{X, Y, Z}src/data/core/fun.vnote.app.VNote.metainfo.xml — prepends a dated <release> entryNote: ci-linux-tsan.yml IS in the script's file list (scripts/update_version.py:21)
and the [Release] VNote 4.3.0 commit a282ef07 bumped it too, so keep the script's
change. Its VNOTE_VER is only a build sanity value and does not publish anything —
it just tracks the release version.
Verify: git diff --stat should show exactly the files listed above.
The two maintained catalogs are src/data/core/translations/vnote_zh_CN.ts
(Simplified Chinese) and vnote_ja.ts (Japanese).
lupdate ships with Qt, and the installed Qt version changes over time — do NOT
hardcode a path (C:\Qt\6.9.3\... was already gone by 4.3.1). Resolve it first:
$lupdate = (Get-Command lupdate -ErrorAction SilentlyContinue).Source
if (-not $lupdate) {
$lupdate = (Get-ChildItem C:\Qt -Recurse -Filter lupdate.exe -ErrorAction SilentlyContinue |
Where-Object FullName -like '*msvc*' | Sort-Object FullName -Descending |
Select-Object -First 1).FullName
}
$lupdate # e.g. C:\Qt\6.10.3\msvc2022_64\bin\lupdate.exe
& $lupdate -no-obsolete -locations relative src `
-ts src/data/core/translations/vnote_zh_CN.ts src/data/core/translations/vnote_ja.ts
-no-obsolete drops entries no longer in the source (keeps the catalog lean).pdf.js JS parse errors are expected — lupdate still finishes.type="unfinished".Every type="unfinished" entry must be translated for BOTH locales. Count them:
(Select-String -Path "src/data/core/translations/vnote_zh_CN.ts" -Pattern 'type="unfinished"').Count
(Select-String -Path "src/data/core/translations/vnote_ja.ts" -Pattern 'type="unfinished"').Count
Extract the source strings needing translation, then for each <message> whose
<translation type="unfinished"> is empty, provide the localized text and drop
the type="unfinished" attribute (<translation>...</translation>).
Practical approach: script it. Build a source -> translation map per locale and
rewrite each unfinished <message> block, escaping &/</> in the output and
preserving %1/%2 placeholders, &-accelerators (e.g. &View -> 查看(&V) /
表示(&V)), and literal newlines. Re-run the count above; both must reach 0.
Two traps when scripting the rewrite:
<source> capture. A pattern like <source>(.*?)</source> with
(?s) lets the group swallow whole </message> blocks to reach a later
unfinished <translation>, silently deleting every message in between (a whole
<context> disappeared this way during 4.3.1). Forbid the closing tag inside
the group and stop the gap before the next message:
<source>((?:(?!</source>).)*)</source>((?:(?!</message>|<source>).)*?)<translation type="unfinished"><translation type="unfinished"><numerusform></numerusform></translation>.
Fill the <numerusform> rather than replacing the element body, so the plural
structure and indentation survive.Then VERIFY against the pre-edit file — an over-matching regex leaves the counts looking fine while having eaten unrelated entries:
foreach ($l in 'zh_CN','ja') {
git show "HEAD:src/data/core/translations/vnote_$l.ts" > "$env:TEMP\old_$l.ts"
$o = [xml](Get-Content -Raw "$env:TEMP\old_$l.ts")
$n = [xml](Get-Content -Raw "src/data/core/translations/vnote_$l.ts")
# NOTE: PowerShell's XML adapter returns a bare string for <translation> once
# the type attribute is gone, so .InnerText is $null — normalize first.
function Tr($m) { $t = $m.translation; if ($null -eq $t) { '' } elseif ($t -is [string]) { $t } else { $t.InnerText } }
$old = @{}; foreach ($c in $o.TS.context) { foreach ($m in $c.message) { $old["$($c.name)|$($m.source)"] = (Tr $m) } }
$changed = 0; $bad = 0
foreach ($c in $n.TS.context) { foreach ($m in $c.message) {
$k = "$($c.name)|$($m.source)"; $tr = (Tr $m)
if ($old.ContainsKey($k)) { if ($old[$k] -ne $tr) { $changed++; "CHANGED $k" }; continue }
$ps = @([regex]::Matches($m.source, '%\d|%n') | ForEach-Object { $_.Value } | Sort-Object -Unique)
$pt = @([regex]::Matches($tr, '%\d|%n') | ForEach-Object { $_.Value } | Sort-Object -Unique)
if (($ps -join ',') -ne ($pt -join ',')) { $bad++; "PLACEHOLDER $k" }
} }
"$l changed_existing=$changed placeholder_mismatch=$bad"
}
changed_existing MUST be 0 (only lupdate's own obsolete removals may drop keys —
cross-check them against git log / the source tree) and placeholder_mismatch
MUST be 0. Finally, compile both catalogs; each must report 0 unfinished:
foreach ($l in 'zh_CN','ja') {
& ($lupdate -replace 'lupdate\.exe$','lrelease.exe') "src/data/core/translations/vnote_$l.ts" -qm "$env:TEMP\$l.qm"
}
Remove-Item "$env:TEMP\zh_CN.qm","$env:TEMP\ja.qm"
The .qm binaries are generated at build time by the lrelease CMake target
(see src/CMakeLists.txt), so you do NOT commit .qm files.
Prepend a new ## vX.Y.Z section at the TOP of changes.md (right under the
# Changes header, above the previous version).
git log <prev-tag>..HEAD --oneline grouped by theme (Editor, Export,
Tasks, Fixes, Security, Translations, …), matching the style of existing entries.## Unreleased section exists, FOLD it into the new ## vX.Y.Z section
(it is not a separate release) rather than leaving both.Per repo AGENTS.md rule 17, delegate to the review subagent (Task tool) for a
read-only second opinion on the release diff before finalizing.
CI publishes a (draft) GitHub release from master ONLY when the head commit
message starts with [Release] (see the Release job in each ci-*.yml;
condition: github.ref == 'refs/heads/master' && startsWith(head_commit.message, '[Release]')).
It creates tag vX.Y.Z and uploads the platform artifacts.
[Release], e.g. [Release] VNote X.Y.Z.AGENTS.md rule 13 for author/commit date (night-time), and only
commit when the user explicitly asks.AGENTS.md § Submodule Push Discipline).The [Release] commit makes CI create a draft GitHub release for tag vX.Y.Z.
Wait until ALL platform jobs are green, then make sure the draft carries the four
release artifacts before publishing. The ncipollo/release-action step in each
ci-*.yml uploads its own platform's asset directly, but confirm all four are
present (and if any job's upload was skipped/failed, download that job's build
artifact and attach it manually).
The four artifacts (with X.Y.Z substituted):
| Platform | Artifact file | Produced by |
|---|---|---|
| Linux | VNote-X.Y.Z-linux-x64.AppImage | ci-linux.yml |
| macOS | VNote-X.Y.Z-mac-<arch>.dmg (universal) | ci-macos.yml |
| Win64 (Qt 6) | VNote-X.Y.Z-win64.zip | ci-win.yml (suffix "") |
| Windows 7 (Qt 5.15) | VNote-X.Y.Z-win64-windows7.zip | ci-win.yml (suffix -windows7) |
Watch the runs and confirm the draft, using gh:
# Watch the release-triggering runs on master until they finish.
gh run list --branch master --limit 8
gh run watch <run-id>
# Inspect the draft release and its currently-attached assets.
gh release view vX.Y.Z
If an asset is missing, download it from the corresponding workflow run and upload it to the draft:
# Download the build artifact(s) from a finished run into ./_artifacts.
gh run download <run-id> -D _artifacts
# Attach a missing asset to the draft release (repeat per file).
gh release upload vX.Y.Z "_artifacts\<path>\VNote-X.Y.Z-...zip" --clobber
changes.mdThe draft's body MUST be the ## vX.Y.Z section of changes.md (the same
changelog written in step 3) — nothing more, nothing less. CI seeds a generic
body, so overwrite it. Extract exactly that one section (from its ## vX.Y.Z
heading up to, but not including, the next ## heading) and set it as the notes:
# Extract the ## vX.Y.Z section into a temp notes file...
$ver = "X.Y.Z"
$md = Get-Content changes.md -Raw
$sec = [regex]::Match($md, "(?ms)^## v$([regex]::Escape($ver))\b.*?(?=^## |\z)").Value.TrimEnd()
Set-Content -Path notes.md -Value $sec -NoNewline -Encoding utf8
# ...and apply it as the draft's description.
gh release edit vX.Y.Z --notes-file notes.md
Drop the leading ## vX.Y.Z line if you prefer the version to appear only as the
release title; keep the bullet body either way. Verify with gh release view vX.Y.Z.
Only when the draft vX.Y.Z release shows all 4 artifacts (linux / macos /
win64 / windows7) AND its body matches the changes.md section do you publish it:
gh release edit vX.Y.Z --draft=false
Publishing fires the Gitee Mirror workflow (.github/workflows/gitee-mirror.yml),
which creates the matching release on gitee.com/vnotex/vnote and prunes all but
the two most recent ones. Confirm it started and check its result:
gh run list --workflow "Gitee Mirror" --limit 3
curl.exe -s "https://gitee.com/api/v5/repos/vnotex/vnote/releases" |
ConvertFrom-Json | Select-Object tag_name, name
Expect at most two Gitee releases, the newest with tag_name = vX.Y.Z. If no run
appeared, re-drive it manually with gh workflow run "Gitee Mirror" -f tag=vX.Y.Z.
The workflow does not upload binaries — Gitee's attachment endpoint runs at well under 50 KB/s from a GitHub-hosted runner. Download the 4 artifacts from the GitHub release and attach them to the Gitee release by hand:
gh release download vX.Y.Z -D gitee-assets
Then upload them at https://gitee.com/vnotex/vnote/releases → edit the release.
Ordering does not matter: the workflow calls no attachment endpoint, and the
release it just mirrored is always one of the two it keeps, so re-running it can
never strip the files you uploaded. (Pruning does delete older releases along
with whatever was attached to them.)
| Step | Command / File |
|---|---|
| Version bump | python scripts/update_version.py X.Y.Z |
| Extract strings | lupdate -no-obsolete -locations relative src -ts <zh_CN.ts> <ja.ts> |
| Fill translations | edit vnote_zh_CN.ts, vnote_ja.ts until 0 unfinished |
| Changelog | prepend ## vX.Y.Z to changes.md |
| Release trigger | commit on master with message starting [Release] |
| Set description | gh release edit vX.Y.Z --notes-file notes.md (the ## vX.Y.Z section of changes.md) |
| Assemble release | wait for green CI, ensure draft vX.Y.Z has all 4 artifacts + changelog body, then gh release edit vX.Y.Z --draft=false |
VNote-X.Y.Z-linux-x64.AppImage (linux)VNote-X.Y.Z-mac-<arch>.dmg (macos, universal)VNote-X.Y.Z-win64.zip (win64, Qt 6)VNote-X.Y.Z-win64-windows7.zip (windows7, Qt 5.15)