Publish from Go

Use the Go module when your Go release tool should sign releases itself. At the end, your program writes a signed packslip.sigstore.json for the files it built.

To sign from a shell step instead, see Publish with GitHub Actions or Publish from other CI. To verify releases from code, see Choose a library.

Know the limits

The module builds a statement from artifact metadata you pass, validates it, and signs it keylessly with Sigstore only. To sign with a key, run packslip create --key (Sign with a key). Unlike packslip create, Build does not open archives, check that a Bin path exists, or derive host requirements.

The module is not a GoReleaser adapter or a release CLI. Mapping GoReleaser configuration, discovering archives, and uploading files belong in an adapter owned by the release tool or its users.

Add the module

go get github.com/jdx/packslip/go@latest

The module requires Go 1.25.8 or newer. Import it as packslip:

import packslip "github.com/jdx/packslip/go"

Module version vX.Y.Z is tagged go/vX.Y.Z (Go requires the subdirectory prefix) on the commit of CLI release X.Y.Z, and the normal Go module proxy serves it. To match the CLI version you use elsewhere, name it:

go get github.com/jdx/packslip/go@vX.Y.Z

Sign a release

This program signs one archive of the GitHub project github.com/owner/mytool. Run it in a GitHub Actions job of owner/mytool with id-token: write; consumers accept the bundle only when a workflow run in that repository signed it.

package main

import (
	"context"
	"log"
	"os"
	"time"

	packslip "github.com/jdx/packslip/go"
)

func main() {
	if err := run(context.Background()); err != nil {
		log.Fatal(err)
	}
}

func run(ctx context.Context) error {
	oidcToken, err := packslip.AmbientIDToken(ctx)
	if err != nil {
		return err
	}
	identity, err := packslip.IdentityFromToken(oidcToken)
	if err != nil {
		return err
	}

	const base = "https://github.com/owner/mytool/releases/download/v1.2.3/"
	predicate := packslip.Predicate{
		Project:     "github.com/owner/mytool",
		Version:     "1.2.3",
		PublishedAt: time.Now().UTC(),
		Source:      &packslip.Source{Repo: "https://github.com/owner/mytool", Tag: "v1.2.3"},
		Identity:    identity,
	}
	inputs := []packslip.ArtifactInput{{
		Path: "dist/mytool-1.2.3-linux-x64.tar.gz",
		Artifact: packslip.Artifact{
			Name:   "mytool-1.2.3-linux-x64.tar.gz",
			URL:    base + "mytool-1.2.3-linux-x64.tar.gz",
			OS:     "linux",
			Arch:   packslip.ArchFromGo("amd64", ""),
			Format: "tar.gz",
			Bin:    []packslip.Bin{packslip.NewBin("mytool")},
		},
	}}
	statement, err := packslip.Build(predicate, inputs)
	if err != nil {
		return err
	}

	options, err := packslip.ProductionKeylessOptions(ctx, oidcToken)
	if err != nil {
		return err
	}
	bundle, err := packslip.SignKeyless(statement, options)
	if err != nil {
		return err
	}
	return os.WriteFile("dist/packslip.sigstore.json", bundle, 0o644)
}

Before you publish, check the bundle. For a GitHub project, packslip verify derives the signer from the project's name:

packslip verify dist/packslip.sigstore.json --artifact dist/mytool-1.2.3-linux-x64.tar.gz

Upload the bundle beside the artifacts as packslip.sigstore.json. When several tools of a monorepo share one GitHub release, name each bundle after its tool's subpath, such as packslip.mytool.sigstore.json for github.com/owner/repo/mytool (Monorepos). BundleName(project, repo) gives the name. With repo set to github.com/owner/repo, the project github.com/owner/repo/mytool is packslip.mytool.sigstore.json. The repository itself, or a project outside it, gets packslip.sigstore.json.

To catch a wrong Project before anything is signed, call CheckProjectSigner(project, oidcToken) after AmbientIDToken. A github.com project must belong to the GitHub Actions repository that runs the job, and a gitlab.com project must be the GitLab CI project. A reusable workflow of another repository is fine. Otherwise consumers refuse the bundle after it is in the transparency log. A domain project always passes, because its vendor pins the signer. The check reads the token without verifying it, and it also fails for a project that names a repository's old name.

Map a Go build matrix

ArchFromGo(goarch, goarm) maps a GOARCH to the documented architecture vocabulary (Vocabularies): amd64 to x86_64, arm64 to aarch64, 386 to i686, ppc64le to powerpc64le, and loong64 to loongarch64. Other architectures take Rust's spelling (mipsle to mipsel, mips64le to mips64el, ppc64 to powerpc64) or pass through unchanged. all, GoReleaser's universal macOS binary, gives no arch, so consumers treat the artifact as running on every architecture.

