| name | release-version |
| description | Steps to cut a VNote version release (e.g. v4.3.0) — bump the version with scripts/update_version.py, refresh and fill zh_CN/ja translations via lupdate, write the changes.md changelog, and trigger the CI [Release] build. Use when asked to "release", "cut a release", "bump the version", or "prepare vX.Y.Z". |
VNote Version Release
End-to-end checklist for releasing a new VNote version. Replace X.Y.Z with the
target version (e.g. 4.3.0) throughout.
0. Preconditions
- You are on
master (releases are published from master), working tree clean
except for the release changes.
- Decide
X.Y.Z. Confirm the previous tag with git tag | Sort-Object -Descending.
- Submodules are already pinned to their intended commits (see root
AGENTS.md
§ Submodule Push Discipline — push submodules BEFORE the parent).
1. Bump the version (use the script — do NOT hand-edit)
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.Z
src/data/core/Info.plist — short (X.Y) and full (X.Y.Z, X.Y.Z.1) strings
src/core/configmgr2.cpp — ConfigMgr2::c_version{X, Y, Z}
src/data/core/fun.vnote.app.VNote.metainfo.xml — prepends a dated <release> entry
Note: 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.
2. Update translations (extract, then fill, both locales)
The two maintained catalogs are src/data/core/translations/vnote_zh_CN.ts
(Simplified Chinese) and vnote_ja.ts (Japanese).
2a. Extract new/changed strings with lupdate
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).
- Harmless
pdf.js JS parse errors are expected — lupdate still finishes.
- The summary reports "N new" strings; those become
type="unfinished".
2b. Fill in the unfinished translations
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:
- Anchor the
<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">
- Numerus entries are
<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.
3. Write the changelog
Prepend a new ## vX.Y.Z section at the TOP of changes.md (right under the
# Changes header, above the previous version).
- Summarize
git log <prev-tag>..HEAD --oneline grouped by theme (Editor, Export,
Tasks, Fixes, Security, Translations, …), matching the style of existing entries.
- Lead with a one-line summary sentence "… on top of VNote :".
- If an
## Unreleased section exists, FOLD it into the new ## vX.Y.Z section
(it is not a separate release) rather than leaving both.
- Always end with a Translations bullet noting zh_CN + ja were updated.
4. Review
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.
5. Commit and trigger the release
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.
- Commit message MUST start with
[Release], e.g. [Release] VNote X.Y.Z.
- Follow repo
AGENTS.md rule 13 for author/commit date (night-time), and only
commit when the user explicitly asks.
- If submodule pointers moved, push submodules first, then the parent (rule +
root
AGENTS.md § Submodule Push Discipline).
6. Once CI is green, assemble the draft release
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
Set the release description from changes.md
The draft's body is driven from ## vX.Y.Z section of changes.md (the same
changelog written in step 3) focusing on user experience changes. CI creates the
release with an empty body, so set it.
# 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 after rewriting it to focus on user experience changes.
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.
ncipollo/release-action REPLACES the body and the name on every update unless
told not to, and all four platform jobs target the same draft with
allowUpdates: true. Before 4.4.2 that silently blanked the notes: whichever job
finished last wiped them, gh release view then showed an empty body, and the
Gitee mirror — which copies the GitHub body — fell back to the bare tag name. The
three ci-*.yml Release steps now pass omitBodyDuringUpdate: true and
omitNameDuringUpdate: true, so notes set at any point after the release exists
survive. If you ever see the body empty again, check those inputs are still there
before re-typing the notes.
Publish
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.)
Quick reference
| 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 |
Release artifacts (must all be present before publishing)
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)