fix(update): review of #171: say what the updater verifies, guard the manifests, and bring the runbook along

- Threat model T1 and ADR 0009 state that the updater checks hashes from the
  same feed and no signature: whoever can publish on the server can deliver an
  update. T1 is accepted, not mitigated.
- Update-PackageManifests.ps1 refuses to write a manifest whose download does
  not answer, so a package cannot be pushed before its release is on the
  server.
- RELEASING.md: what a release on git.kof22.com must contain, the prerelease
  flag, the ten-release window, and where a broken release is withdrawn.
- PackagingManifestTests pin the auto-update URL, the hash URL, the release
  notes link and the version check exactly.
- UpdateService no longer says GitHub; the Chocolatey license and icon links
  and ADR 0001/0003 follow.

Refs #170

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandClaude Fable 5.2 committed 2026-11-10 20:16:18 -06:00
1 parent 93213f49ac
commit 6a26883e0e
10 files changed
+108 -31

No files matched your search

+21 -1
View File
@@ -29,7 +29,10 @@ param(
[string] $CliZipPath,
[string] $Sha256
[string] $Sha256,
# Skips the check that the download is on the server (offline use, a rehearsal).
[switch] $SkipDownloadCheck
)
Set-StrictMode -Version Latest
@@ -78,6 +81,23 @@ if($Sha256 -notmatch '^[0-9a-f]{64}$')
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$url = "https://git.kof22.com/KofTwentyTwo/gclo/releases/download/v$Version/gclo-cli-win-x64-$Version.zip"
################################################################################
## The manifests name the download on git.kof22.com (ADR 0009). A package that ##
## points at a file that is not there yet fails for everyone who installs it, ##
## so the release must be on that server before its manifests are written. ##
################################################################################
if(-not $SkipDownloadCheck)
{
try
{
$null = Invoke-WebRequest -Uri $url -Method Head -MaximumRedirection 5 -TimeoutSec 60 -ErrorAction Stop
}
catch
{
throw "$url does not answer ($($_.Exception.Message)). Publish the release and its assets on git.kof22.com first, then run this again."
}
}
################################################################################
## Scoop: a real JSON edit, so the file stays valid whatever its layout. ##
################################################################################
+1 -1
View File
@@ -120,7 +120,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca
**Share:** Help > Share gclo by email… opens a new message in your email program with an invitation already written: what gclo is, where to download the latest release, and how to install it. Type an address and send; gclo itself sends nothing. If no email program is set up, the text is shown and copied to the clipboard, to paste into a message by hand.
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel from the project's releases on git.kof22.com. Versions up to 1.1.0 look for updates on GitHub and do not find newer ones: install 1.1.1 or later once by hand, and it updates itself from then on. Self-update is only available in installed builds (not the portable zip or a local debug build).
**Updates:** Help > Check for updates… downloads and applies the latest release for your channel from the project's releases on git.kof22.com. Versions up to 1.1.0 look for updates on GitHub; if they do not find 1.1.1 there, install it once by hand, and it updates itself from then on. Self-update is only available in installed builds (not the portable zip or a local debug build).
### Saved accounts
+43 -7
View File
@@ -200,6 +200,35 @@ Both install the same asset, `gclo-cli-win-x64-<version>.zip`, by URL and SHA-25
- To re-point the manifests by hand (for example after a missed release):
`./.github/scripts/Update-PackageManifests.ps1 -Version 1.2.3 -CliZipPath <zip>`.
## 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](adr/0009-updates-and-downloads-from-gitea.md)). 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 `.nupkg` packages (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 and `SHA256SUMS`.
- **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.ps1`
refuses to write a manifest whose download does not answer, so the `packages`
job fails until the copy is done; run it again afterwards.
- **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.
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
1. **Open the release checklist issue** from the KofTwentyTwo standards'
@@ -255,9 +284,12 @@ Both install the same asset, `gclo-cli-win-x64-<version>.zip`, by URL and SHA-25
above; each fails loudly when it cannot publish.
7. **Verify.** On the [releases page](https://git.kof22.com/KofTwentyTwo/gclo/releases)
every asset listed above is attached and
every asset listed above is attached, with the same hashes as in `SHA256SUMS`
and the right prerelease flag (see "The release on git.kof22.com"). On GitHub,
where the release was built,
`gh attestation verify <asset> --repo KofTwentyTwo/gclo --signer-repo KofTwentyTwo/standards`
passes. Merge the packaging PR. Check the winget PR on microsoft/winget-pkgs.
passes. Install the previous version and let it update itself to this one.
Merge the packaging PR. Check the winget PR on microsoft/winget-pkgs.
Tick the "Ship" section of the checklist issue.
8. **If something failed**, where it failed decides the recovery:
@@ -288,13 +320,17 @@ Both install the same asset, `gclo-cli-win-x64-<version>.zip`, by URL and SHA-25
### A broken release
Releases are immutable and a published version is never deleted or reused
(K22-REL-07): installed apps, package managers, and attestations may already
refer to it. Instead:
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:
1. Edit the broken release's notes to say what is wrong and which version fixes
it (`gh release edit v1.2.3 --notes-file ...`), and mark it as a prerelease if
stable users must stop receiving it (`gh release edit v1.2.3 --prerelease`).
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 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`).
2. Fix the cause on `main` through a normal pull request.
3. Tag the **next** patch version and let the pipeline publish it.
@@ -1,6 +1,6 @@
# ADR-0001: Use Velopack for install and self-update from GitHub Releases
- **Status:** Accepted
- **Status:** Accepted; amended by [ADR 0009](0009-updates-and-downloads-from-gitea.md) (releases are read from git.kof22.com)
- **Date:** 2026-07-05 (recorded 2026-10-09)
- **Pull request:** #64
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-0003: Make this repository its own Scoop bucket and ship the Chocolatey package source
- **Status:** Accepted
- **Status:** Accepted; amended by [ADR 0009](0009-updates-and-downloads-from-gitea.md) (releases are read from git.kof22.com)
- **Date:** 2026-10-09
- **Pull request:** #59
@@ -27,12 +27,23 @@ would install an old version for ever.
## Consequences
- Versions up to 1.1.0 look for updates on GitHub and will not find 1.1.1 or later.
They are updated once by hand; from 1.1.1 on the app updates itself.
- Versions up to 1.1.0 look for updates on GitHub. They find a later version only
if it is also published there; otherwise they are updated once by hand. From
1.1.1 on the app looks on `git.kof22.com`.
- The availability and the integrity of updates now rest on a self-hosted server
and its TLS certificate instead of GitHub's release hosting. Packages are still
verified against the feed's hashes, and the binaries are still Authenticode-signed
(ADR 0008); the threat model records the change.
instead of GitHub's release hosting. The updater verifies a package against the
hash in the feed, and the feed comes from the same server; it checks no signature
and no attestation (it never did). Whoever can publish a release on that server,
or holds its TLS certificate or DNS, can therefore deliver an update that installs
without a warning. Authenticode signing (ADR 0008) protects a download a person
runs, not an update the app applies. The threat model's T1 says the same and is
marked accepted, not mitigated.
- The server must be reachable from wherever the app runs. An app outside the
network that can reach `git.kof22.com` reports that it could not check for
updates, and Scoop cannot add the bucket.
- Velopack reads the ten newest releases of any kind and then picks by channel.
More than nine prereleases published after the latest stable release would hide
that release from stable installs. Keep fewer, or delete old prereleases.
- Build-provenance attestations are a GitHub feature. While releases are still
built by the GitHub workflow and copied over, they exist and are verified against
GitHub as the README describes; a release built on Gitea needs another answer.
+3 -3
View File
@@ -29,7 +29,7 @@ GitHub.
| Local data directory | `%LOCALAPPDATA%\KofTwentyTwo\gclo` (or `GCLO_DATA_DIR`): settings, accounts, logs. Deliberately outside the Velopack install root `%LOCALAPPDATA%\gclo`, which Setup.exe deletes on reinstall (#91) | Trusted; same user |
| Target folder | Where repositories are written; may already contain repositories | Partly untrusted (pre-existing content, `.git` config) |
| WSL default distribution | Runs `git clone` for the "Clone in WSL" recovery | Semi-trusted (user-installed Linux environment) |
| Update feed | The releases of this project on `git.kof22.com`, `releases.<channel>.json` | Untrusted until verified (HTTPS, package hashes, attestations) |
| Update feed | The releases of this project on `git.kof22.com`, `releases.<channel>.json` | Untrusted transport, trusted publisher: HTTPS, and package hashes taken from the same feed (see T1) |
| Package managers | Scoop bucket (this repo), Chocolatey, winget | Untrusted transport; assets verified by hash |
## 3. Data flow and trust boundaries
@@ -83,7 +83,7 @@ flowchart LR
| # | Boundary / component | Category | Threat | Likelihood | Impact | Mitigation | Status |
| --- | --- | --- | --- | --- | --- | --- | --- |
| T1 | Update feed | Tampering | Attacker serves a malicious update | Low | Critical | HTTPS to this repository's Releases only; Velopack hash verification; immutable releases; provenance and SBOM attestations; Scorecard; Authenticode pending (EX-0002) | Mitigated (signing pending) |
| T1 | Update feed | Tampering | Attacker serves a malicious update | Low | Critical | HTTPS to the project's releases on `git.kof22.com` only (ADR 0009); Velopack verifies each package against the size and hash in the feed. The feed and the packages come from the same server, and the updater does not check an Authenticode signature or an attestation: whoever controls that server, the repository's release permission, or the server's TLS certificate can deliver a package that installs without a warning. The mitigations are therefore the server's own: who may publish a release there, how its certificate and DNS are held. Releases are not immutable on Gitea. | Accepted for 1.1.1; depends on the server's access control |
| T2 | Package managers / downloads | Tampering | Modified installer or CLI zip on a mirror | Low | High | `SHA256SUMS`; Scoop and Chocolatey manifests pin the hash; "Verifying a release" instructions | Mitigated |
| T3 | Git remotes | Tampering / EoP | Repository with Windows-invalid or traversal paths (`..`, reserved names, case collisions) writes outside or corrupts the target | Medium | High | Pre-checkout validation of every incoming path, which also refuses a `.git` segment in any spelling Windows opens as that folder; `RepositoryPathResolver` containment; recovery by rename/skip or clone in WSL; `core.longpaths`. The recovery materializes the tree itself, so every write and delete it makes goes through `WorkingTree`: inside the working directory, never in `.git`, never through a link, and an existing file is replaced rather than written through (#141). Residual: the link check and the write are two steps, so a link swapped in between them by something already running as the user is not caught | Mitigated |
| T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees; a folder is fetched only when its `origin` names the listed repository, and the remote contacted is the listed URL, not origin's spelling of it; a `url.<base>.insteadOf` rewrite in the folder's own configuration is refused, and so is its `core.sshCommand` when the fetch is not HTTPS (`RemoteUrl`, #139). The user's global git configuration still applies, as it does to git itself. The recovery file `.git\gclo-recovery.json` is validated when it is loaded: a listed path that is not in the working tree fails the row instead of being deleted (#141) | Mitigated for the remote, the transport program, and the recovery file; other `.git` content of an existing folder is still trusted |
@@ -121,4 +121,4 @@ flowchart LR
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T3/T4 updated: path recovery writes and deletes only through `WorkingTree` (inside the working directory, never in `.git`, never through a link); `.git` and its Windows aliases are invalid path segments; the stored recovery file's paths are validated on load; cleanup of a failed clone does not follow links (#141) |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | T15 added: text from repositories and servers is shown with control, separator and format characters made visible, in the activity log and in everything the CLI prints as text, including characters a legacy console code page would turn into control bytes; the desktop app's surfaces are named as not covered (#143) |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | The project's home moved to `git.kof22.com`: the Help menu, the About dialog, the CLI's error text and the documentation link there, and the Scoop bucket is added from there (the manifest that pins the download URL and its hash now comes from that server over HTTPS; T2 is unchanged in kind). Help > Share hands a fixed text (what gclo is, where to download it, the sender's gclo version) to the system's mail handler as a `mailto:` link with no recipient, or shows it and copies it to the clipboard; nothing about the user, their accounts, or their repositories is in it, and the address it is built from must be a plain http(s) host and path (#163, #165). The update feed and the release downloads named in package manifests are still GitHub Releases at this revision. |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | Update feed and release downloads moved from GitHub Releases to the project's releases on `git.kof22.com` (ADR 0009, #170): the in-app updater reads the feed with Velopack's Gitea source, anonymously over HTTPS, and the Scoop and Chocolatey manifests download from there. Packages are verified as before (feed hashes for updates, pinned SHA-256 for the manifests, Authenticode signatures on the binaries). The trust that was placed in GitHub's release hosting is now placed in a self-hosted server: whoever controls that server or its TLS certificate can offer a release; a package they offer still has to carry the publisher's Authenticode signature to install without a warning, but the updater itself checks hashes from the same feed, not the signature. T2 covers the manifests; the residual risk for the update feed is accepted for 1.1.1. |
| 2026-11-10 | v1.1.1 | James Maes (@KofTwentyTwo) | Update feed and release downloads moved from GitHub Releases to the project's releases on `git.kof22.com` (ADR 0009, #170): the in-app updater reads the feed with Velopack's Gitea source, anonymously over HTTPS, and the Scoop and Chocolatey manifests download from there. The updater verifies packages against the hashes in the feed, as before; it has never checked a signature or an attestation. The trust that was placed in GitHub's release hosting (account security, immutable releases) is now placed in a self-hosted server: whoever can publish a release there, or holds its TLS certificate or DNS, can deliver an update that installs without a warning. T1 is rewritten to say so and its status changed from mitigated to accepted. The manifests still pin a SHA-256 (T2). |
+17 -7
View File
@@ -57,16 +57,24 @@ public sealed class PackagingManifestTests
JsonElement x64 = root.GetProperty("architecture").GetProperty("64bit");
Assert.Equal("gclo.exe", root.GetProperty("bin").GetString());
Assert.Equal("MIT", root.GetProperty("license").GetString());
// The newest version is asked of the project's own server (ADR 0009): the
// tag of its latest release, 'v1.2.3' read as '1.2.3'.
/////////////////////////////////////////////////////////////////////////////
// Everything is asked of the project's own server (ADR 0009): the newest //
// version (the tag of its latest release, 'v1.2.3' read as '1.2.3'), the //
// download of a later version, its hash, and the pages the notes name. //
/////////////////////////////////////////////////////////////////////////////
JsonElement checkver = root.GetProperty("checkver");
Assert.Equal("https://git.kof22.com/api/v1/repos/KofTwentyTwo/gclo/releases/latest", checkver.GetProperty("url").GetString());
Assert.Equal("$.tag_name", checkver.GetProperty("jsonpath").GetString());
Assert.Equal("1.2.3", Regex.Match("v1.2.3", checkver.GetProperty("regex").GetString()!, RegexOptions.None, TimeSpan.FromSeconds(1)).Groups[1].Value);
Assert.Equal(@"v([\d.]+)", checkver.GetProperty("regex").GetString());
Assert.Equal("https://git.kof22.com/KofTwentyTwo/gclo", root.GetProperty("homepage").GetString());
string autoupdateUrl = root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("url").GetString()!;
Assert.Contains("v$version/gclo-cli-win-x64-$version.zip", autoupdateUrl);
Assert.EndsWith("/SHA256SUMS", root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit").GetProperty("hash").GetProperty("url").GetString());
JsonElement autoupdate = root.GetProperty("autoupdate").GetProperty("architecture").GetProperty("64bit");
Assert.Equal(
"https://git.kof22.com/KofTwentyTwo/gclo/releases/download/v$version/gclo-cli-win-x64-$version.zip",
autoupdate.GetProperty("url").GetString());
Assert.Equal("$baseurl/SHA256SUMS", autoupdate.GetProperty("hash").GetProperty("url").GetString());
Assert.All(
root.GetProperty("notes").EnumerateArray().Select(note => note.GetString()!),
note => Assert.DoesNotContain("github.com/KofTwentyTwo", note, StringComparison.Ordinal));
return (root.GetProperty("version").GetString()!, x64.GetProperty("url").GetString()!, x64.GetProperty("hash").GetString()!);
}
@@ -79,7 +87,9 @@ public sealed class PackagingManifestTests
XElement metadata = nuspec.Root.Element(ns + "metadata")!;
Assert.Equal("gclo", metadata.Element(ns + "id")!.Value);
string version = metadata.Element(ns + "version")!.Value;
Assert.EndsWith($"/releases/tag/v{version}", metadata.Element(ns + "releaseNotes")!.Value);
Assert.Equal($"https://git.kof22.com/KofTwentyTwo/gclo/releases/tag/v{version}", metadata.Element(ns + "releaseNotes")!.Value);
Assert.DoesNotContain("github.com/KofTwentyTwo", nuspec.ToString(), StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("githubusercontent.com", nuspec.ToString(), StringComparison.OrdinalIgnoreCase);
string install = File.ReadAllText(Path.Combine(RepoRoot, "packaging", "chocolatey", "tools", "chocolateyinstall.ps1"));
string url = Regex.Match(install, @"url64bit\s*=\s*'(?<value>[^']+)'", RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)).Groups["value"].Value;
+3 -3
View File
@@ -16,7 +16,7 @@ namespace gclo.Services;
/// <summary>
/// Self-update via Velopack, with releases hosted on the app's public GitHub repository.
/// Self-update via Velopack, with releases hosted on the project's public repository (ADR 0009).
/// Updates only work in Velopack-installed builds: under F5, loose unpackaged builds, or
/// MSIX packages <see cref="IsSupported"/> is false. Every member is guarded — failures are
/// reported as strings and must never crash the app over something as optional as an update.
@@ -85,7 +85,7 @@ public sealed class UpdateService
/// <summary>
/// Checks GitHub releases for a newer version and remembers it for
/// Checks the project's releases for a newer version and remembers it for
/// <see cref="DownloadAndApplyAsync"/>. Never throws: failures come back in
/// <see cref="UpdateCheckResult.Error"/>.
/// </summary>
@@ -103,7 +103,7 @@ public sealed class UpdateService
}
catch(Exception ex)
{
_log.Error($"Update check failed before contacting GitHub: {ex.Message}", ex);
_log.Error($"Update check failed before contacting the server: {ex.Message}", ex);
return new UpdateCheckResult(null, ex.Message);
}
+2 -2
View File
@@ -16,9 +16,9 @@
<packageSourceUrl>https://git.kof22.com/KofTwentyTwo/gclo/src/branch/main/packaging/chocolatey</packageSourceUrl>
<docsUrl>https://git.kof22.com/KofTwentyTwo/gclo/src/branch/main/docs/CLI.md</docsUrl>
<bugTrackerUrl>https://git.kof22.com/KofTwentyTwo/gclo/issues</bugTrackerUrl>
<licenseUrl>https://raw.githubusercontent.com/KofTwentyTwo/gclo/main/LICENSE</licenseUrl>
<licenseUrl>https://git.kof22.com/KofTwentyTwo/gclo/raw/branch/main/LICENSE</licenseUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<iconUrl>https://raw.githubusercontent.com/KofTwentyTwo/gclo/main/gclo/Assets/Brand/gclo-icon.svg</iconUrl>
<iconUrl>https://git.kof22.com/KofTwentyTwo/gclo/raw/branch/main/gclo/Assets/Brand/gclo-icon.svg</iconUrl>
<tags>git github clone mirror organization cli</tags>
<summary>Clone or update every repository of a GitHub organization in one pass (CLI).</summary>
<description><![CDATA[