Verify a release before you trust it¶
A self-updating tool downloads bytes and then runs them. This tutorial builds the check that sits between those two events: fetch a release, get its checksums manifest, compare, and refuse the download when the hashes disagree.
You'll end up with a program you can run twice, once against a good release and once against a tampered one, and watch it accept the first and reject the second.
It runs entirely in memory. No network, no forge account, no token. That is deliberate: the interesting half of verification is the rejection, and you can't tamper with somebody else's release to see it.
Allow about twenty minutes.
Before you start¶
You'll need Go 1.26.6 or later, which is what go.mod requires. Nothing else.
mkdir releasecheck && cd releasecheck
go mod init example.com/releasecheck
go get gitlab.com/phpboyscout/go/forge
Get the manifest, whichever way this provider offers it¶
There are two routes to a checksums manifest, and a caller has to handle both.
Most releases ship checksums.txt as an ordinary asset, so you find it by name.
Some sources can't do that. The direct
provider composes the URL from a template that may point outside the asset
listing entirely, so those implement the optional ChecksumProvider interface.
Put this in verify.go:
package main
import (
"context"
"errors"
"fmt"
"gitlab.com/phpboyscout/go/forge"
)
// checksums returns the manifest bytes, preferring the optional capability and
// falling back to the asset named checksums.txt.
func checksums(ctx context.Context, p forge.Provider, owner, repo string, rel forge.Release) ([]byte, error) {
var cp forge.ChecksumProvider
if forge.As(p, &cp) {
manifest, err := cp.DownloadChecksumManifest(ctx, rel, forge.DefaultMaxChecksumsSize)
switch {
case err == nil:
fmt.Println("manifest source: ChecksumProvider")
return manifest, nil
case errors.Is(err, forge.ErrNotSupported):
// Configured off for this release. Fall through.
default:
return nil, err
}
}
fmt.Println("manifest source: checksums.txt in the asset list")
return download(ctx, p, owner, repo, rel, "checksums.txt")
}
Two details here bite if you get them wrong, so they're worth stating at the step:
forge.As rather than a bare type assertion. A decorator that forwards only the
four required Provider methods (a logging or metrics shim) strips the
optional interfaces, and a bare assertion would then report the capability
absent. Since absence is a graceful fallback, verification would quietly
downgrade instead of failing.
errors.Is(err, forge.ErrNotSupported) is not a failure. It means the provider
implements the interface but is configured with it switched off, and it must be
treated exactly like "does not implement the interface": fall back. Any other
error is real, and returning it is right.
forge.DefaultMaxChecksumsSize is 1 MiB. It's the bound you choose and the
provider enforces. See configuration
if your artefacts are unusual.
Read one asset out of a release¶
Add download to the same file. It walks the release's assets, finds the one
you asked for, and reads it:
// download reads one named asset out of the release.
func download(
ctx context.Context,
p forge.Provider,
owner, repo string,
rel forge.Release,
name string,
) ([]byte, error) {
for _, a := range rel.GetAssets() {
if a.GetName() != name {
continue
}
rc, redirect, err := p.DownloadReleaseAsset(ctx, owner, repo, a)
if err != nil {
return nil, err
}
if redirect != "" {
return nil, fmt.Errorf("asset %s redirects to %s: refusing to follow", name, redirect)
}
defer func() { _ = rc.Close() }()
return io.ReadAll(rc)
}
return nil, fmt.Errorf("release %s has no asset named %s", rel.GetTagName(), name)
}
Add "io" to the imports.
That redirect != "" branch is the part people delete because it never fires in
their tests. Refusing is the correct behaviour: a non-empty redirect means the
provider was handed a location it did not follow, pointing at a host nobody
vetted. Following it yourself would fetch bytes from an address the release
author chose. See credential pinning for
why that address is not trustworthy.
Compare the hash and refuse a mismatch¶
Now the check itself:
// errChecksumMismatch is returned when the bytes do not match the manifest.
var errChecksumMismatch = errors.New("checksum mismatch: refusing the download")
// fetchAndVerify downloads the named asset from the latest release and checks it
// against that release's checksums manifest.
func fetchAndVerify(ctx context.Context, p forge.Provider, owner, repo, asset string) ([]byte, error) {
rel, err := p.GetLatestRelease(ctx, owner, repo)
if err != nil {
return nil, err
}
fmt.Println("latest release:", rel.GetTagName())
manifest, err := checksums(ctx, p, owner, repo, rel)
if err != nil {
return nil, err
}
body, err := download(ctx, p, owner, repo, rel, asset)
if err != nil {
return nil, err
}
want, ok := lookup(manifest, asset)
if !ok {
return nil, fmt.Errorf("%s is not listed in checksums.txt", asset)
}
got := sha256.Sum256(body)
if hex.EncodeToString(got[:]) != want {
return nil, errChecksumMismatch
}
return body, nil
}
// lookup finds name's recorded digest in a GoReleaser-style manifest.
func lookup(manifest []byte, name string) (string, bool) {
for line := range strings.Lines(string(manifest)) {
fields := strings.Fields(line)
if len(fields) == 2 && fields[1] == name {
return fields[0], true
}
}
return "", false
}
Add "crypto/sha256", "encoding/hex" and "strings" to the imports.
Note the !ok branch. An asset the manifest doesn't mention is unverified, not
verified. Treating a missing line as a pass is the same failure as skipping the
check.
Serve a release without a forge¶
forgetest.Source is a complete in-memory forge.Provider. It's a real
implementation rather than a mock, so the code above runs the same paths it would
against GitHub or GitLab, including the fallback you wrote.
Put this in main.go:
package main
import (
"context"
"fmt"
"log"
"os"
forgetest "gitlab.com/phpboyscout/go/forge/test"
)
func main() {
tampered := len(os.Args) > 1 && os.Args[1] == "--tampered"
binary := forgetest.TarGzAsset("mytool", "mytool", "#!/bin/sh\necho hello\n")
src := forgetest.New(
forgetest.WithRelease("v1.2.3",
binary,
forgetest.ChecksumsAsset(tampered, binary),
),
forgetest.WithLatestTag("v1.2.3"),
)
body, err := fetchAndVerify(context.Background(), src, "acme", "mytool", binary.Name)
if err != nil {
log.Fatalf("update refused: %v", err)
}
fmt.Printf("verified %s (%d bytes)\n", binary.Name, len(body))
}
Alias the import. A bare test collides the moment a test needs helpers from two
modules.
TarGzAsset builds a real gzipped tarball, and ChecksumsAsset a real
GoReleaser-style manifest over it. That first argument is the corrupt flag: pass
true and the manifest records hashes of a different payload, which is exactly
what a tampered release looks like from the outside.
Run it, and watch it accept¶
latest release: v1.2.3
manifest source: checksums.txt in the asset list
verified mytool_Linux_x86_64.tar.gz (114 bytes)
The asset name and byte count depend on your platform. AssetName follows the
{tool}_{OS}_{arch}.tar.gz convention, so macOS on Apple silicon gives
mytool_Darwin_arm64.tar.gz.
The manifest came from the asset list, because a bare Source returns
ErrNotSupported from both optional methods. Your fallback ran, which is the
path most real releases take.
Run it again, and watch it refuse¶
latest release: v1.2.3
manifest source: checksums.txt in the asset list
2026/01/01 00:00:00 update refused: checksum mismatch: refusing the download
exit status 1
That is the half worth testing. A verifier that accepts a good artefact proves very little, because one that accepts everything also passes. What matters is that a tampered one stops here rather than being written over a running binary.
Exercise the capability path too¶
So far only the fallback has run. Opt the double into ChecksumProvider by
adding one option to main.go:
src := forgetest.New(
forgetest.WithRelease("v1.2.3",
binary,
forgetest.ChecksumsAsset(tampered, binary),
),
forgetest.WithLatestTag("v1.2.3"),
forgetest.WithChecksumManifest(forgetest.Manifest(tampered, binary)),
)
latest release: v1.2.3
manifest source: ChecksumProvider
verified mytool_Linux_x86_64.tar.gz (114 bytes)
The printed source flips, and the verdict doesn't. Both routes reach the same
bytes, which is what makes the fallback safe rather than second-best. Run it with
--tampered once more and it refuses through this route as well.
What this does not cover¶
Checksums prove the bytes are the bytes the manifest names. They prove nothing
about who wrote the manifest, and an attacker who can replace an asset can usually
replace checksums.txt alongside it. Closing that gap needs a detached
signature over the manifest, verified against a key you already trust.
forge supplies the retrieval half of that too: SignatureProvider mirrors
ChecksumProvider exactly, and forgetest.SignatureAsset builds a real OpenPGP
signature, with a bad flag that signs different bytes, so you can test the
rejection the same way. The verification itself is your consumer's job, or
signing's.
The direct provider is the other thing worth knowing about here. Its synthetic
release exposes a single asset named <tag>.tar.gz, whatever the URL template
produces, so matching assets by filename the way this tutorial does will not find
it. See use the direct provider.
Where next¶
- Verify checksums and signatures: the same ground as a how-to, for when you're wiring this into a real tool
- Test against a forge: the rest of
forgetest, and when to reach for a mock instead - Optional capabilities: why these are opt-in interfaces rather than required methods
- Errors: every sentinel, and what to do with each