Releasing¶
Release pipeline¶
Releases are driven by GitHub Actions (.github/workflows/release.yml). The
pipeline runs on a version tag (v*) and proceeds in this order:
- Verify the tag matches every version (
scripts/check-version.mjschecks thepackage.jsonfiles,tauri.conf.json, and the Cargo workspace version). - Build and package the desktop app per platform in a build matrix, using
tauri buildviatauri-action. Artifacts are signed where the relevant signing secrets are configured. - Publish a GitHub release with notes generated from
CHANGES.md(scripts/release-notes.mjs) and every built artifact attached.
Continuous checks run separately on every push and pull request via
.github/workflows/ci.yml: the TypeScript side runs pnpm lint,
pnpm typecheck, and coverage-gated tests, and the Rust side runs
cargo fmt --check, cargo clippy, and cargo test. See
Development for the local gate commands.
Per-platform packaging¶
Packaging is done by the Tauri bundler (tauri build). The bundle targets are
declared in apps/desktop/src-tauri/tauri.conf.json and are: app, dmg,
deb, appimage, nsis, msi.
| Platform | Artifact(s) |
|---|---|
| Linux | AppImage, deb |
| macOS | dmg + .app (signed and notarized when credentials are provided as CI secrets) |
| Windows | nsis installer + msi (Authenticode-signed when a certificate secret is set) |
Application icons are generated from the sources in assets/icons/
(icon-default.png / icon-dark.png plus the renderer glyph SVGs) by
scripts/gen-icons.mjs (pnpm run icons), which writes the platform icon set
into apps/desktop/src-tauri/icons/. That directory is git-ignored; the desktop
crate's build.rs regenerates it when missing, and CI regenerates it before the
build.
Windows MSIX (best-effort)¶
In addition to the nsis and msi bundles, the release workflow packages the
built executable into an MSIX with the Windows SDK MakeAppx tool, for optional
distribution through the Microsoft Store. This step is best-effort
(continue-on-error): it is skipped when the executable or MakeAppx is
unavailable and never fails the release. The MSIX is uploaded alongside the
other artifacts and is unsigned (the Store signs it on ingest).
Branching and release flow¶
feature/* -> develop -> main (Merge Request)
- All feature work lands in
developvia feature branch Merge Requests. - When ready to release, open a Merge Request from
developtomain. - A release commit on
mainprepares the version (bump the version in everypackage.json,tauri.conf.json, and the Cargo workspace so they all match; updateCHANGES.mddevelopment bullets to a versioned heading). The release pipeline verifies these agree with the tag. - Tag the release commit:
git tag v1.0.0. - Push the tag to trigger the release pipeline.
Direct commits to main are allowed only until the first release. After v1,
all changes enter main only via Merge Request.
Release candidates are tagged on develop¶
A release candidate exists to exercise this pipeline -- the four-platform build,
the asset names, the generated notes, the installers -- before the work reaches
the release branch. Requiring a merge to main first would defeat that and fill
main with candidates, so:
| Tag | Cut from | Published as |
|---|---|---|
v<version>-rc.<n> |
develop |
GitHub pre-release |
v<version> |
main |
full release |
Only RC tags may come from develop. scripts/check-tag-branch.mjs runs in the
pipeline's first job and fails a final tag that is not on main, before anything
is built or published.
Three properties make an RC safe to publish from develop:
- The workflow marks any tag containing
-as a pre-release, andreleases/latest/download/...-- the URL both one-line installers use -- never resolves to a pre-release. Users oninstall.shwill not pick up an RC. - The docs workflow ignores pre-release tags entirely, so an RC neither becomes
the site's
latest(which the bare documentation URL redirects to) nor adds a throwaway entry to the version switcher. A candidate's docs are in-progress docs, already published under thedevalias frommain. - Nothing else consumes the release except by explicit tag.
Cutting an RC therefore looks like:
git checkout develop
node scripts/bump-version.mjs 1.0.0-rc.1
git commit -am "release: 1.0.0-rc.1"
git tag -a v1.0.0-rc.1 -m "SkillKeeper 1.0.0-rc.1"
git push origin develop
git push origin v1.0.0-rc.1
CHANGES.md¶
CHANGES.md is a per-version list of short bullet points grouped into:
- Breaking Changes
- Features
- Fixes
New work lands under a top Development heading during development. At
release, those bullets are moved under the new version heading. Each bullet is
short; large items link to documentation rather than describing details inline.
versions.json¶
Every release also publishes versions.json
(scripts/gen-versions-json.mjs), the manifest the desktop app's
self-updater reads: one entry per version with its tag, whether it is a
prerelease, and release notes, plus, on the newest entry only, the
per-platform download assets and their SHA-256. It is generated on every tag,
release candidates included; that is safe because
releases/latest/download/... never resolves to a prerelease, so an RC's
manifest is never served to a stable build (a candidate build instead queries
the GitHub API for the newest release, prereleases included, by design). A
version whose GitHub release was later deleted drops out of the manifest on
the next release, rather than lingering as an advertised download that 404s.
Release ordering is not chronological¶
GitHub orders both the releases page and GET /repos/{owner}/{repo}/releases
LEXICOGRAPHICALLY by tag name, not by date, and the order is not configurable.
So v0.5.0-rc.10 sorts below v0.5.0-rc.3, because as text 1 is less than
3. This is not a release-candidate quirk: v0.10.0 will sort below v0.9.0
for exactly the same reason.
Do not rely on that order for anything. The self-updater does not: it reads
several pages, drops drafts, and picks the greatest tag with its own version
parser (app_update::fetch::greatest_tag), which compares the numbers as
numbers. Sorting by date would be wrong anyway -- we want the highest version,
and a re-published older release is the newest by time while being the wrong
answer.
The one ordering-adjacent thing that IS controllable is which release carries
the "Latest" designation, through the make_latest field on release creation.
It needs no setting here: a candidate is published with prerelease: true and
so can never take it, and a final release takes it by default. That designation,
not the list order, is what releases/latest/download/... resolves against,
which is why a stable build reading that URL was never affected by any of this.
Treat the page's own order as decoration.
macOS signing and notarization¶
macOS dmg builds are signed and notarized in CI. Signing certificates and notarization credentials are provided as encrypted CI secrets. Notarization is performed through the platform notary service (the ticket is stapled after notarization).
Locally, you can produce unsigned installers for testing by running the desktop build without any signing secrets:
pnpm --filter @skillkeeper/desktop build
(This runs tauri build; with no signing credentials configured it emits
unsigned artifacts.)
Release integrity: checksums and GPG signature¶
Every release ships two extra assets next to the installers:
checksums.txt-- SHA-256 of every attached artifact (one line per file).checksums.txt.asc-- a detached, armored GPG signature overchecksums.txt, produced in thepublishjob of.github/workflows/release.yml.
The signing key is a dedicated release key (Lorem Dev Release
<contact@lorem.dev>), separate from any maintainer's personal
commit-signing key. Its private half and passphrase live in the CI secrets
SKILLKEEPER_RELEASE_GPG_KEY and SKILLKEEPER_RELEASE_GPG_PASSPHRASE; the
public half is committed at
.github/release-key.asc.
See CONTRIBUTING.md for how the key is bootstrapped
and rotated.
Signing is gated on the SKILLKEEPER_RELEASE_GPG_KEY secret: when it is
absent the workflow still publishes checksums.txt but skips the signature
and prints a warning, so a release stays green-able before the key is
configured (the same way the macOS and Windows code-signing steps degrade to
unsigned artifacts).
| Field | Value |
|---|---|
| Owner | Lorem Dev Release <contact@lorem.dev> |
| Algorithm | ed25519 (signing) + cv25519 (encryption subkey) |
| Fingerprint | CFE6 485E 2351 9A25 A475 B900 AD0F 7A29 E439 8670 |
Verifying a release¶
# 1. Import the public key and confirm the fingerprint matches the table above.
curl -fsSL https://raw.githubusercontent.com/lorem-dev/skillkeeper/main/.github/release-key.asc \
| gpg --import
gpg --list-keys --fingerprint contact@lorem.dev
# 2. Download the checksum file, its signature, and the artifact you want.
TAG=v1.0.0
BASE="https://github.com/lorem-dev/skillkeeper/releases/download/${TAG}"
curl -fsSLO "${BASE}/checksums.txt"
curl -fsSLO "${BASE}/checksums.txt.asc"
# ...and one artifact, e.g. the Linux AppImage:
curl -fsSLO "${BASE}/SkillKeeper_1.0.0_amd64.AppImage"
# 3. Verify the signature over the checksum file.
gpg --verify checksums.txt.asc checksums.txt
# 4. Verify the artifact against the now-trusted checksum file.
sha256sum -c checksums.txt --ignore-missing
# macOS: shasum -a 256 -c checksums.txt --ignore-missing
A successful run prints
Good signature from "Lorem Dev Release <contact@lorem.dev>" followed by
<artifact>: OK. Any other outcome means the file should be discarded and
re-downloaded.
Desktop UI¶
The desktop app is a Tauri v2 shell (Rust backend) with a React 19 + Zustand renderer covering the Repositories, Skills, Projects, and Settings views. All privileged work (filesystem, Git, config, state) runs in the Rust backend; the renderer holds only UI state and a mirror of backend data and talks to the backend through a typed Tauri command/event bridge.