For arm, it appends GOARM to armv, as in armv7. An empty GOARM means 6, GoReleaser's default. A plain Go cross-build with GOARM unset builds for 7, so pass the value your build used. A floating-point option is ignored, so 7,softfloat counts as 7. Values other than 6 and 7 fall outside the vocabulary.

A GoTarget holds one build's settings (GOARCH, GOARM, GOAMD64, GO386, GOARM64, GOMIPS, GOPPC64, GORISCV64, and FloatABI), so a tool that stores them apart does not pick the right one itself. Arch() calls ArchFromGo. Variant() calls VariantFromGo with the setting for GOARCH. For arm it reads FloatABI, either Go's softfloat or hardfloat or a Rust or Zig target ABI such as gnueabi, through VariantFromRustABI:

target := packslip.GoTarget{GOARCH: "amd64", GOAMD64: "v3"}
artifact.Arch = target.Arch()       // "x86_64"
artifact.Variant = target.Variant() // "v3"

VariantFromGo(goarch, level) returns the variant that distinguishes non-default CPU-level and floating-point builds of one platform. Pass the Go setting for the architecture (GOAMD64, GO386, GOARM64, GOMIPS, GOPPC64, GORISCV64, or GOARM with its float option for arm): VariantFromGo("amd64", "v3") is v3, VariantFromGo("arm", "7,softfloat") is softfloat, ("arm", "5,hardfloat") is 5-hardfloat, and a default-level build such as ("amd64", "v1") has no variant. A comma in a level becomes -, so v8.2,lse is v8.2-lse. VariantFromRustABI(abi) does the same for a Rust target's ABI: gnueabi and musleabi are softfloat, and gnueabihf has none (Vocabularies).

NewBareBin(artifactName, format, command) names the executable inside a bare artifact (format raw, gz, xz, zst, or bz2): the artifact's name without the compression suffix, installed as command with any trailing .exe dropped. This entry installs mytool-1.2.3-darwin-arm64 as mytool:

name := "mytool-1.2.3-darwin-arm64.gz"
artifact := packslip.Artifact{
	Name:   name,
	OS:     "darwin",
	Arch:   packslip.ArchFromGo("arm64", ""),
	Format: "gz",
	Bin:    []packslip.Bin{packslip.NewBareBin(name, "gz", "mytool")},
}

Add separately uploaded assets

Pass each separately uploaded release asset that a resource references in packslip.BuildOptions. For a man page shipped as its own release file, replace the Build call in the program above with:

predicate.Resources = []packslip.Resource{{Kind: "man", Asset: "mytool.1"}}
statement, err := packslip.Build(predicate, inputs, packslip.BuildOptions{
	Assets: []packslip.AssetInput{{Name: "mytool.1", Path: "dist/mytool.1"}},
})

Assets are hashed into the statement's subjects but are not artifacts. Build refuses an asset that no resource names. Ship resources lists the resource kinds.

Sign a domain or GitLab project

To sign a domain project, or a gitlab.com project from GitLab CI:

  1. Set Project to the project's name, Source.Repo to the repository you build from, and each artifact's URL to where you upload it (Choose a project name).
  2. Outside GitHub Actions, put an OIDC token for the sigstore audience in SIGSTORE_ID_TOKEN. On GitLab CI, the id_tokens entry in Sign from GitLab CI does that.
  3. Check the bundle with packslip verify and the identity flags consumers will use. A gitlab.com project needs none.
  4. For a domain project, add each release to its signed release list with packslip releases; consumers refuse a domain project without one. The module does not build lists (Create the list).

The module cannot sign on Buildkite or CircleCI: Fulcio certifies a pipeline URL there, which never matches the derived identity, so SignKeyless fails. Sign those releases with packslip create --key (Sign with a key).

Prepare for later releases

Consumers hold each release to the ones they accepted before (Keep later releases acceptable). From the first release:

Check what signing establishes

CallEstablishesDoes not establish
Build, Statement.ValidateOnly structural validityWho made the statement
SignKeylesssigstore-go verified the returned bundle against the trust root in the options, and its certificate names the statement's identityThat the files are safe, or how they were built

Warning: Never treat parsed statement JSON as cryptographically verified, even when Statement.Validate accepts it. Verify the bundle with packslip verify or a library that verifies (Choose a library).

The Go package reference documents every exported name; the module README summarizes the API.