Skip to content

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

go mod tidy
go run .
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

go run . --tampered
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)),
    )
go run .
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