Statements the release reviews of 1.1.1 found untrue at this commit, and two gaps in the release tooling. - Threat model: the version reviewed; an installed build contacts git.kof22.com for updates; the binaries are signed (the accepted risks named them unsigned) and T1 with the Scoop part of T2 is what is accepted; T2 and T11 say what ADR 0009 changed, and T11 is not marked mitigated as a whole while the copy to the release server is manual. - SECURITY.md names 1.1.x as the supported line. - README: the beta is a separate app with its own accounts; parallelism is under Sync options, after loading; the buttons have the names on screen; the app is built unpackaged, with no Developer Mode. CONTRIBUTING the same. docs/CLI.md shows a current version. - RELEASING.md: the check that the server answers from outside comes before anything is tagged; a version that moves the updater is rehearsed with two prereleases read from that server; the copy to git.kof22.com is a numbered step; the packaging change is committed on Gitea; winget is updated by hand while the job would submit the GitHub URL; the engine package is numbered by its own public surface. - Update-PackageManifests.ps1 downloads the file the manifests will name and compares its SHA-256 with that of the built file it is given, instead of asking only whether the URL answers; it writes the Scoop manifest with the repository's line endings. - One vault test stored an entry in the credential namespace the installed app uses. It uses two test scopes now (#161). Closes #183 Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Signed-off-by: James Maes <james@kof22.com>
23 KiB
Releasing gclo
Releases are automated by .github/workflows/release.yml,
which calls the KofTwentyTwo standards' shared
release-velopack.yml
for the build (ADR 0007). Pushing a
tag that starts with v builds, tests, packages, and publishes everything. This
document explains how versioning works, what gets published where, what must be
configured for a release to succeed, and how to cut one start to finish.
Versioning and channels
The version is the tag with the leading v stripped. Tags must be strict
semver (vMAJOR.MINOR.PATCH plus an optional prerelease
suffix). Build metadata (+...) is rejected.
| Tag | Version | GitHub Release | Velopack channel |
|---|---|---|---|
v1.2.3 |
1.2.3 |
stable | stable |
v1.2.3-beta.1 |
1.2.3-beta.1 |
prerelease | dev |
Any prerelease identifier works (-alpha, -beta.2, -rc.1, ...) — the rule
is simply: a - in the version means prerelease, which means the dev
channel.
The dev edition is a separate app
A prerelease is packed as its own Velopack app, id gclo-dev, while stable
releases use gclo. The two install side by side: separate install folder
(%LOCALAPPDATA%\gclo-dev vs %LOCALAPPDATA%\gclo), Start menu entry, update
feed and data root (%LOCALAPPDATA%\KofTwentyTwo\gclo-dev vs …\gclo), and
the dev edition's window title reads "gclo (dev)". A machine can therefore keep
the stable app for real work and a dev build for trying the next release; each
only ever updates within its own channel. The dev edition's assets follow its
app id: gclo-dev-dev-Setup.exe, gclo-dev-dev-Portable.zip,
gclo-dev-1.2.3-beta.1-dev-full.nupkg, gclo-dev-cli-win-x64-<version>.zip,
releases.dev.json. Both editions read the same Windows Credential Manager
entries (the default token saved in one is available in the other; account
tokens are keyed by account id, and each edition has its own accounts), so a
dev build needs no token re-entry. Dev installs made before this split (app id
gclo on the dev channel, up to 1.0.2-beta.3) cannot update into gclo-dev:
uninstall them, or install the stable app over them.
How self-update channels map
The desktop app updates itself with Velopack. Each
release is packed with vpk pack --channel <stable|dev>, which stamps the
channel into the installed app. An installed app only ever sees updates from
its own channel:
- Users who installed from a stable release (
gclo-stable-Setup.exe) get updates only when you tag a new stable release. - Users who installed from a dev release (
gclo-dev-dev-Setup.exe) get every prerelease you tag.
A stable release v1.2.3 carries:
gclo-stable-Setup.exe— the installergclo-stable-Portable.zip— portable buildgclo-1.2.3-stable-full.nupkg— full update packagegclo-1.2.3-stable-delta.nupkg— delta from the previous stable release (absent on the first release of a channel)releases.stable.jsonandassets.stable.json— the Velopack feed, limited to this buildgclo-cli-win-x64-1.2.3.zip— self-contained single-file CLI, with the LICENSEgclo-1.2.3.cdx.json,gclo-cli-1.2.3.cdx.json— CycloneDX SBOMsSHA256SUMS— one line per asset
The shared workflow downloads the previous release on the same channel before packing so deltas can be generated; on the very first release of a channel that finds nothing, and the release simply has no delta package.
What gets published, and what it needs
Everything a publishing job needs lives in the release environment
(Settings → Environments → release, deployment rule: tags matching v*) or in
repository variables. A job whose token is missing fails — a green run means
every enabled channel was published. A failed publisher can be re-run on its own
after the token is added ("Re-run failed jobs"); the release itself is not rebuilt.
winget is the one channel switched on explicitly (WINGET_PACKAGE_ID), because
its first submission cannot be automated; see below.
| Job | Runs for | Needs | Publishes |
|---|---|---|---|
release (shared workflow) |
every tag | the signing identity, as release-environment variables (no secret exists): AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT, TRUSTED_SIGNING_ACCOUNT, TRUSTED_SIGNING_PROFILE (see "Code signing") |
the GitHub Release: Setup, portable, full/delta packages, feed, CLI zip, SBOMs, SHA256SUMS, provenance and SBOM attestations; every exe/dll and Setup.exe Authenticode-signed |
verify-signatures |
every tag | TRUSTED_SIGNING_SUBJECT (release-environment variable: the publisher name on the certificate, as validated by Azure) |
nothing; fails the run when a shipped executable is unsigned, untimestamped or signed by another publisher |
packages |
stable only | RELEASE_APP_PRIVATE_KEY (private key of the release GitHub App, see below) plus the repository variable RELEASE_APP_CLIENT_ID, CHOCO_API_KEY (chocolatey.org) |
the Chocolatey package pushed to chocolatey.org, and a build(packaging): … pull request against main with bucket/gclo.json and packaging/chocolatey pointed at the release |
winget |
stable only, once the repository variable WINGET_PACKAGE_ID is set (KofTwentyTwo.gclo, after the one-time manual submission below) |
WINGET_TOKEN (fine-grained token that can fork and open pull requests on public repositories, ≤ 90 days); missing → the job fails |
a manifest update PR on microsoft/winget-pkgs |
nuget |
only when the repository variable NUGET_PUBLISH is true |
NUGET_API_KEY (nuget.org, push rights for gclo.Engine) |
gclo.Engine on nuget.org |
Why a GitHub App: main requires signed commits, a DCO sign-off, and the full set
of status checks. A pull request opened with the default GITHUB_TOKEN triggers
no checks, and the action that opens the PR cannot sign commits with a personal
access token. With a short-lived token minted from the App, GitHub signs the
commit, its author is the App's [bot] user (exempt from the DCO check), and the
checks run. Create the App once (the same pattern the standards repository uses):
GitHub App installed only on this repository, permissions Contents and Pull
requests write, no ruleset bypass. Store its client id as the Actions variable
RELEASE_APP_CLIENT_ID and its private key (.pem) as RELEASE_APP_PRIVATE_KEY
in the release environment; never as a repository secret or a committed file.
Code signing
Every release is Authenticode-signed with Azure Artifact Signing (formerly Trusted
Signing): the shared workflow signs every published .exe/.dll (app and CLI) before
packing, and vpk pack signs Setup.exe and the update stub with the same identity
(--azureTrustedSignFile). Always SHA-256 with an RFC 3161 timestamp from
http://timestamp.acs.microsoft.com. SHA256SUMS, the Velopack feed and the
attestations are produced after signing, so they describe the signed files. The
verify-signatures job then downloads what was published and runs
signtool verify /pa /all on Setup.exe and every gclo executable (ADR
0008).
There is no signing secret: the release job logs in to Azure over GitHub OIDC
(azure/login) as an Entra application whose federated credential trusts only this
repository's release environment, and that application holds only the
Artifact Signing Certificate Profile Signer role on the certificate profile.
The identity is configuration, not a secret, so it lives in release-environment
variables:
| Variable | Value |
|---|---|
AZURE_TENANT_ID |
the Entra tenant GUID (not the directory domain) |
AZURE_CLIENT_ID |
the application (client) id of gclo-release-signing |
AZURE_SUBSCRIPTION_ID |
the subscription that holds the signing account |
TRUSTED_SIGNING_ENDPOINT |
the account's regional endpoint, e.g. https://eus.codesigning.azure.net |
TRUSTED_SIGNING_ACCOUNT |
the signing account name (kof22signing) |
TRUSTED_SIGNING_PROFILE |
the certificate profile name (releases) |
TRUSTED_SIGNING_SUBJECT |
the validated publisher name on the certificate, checked by verify-signatures |
A missing variable fails the Azure login, and the run with it; nothing unsigned is
published. The Azure side (application, federated credential, role assignment) is
administered interactively by the owner; .github/scripts/Set-ReleaseSigning.ps1
records the exact steps and is idempotent.
Signing locally
Only for a one-off (for example re-signing a hotfix build by hand); releases are
signed in CI. You need SignTool (Windows SDK), the .NET 8 runtime, the
Microsoft.ArtifactSigning.Client
package (the dlib), and an identity holding the Certificate Profile Signer role:
# One-time: the dlib, and a metadata file with the same values as the variables above
nuget install Microsoft.ArtifactSigning.Client -x -OutputDirectory $env:LOCALAPPDATA\ArtifactSigning
@{ Endpoint = 'https://eus.codesigning.azure.net'; CodeSigningAccountName = 'kof22signing'; CertificateProfileName = 'releases' } |
ConvertTo-Json | Set-Content $env:LOCALAPPDATA\ArtifactSigning\metadata.json
# Sign in as an authorized identity (the dlib uses the Azure CLI / Azure PowerShell credential)
az login --tenant <tenant GUID>
# Sign and verify
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe | Where-Object FullName -match '\\x64\\' | Sort-Object FullName -Descending | Select-Object -First 1
& $signtool sign /v /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 `
/dlib "$env:LOCALAPPDATA\ArtifactSigning\Microsoft.ArtifactSigning.Client\bin\x64\Azure.CodeSigning.Dlib.dll" `
/dmdf "$env:LOCALAPPDATA\ArtifactSigning\metadata.json" .\gclo.exe
& $signtool verify /pa /all /v .\gclo.exe
First winget submission (one-time, manual)
wingetcreate update can only update a package that already exists in
microsoft/winget-pkgs. The first
submission is done by hand, once, after the first stable GitHub release exists
(until WINGET_PACKAGE_ID is set the winget job does not run):
# From any Windows machine:
Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
# Interactive: prompts for package id (use KofTwentyTwo.gclo), publisher,
# license, description, etc., then opens a PR against microsoft/winget-pkgs.
.\wingetcreate.exe new https://git.kof22.com/KofTwentyTwo/gclo/releases/download/v1.2.3/gclo-stable-Setup.exe --token <your GitHub PAT>
Once that first PR is merged and the package is live, the winget job can update
the manifest for every later stable release: it runs when the repository variable
WINGET_PACKAGE_ID=KofTwentyTwo.gclo and the WINGET_TOKEN secret are set.
Leave WINGET_PACKAGE_ID unset for now. The job submits the download URL of
the GitHub release, and the downloads are on git.kof22.com (ADR 0009). Until the
pipeline runs there (#197), update winget by hand for each stable release:
.\wingetcreate.exe update KofTwentyTwo.gclo --version 1.2.3 --urls https://git.kof22.com/KofTwentyTwo/gclo/releases/download/v1.2.3/gclo-stable-Setup.exe --submit --token <your GitHub PAT>
Scoop and Chocolatey
Both install the same asset, gclo-cli-win-x64-<version>.zip, by URL and SHA-256.
- Scoop needs no account: this repository is its own bucket. Users run
scoop bucket add gclo https://git.kof22.com/KofTwentyTwo/gcloonce, thenscoop install gclo. The manifest (bucket/gclo.json) carriescheckverandautoupdateentries keyed onSHA256SUMS, soscoop updateworks as soon as the manifest onmainnames the new version — which is why thepackagesjob opens its pull request againstmain(three generated files, nothing else). The workflow runs on GitHub and opens that pull request on the GitHub copy, which takes no changes of its own. Scoop reads the bucket frommainongit.kof22.com, so the same three files are committed there through a pull request: runUpdate-PackageManifests.ps1by hand (below) on a branch and merge it on Gitea. Until then Chocolatey can already hold the new version while Scoop still names the old one. - Chocolatey needs a one-time account on chocolatey.org and its API key as
CHOCO_API_KEY. The first version goes through Chocolatey's moderation queue (expect a few days); later versions are usually auto-approved. Users then runchoco install gclo. The community repository holds one version of a package in moderation at a time: pushing another while one is queued fails with HTTP 403 (v1.1.0 hit this while 1.0.1 was queued). Thepackagesjob opens the manifests PR before the push so Scoop is never held up; re-run the failed job once the queued version is approved or rejected. - To re-point the manifests by hand (for example after a missed release, and for
the commit on Gitea):
./.github/scripts/Update-PackageManifests.ps1 -Version 1.2.3 -CliZipPath <zip>.<zip>is the CLI zip downloaded from the GitHub release, where it was built, aftergh attestation verifyhas passed for it. The script compares the file on git.kof22.com with the one it is given: given a zip taken from git.kof22.com, it would compare the server's file with itself.
The release on git.kof22.com
From 1.1.1 on, the app's updater, the Scoop manifest and the Chocolatey package
read releases from https://git.kof22.com/KofTwentyTwo/gclo
(ADR 0009). The release workflow
still builds, signs and publishes on GitHub. Until a pipeline on Gitea exists, a
release is therefore not done until it also exists on Gitea, exactly as it
does on GitHub:
- The same tag and the same assets, byte for byte: Setup.exe, the portable
zip, the CLI zip, the
.nupkgpackages (full and delta),releases.<channel>.json,SHA256SUMS, the SBOMs. The updater needs the feed file and the packages; the package managers need the CLI zip andSHA256SUMS. - The same prerelease flag. A beta copied without the flag becomes the latest release (Scoop would see it, and stable installs would be offered nothing newer because their feed file is not in it). A stable release copied with the flag is invisible to stable installs.
- Before the package manifests are written.
Update-PackageManifests.ps1downloads the CLI zip from git.kof22.com and refuses to write a manifest when it does not answer or is not, by its SHA-256, the file that was built; so thepackagesjob fails until the copy is done and right. Run it again afterwards. - winget by hand. The workflow's winget step would submit the download on GitHub, so it stays switched off ("First winget submission"); a version is submitted to winget by hand, with the URL on git.kof22.com.
- No more than nine prereleases newer than the latest stable release. The updater reads the ten newest releases and picks by channel; a longer run of betas hides the stable release from stable installs. Delete old prereleases.
- From the network the users are on. Check the release page and one download from outside the local network, without signing in. For the server as such this is checked before anything is tagged (step 2 below): an install that has updated to a version that reads git.kof22.com looks nowhere else.
Releases on Gitea can be edited and their assets replaced; nothing there is immutable. Treat a published version as final anyway (see "A broken release").
Cutting a release, start to finish
-
Open the release checklist issue from the KofTwentyTwo standards' release checklist template, titled
Release vX.Y.Z, and work through it: the conformance checker (tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/gclo, attach the output), open scanning alerts, and for a MINOR or MAJOR the threat-model review (docs/security/threat-model.md). -
Make sure
mainis green and thereleaseenvironment holds every token the table above lists for the kind of release you are cutting. Check, from a machine outside the local network and without signing in, thathttps://git.kof22.com/KofTwentyTwo/gclo/releasesand one asset of an earlier release answer. If they do not, stop: the updater of the version you are about to publish reads only that server. -
Rehearse on a prerelease. Tag
vX.Y.Z-beta.Nfirst: it runs the whole pipeline except the stable-only publishers, on thedevchannel. Installgclo-dev-dev-Setup.exeon a clean machine, run a Quick Sync, and let an installed earlier prerelease self-update to it. Copy each prerelease to git.kof22.com first ("The release on git.kof22.com"): the self-update is read from there, and it is tried from a machine outside the local network. A version that changes where updates are read (1.1.1 did) needs two prereleases for this, one to install and one to be updated to. Only then tag the stable version. -
Pick the version. Follow semver: breaking change → major, new feature → minor, fix → patch. The
gclo.Enginepackage is judged by its own public surface (git diff <last tag>..HEAD -- gclo.Engine): when a public member changed and the app still ships as a patch, leaveNUGET_PUBLISHoff for that release, or number the release for the package, and say which in the notes. -
Tag and push (the
protect-release-tagsruleset lets only the repository admin create av*tag):git checkout main git pull git tag -s v1.2.3 # or v1.2.3-beta.1 for a dev-channel prerelease git push origin v1.2.3 -
Watch the workflow under the Actions tab:
- Verify tag — the tagged commit must be contained in
main. A tag on any other commit fails here and nothing is built or published. - Gates — the full
ciworkflow, exactly as a pull request runs it, including the FlaUI end-to-end suite. If a gate fails, fix the cause onmainand cut a new patch version; never reuse the tag. - Smoke test the published app — publishes the app with the shared
workflow's exact commands and runs the FlaUI suite against that published
gclo.exe. The gates test whatdotnet buildproduces; this tests what ships (1.0.1 passed every gate and crashed at startup, #87). Nothing is published until it passes. - Release (shared workflow): locked restore, Release build with warnings
as errors, tests with the 100% coverage gate, publish, Velopack pack, SBOMs,
SHA256SUMS, provenance, a draft GitHub Release with every asset and generated notes, then publish — releases are immutable, so everything lands while the release is still a draft. - Verify signatures on the published assets —
signtool verify /pa /allon Setup.exe and every gclo executable inside the portable and CLI zips, checking the publisher name and the timestamp. A failure here means a published release is unsigned: handle it as "A broken release". - Scoop and Chocolatey, winget, NuGet — the publishers described above; each fails loudly when it cannot publish.
- Verify tag — the tagged commit must be contained in
-
Copy the release to git.kof22.com. On GitHub, where the release was built,
gh attestation verify <asset> --repo KofTwentyTwo/gclo --signer-repo KofTwentyTwo/standardspasses for each asset. Then create the release on Gitea with the same tag, the same assets and the same prerelease flag ("The release on git.kof22.com"). For a stable release: re-run thepackagesjob, which could not finish before the download was there, and commit the three packaging files on Gitea through a pull request (Update-PackageManifests.ps1with the CLI zip from the GitHub release, see "Scoop and Chocolatey"). -
Verify. On the releases page every asset listed above is attached, with the same hashes as in
SHA256SUMSand the right prerelease flag. Install the previous version and let it update itself to this one; then, from a machine outside the local network, the updated app answers "up to date" in Help > Check for updates (it reads git.kof22.com from now on). The packaging pull request on Gitea is merged andscoop updateoffers the version. winget is updated by hand ("First winget submission"). Tick the "Ship" section of the checklist issue. -
If something failed, where it failed decides the recovery:
-
In
verify,gates, or insidereleasebefore "Publish release": nothing is public. Delete the draft release if one was created (gh release delete v1.2.3 --yes), fix the cause through a pull request, and re-run the workflow from the Actions UI; the tag is unchanged and everything is rebuilt from it. A fix that changes the shipped code needs a new patch version instead. -
In a publisher after the release was published: the release is fine and frozen. Add the missing token or variable and re-run the failed job only; do not re-run the whole workflow, which would try to recreate the release. The manual equivalents, should a job keep failing:
# Scoop + Chocolatey manifests, then push the package and open the PR by hand ./.github/scripts/Update-PackageManifests.ps1 -Version 1.2.3 -CliZipPath .\gclo-cli-win-x64-1.2.3.zip choco pack packaging/chocolatey/gclo.nuspec --outputdirectory artifacts choco push artifacts\gclo.1.2.3.nupkg --source https://push.chocolatey.org/ --api-key <CHOCO_API_KEY> # winget manifest update (stable releases only) .\wingetcreate.exe update KofTwentyTwo.gclo --urls https://git.kof22.com/KofTwentyTwo/gclo/releases/download/v1.2.3/gclo-stable-Setup.exe --version 1.2.3 --submit --token <WINGET_TOKEN> -
The published release itself is broken: do not fix it in place and do not delete it; see "A broken release".
-
A broken release
A published version is never deleted or reused (K22-REL-07): installed apps, package managers, and attestations may already refer to it. (GitHub enforces that; Gitea does not, so it is a rule to keep, not one to rely on.) Instead:
- Edit the broken release's notes to say what is wrong and which version fixes
it, and mark it as a prerelease if stable users must stop receiving it. Do
both on git.kof22.com, which is where installed apps from 1.1.1 on and the
package managers look (edit the release there, or
tea releases edit --repo KofTwentyTwo/gclo --prerelease true v1.2.3), and on GitHub for the versions up to 1.1.0 that still look there (gh release edit v1.2.3 --notes-file ...,gh release edit v1.2.3 --prerelease). - Fix the cause on
mainthrough a normal pull request. - Tag the next patch version and let the pipeline publish it.
The protect-release-tags ruleset blocks tag deletion and updates for everyone;
that is deliberate and is not to be bypassed.